Compare commits

..

3 Commits

Author SHA1 Message Date
nityanandagohain
6aec05cf7a fix: more tests 2026-07-09 08:45:22 +05:30
nityanandagohain
683a52f35a fix: take perf into consideration 2026-07-09 08:45:22 +05:30
nityanandagohain
e924fa1e62 feat: support llm trace list and span list 2026-07-09 08:45:20 +05:30
107 changed files with 3211 additions and 2487 deletions

View File

@@ -65,9 +65,7 @@ function TagKeyValueInput({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
// (e.g. a modal) can submit on it.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
@@ -95,17 +93,11 @@ function TagKeyValueInput({
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
// form (e.g. a modal) can submit on it.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
commitEdit();
} else if (e.key === 'Escape') {
// Contain Escape so it cancels the inline edit instead of bubbling up and
// closing the host drawer/modal.
e.preventDefault();
e.stopPropagation();
cancelEdit();
}
};

View File

@@ -1,4 +0,0 @@
.wrapper :global(button[data-color]) {
--checkbox-checked-background: var(--series-color);
--checkbox-border-color: var(--series-color);
}

View File

@@ -3,7 +3,6 @@ import { Checkbox } from '@signozhq/ui/checkbox';
import { CSSProperties } from 'react';
import { CheckBoxProps } from '../types';
import styles from './CustomCheckBox.module.scss';
function CustomCheckBox({
data,
@@ -16,11 +15,12 @@ function CustomCheckBox({
const isChecked = graphVisibilityState[index] || false;
const colorStyle = {
'--series-color': color,
'--checkbox-checked-background': color,
'--checkbox-border-color': color,
} as CSSProperties;
return (
<span className={styles.wrapper} style={colorStyle}>
<span style={colorStyle}>
<Checkbox
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
value={isChecked}

View File

@@ -1,20 +1,15 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { Dashboard } from 'types/api/dashboard/getAll';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
@@ -22,13 +17,6 @@ import dialsUrl from '@/assets/Icons/dials.svg';
import { getItemIcon } from '../constants';
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
interface RecentDashboard {
id: string;
title: string;
tags: string[];
}
export default function Dashboards({
onUpdateChecklistDoneItem,
loadingUserPreferences,
@@ -38,58 +26,33 @@ export default function Dashboards({
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { user } = useAppContext();
const isDashboardV2 = useIsDashboardV2();
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
// selects; the inactive one stays disabled so it never fires.
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
// Fetch Dashboards
const {
data: v1List,
isLoading: v1Loading,
isError: v1Error,
} = useGetAllDashboard({ enabled: !isDashboardV2 });
const {
data: v2List,
isLoading: v2Loading,
isError: v2Error,
} = useListDashboardsForUserV2(
{
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
limit: 5,
offset: 0,
},
{ query: { enabled: isDashboardV2 } },
);
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
if (isDashboardV2) {
return (v2List?.data?.dashboards ?? []).map((d) => ({
id: d.id,
title: d.spec?.display?.name ?? d.name,
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
}));
}
return [...(v1List?.data ?? [])]
.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, 5)
.map((d) => ({
id: d.id,
title: d.data.title,
tags: d.data.tags ?? [],
}));
}, [isDashboardV2, v1List, v2List]);
data: dashboardsList,
isLoading: isDashboardListLoading,
isError: isDashboardListError,
} = useGetAllDashboard();
useEffect(() => {
if (!dashboardsList) {
return;
}
const sortedDashboards = dashboardsList.data.sort((a, b) => {
const aUpdateAt = new Date(a.updatedAt).getTime();
const bUpdateAt = new Date(b.updatedAt).getTime();
return bUpdateAt - aUpdateAt;
});
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
}
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
setSortedDashboards(sortedDashboards.slice(0, 5));
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
const emptyStateCard = (): JSX.Element => (
<div className="empty-state-container">
@@ -150,7 +113,7 @@ export default function Dashboards({
event.stopPropagation();
logEvent('Homepage: Dashboard clicked', {
dashboardId: dashboard.id,
dashboardName: dashboard.title,
dashboardName: dashboard.data.title,
});
if (event.metaKey || event.ctrlKey) {
openInNewTab(getLink());
@@ -180,12 +143,12 @@ export default function Dashboards({
/>
<div className="alert-rule-item-name home-data-item-name">
{dashboard.title}
{dashboard.data.title}
</div>
</div>
<div className="alert-rule-item-description home-data-item-tag">
{dashboard.tags.map((tag) => (
{dashboard.data.tags?.map((tag) => (
<Badge color="sienna" variant="outline" key={tag}>
{tag}
</Badge>

View File

@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import debounce from 'lodash-es/debounce';
export type Dimensions = {
@@ -15,15 +15,6 @@ export function useResizeObserver<T extends HTMLElement>(
height: ref.current?.clientHeight || 0,
});
// Measure before paint so the first frame has real dimensions, not 0 (the
// debounced observer below only catches up after paint → layout jump).
useLayoutEffect(() => {
const node = ref.current;
if (node) {
setSize({ width: node.clientWidth, height: node.clientHeight });
}
}, [ref]);
useEffect(() => {
const handleResize = debounce(
(entries: ResizeObserverEntry[]) => {

View File

@@ -1,4 +1,4 @@
import { type FocusEvent, KeyboardEvent } from 'react';
import { KeyboardEvent } from 'react';
import {
Check,
Globe,
@@ -90,20 +90,12 @@ function DashboardInfo({
}
};
// Clicking outside the editor commits, matching the input's Enter behaviour.
// Guard against blurs that move focus to the Save/Cancel buttons within it.
const onEditorBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
onCommit();
}
};
return (
<div className={styles.dashboardInfo}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}

View File

@@ -1,32 +0,0 @@
.trigger {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.options {
min-width: min-content;
}
.item {
width: min-content;
span {
vertical-align: middle;
}
}
.image {
height: 16px;
width: 16px;
}

View File

@@ -1,43 +0,0 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import cx from 'classnames';
import { Base64Icons } from '../utils';
import styles from './DashboardImagePicker.module.scss';
interface Props {
// The selected image — one of the base64 icon data-URIs.
image: string;
onChange: (value: string) => void;
// Consumers set the trigger's border-radius (e.g. rounded-left when joined to
// a name input); this component owns size / background / icon-only styling.
triggerClassName?: string;
}
// Icon picker shared by the dashboard-details settings and the create-dashboard
// modal so both choose from the same `Base64Icons` set.
function DashboardImagePicker({
image,
onChange,
triggerClassName,
}: Props): JSX.Element {
return (
<Select value={image} onChange={(value): void => onChange(value as string)}>
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
<SelectContent className={styles.options} withPortal={false}>
{Base64Icons.map((icon) => (
<SelectItem key={icon} value={icon} className={styles.item}>
<img src={icon} alt="dashboard-icon" className={styles.image} />
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default DashboardImagePicker;

View File

@@ -25,10 +25,38 @@
}
}
// The image-picker owns its size/background; here we only round the left edge so
// it joins flush with the name input to its right.
.dashboardImageInput {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.dashboardImageOptions {
min-width: min-content;
}
.dashboardImageSelectItem {
width: min-content;
span {
vertical-align: middle;
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {

View File

@@ -1,11 +1,17 @@
import { Dispatch, SetStateAction } from 'react';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import DashboardImagePicker from '../DashboardImagePicker/DashboardImagePicker';
import { Base64Icons } from '../utils';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DashboardInfoForm.module.scss';
@@ -37,11 +43,30 @@ function DashboardInfoForm({
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<DashboardImagePicker
image={image}
onChange={onImageChange}
triggerClassName={styles.dashboardImageInput}
/>
<Select
value={image}
onChange={(value): void => onImageChange(value as string)}
>
<SelectTrigger className={styles.dashboardImageInput} />
<SelectContent
className={styles.dashboardImageOptions}
withPortal={false}
>
{Base64Icons.map((icon) => (
<SelectItem
key={icon}
value={icon}
className={styles.dashboardImageSelectItem}
>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<Input
testId="dashboard-name"

View File

@@ -83,18 +83,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
);
}
if (updatedImage !== image) {
// `replace` fails when the image doesn't exist yet, so add it when the
// dashboard has none (`add` creates or replaces the member). Key off the
// raw stored value, not the `Base64Icons[0]`-defaulted local `image`.
ops.push(
op(
dashboard.image
? DashboardtypesPatchOpDTO.replace
: DashboardtypesPatchOpDTO.add,
'/image',
updatedImage,
),
);
ops.push(replace('/image', updatedImage));
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
@@ -107,7 +96,6 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
description,
updatedImage,
image,
dashboard.image,
updatedTags,
tagsAsStrings,
]);

View File

@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Pan
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from '../utils/legendSeries';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';

View File

@@ -5,7 +5,7 @@ import { Input } from 'antd';
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import { Virtuoso } from 'react-virtuoso';
import type { LegendSeries } from '../../../utils/legendSeries';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import LegendColorRow from './LegendColorRow';
import {
clearSeriesColor,

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { LegendSeries } from '../../../../utils/legendSeries';
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import LegendColors from '../LegendColors';
const SERIES: LegendSeries[] = [

View File

@@ -1,4 +1,4 @@
import type { LegendSeries } from '../../../../utils/legendSeries';
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import {
clearSeriesColor,
filterLegendSeries,

View File

@@ -1,6 +1,6 @@
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from '../../../utils/legendSeries';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
/** Case-insensitive substring filter over series labels. Empty query → all series. */
export function filterLegendSeries(

View File

@@ -1,7 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from '../utils/legendSeries';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';

View File

@@ -11,8 +11,6 @@ interface ColumnUnitsProps {
columns: TableColumnOption[];
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
value: Record<string, string>;
/** Unit the selected metric was sent with; each column warns if its unit mismatches. */
metricUnit?: string;
onChange: (next: Record<string, string>) => void;
}
@@ -25,7 +23,6 @@ interface ColumnUnitsProps {
function ColumnUnits({
columns,
value,
metricUnit,
onChange,
}: ColumnUnitsProps): JSX.Element {
if (columns.length === 0) {
@@ -56,7 +53,6 @@ function ColumnUnits({
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
value={value[column.key]}
initialValue={metricUnit}
containerClassName={styles.columnUnitSelector}
onChange={(unit): void => setUnit(column.key, unit)}
/>

View File

@@ -81,7 +81,6 @@ function FormattingSection({
<ColumnUnits
columns={tableColumns}
value={value?.columnUnits ?? {}}
metricUnit={metricUnit}
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
/>
</div>

View File

@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
@@ -100,33 +100,4 @@ describe('FormattingSection', () => {
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
it('warns when a column unit mismatches the metric unit', () => {
// metric sent in seconds, but the column is set to bytes.
render(
<FormattingSection
value={{ columnUnits: { A: 'By' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.getByLabelText('warning')).toBeInTheDocument();
});
it('shows no warning when the column unit matches the metric unit', () => {
render(
<FormattingSection
value={{ columnUnits: { A: 's' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
});

View File

@@ -7,9 +7,4 @@
.dropdown {
width: 260px;
/* The dropdown portals to body; lift it above the query builder's antd
* popups (1050) so it stays clickable, incl. inside the View modal.
* TODO: remove after the antd -> @signozhq/ui migration
**/
z-index: 1100;
}

View File

@@ -63,7 +63,7 @@ function AddColumnDropdown({
<Plus size={16} />
</Button>
</ComboboxTrigger>
<ComboboxContent arrow side="bottom" align="end" className={styles.dropdown}>
<ComboboxContent arrow side="top" align="end" className={styles.dropdown}>
<ComboboxCommand shouldFilter={false}>
<ComboboxInput
value={searchText}

View File

@@ -10,9 +10,7 @@
}
.scrollArea {
// Top padding lives on the sticky tab nav instead, so the nav keeps its
// breathing room both at rest and when pinned to the top.
padding: 0 12px 12px 12px;
padding: 12px;
}
.tabsContainer {
@@ -26,15 +24,6 @@
background-color: var(--l1-background) !important;
}
:global(.ant-tabs-nav) {
// Pin the query-type tabs + Run Query button to the top of the
// `.container` scroll area while the query body scrolls underneath.
// `padding-top` owns the nav's top spacing (moved off `.scrollArea`).
position: sticky;
top: 0px;
z-index: 1100;
padding-top: 12px;
background-color: var(--l1-background);
&::before {
border-color: var(--l2-border);
}

View File

@@ -5,7 +5,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
import { useScrollToPanelStore } from '../../store/useScrollToPanelStore';
/**
* Characterization test for the editor's composition: which derived values and
@@ -20,7 +19,7 @@ const mockRefetch = jest.fn();
const mockCancelQuery = jest.fn();
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
const mockOnChangePanelKind = jest.fn();
const mockSave = jest.fn().mockResolvedValue('panel-1');
const mockSave = jest.fn().mockResolvedValue(undefined);
const mockUseDraft = jest.fn();
jest.mock('../hooks/usePanelEditorDraft', () => ({
@@ -62,8 +61,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
jest.mock('../hooks/useTableColumns', () => ({
useTableColumns: (): [] => [],
}));
jest.mock('../hooks/useSeedMetricUnit', () => ({
useSeedMetricUnit: (): unknown => ({
jest.mock('../hooks/useMetricYAxisUnit', () => ({
useMetricYAxisUnit: (): unknown => ({
metricUnit: undefined,
isLoading: false,
}),
@@ -105,17 +104,12 @@ jest.mock('@signozhq/ui/sonner', () => ({
const mockHeaderProps = jest.fn();
jest.mock('../Header/Header', () => ({
__esModule: true,
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
default: (props: { onSave: () => void }): JSX.Element => {
mockHeaderProps(props);
return (
<>
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
<button type="button" data-testid="editor-close" onClick={props.onClose}>
close
</button>
</>
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
);
},
}));
@@ -202,10 +196,7 @@ function setup(
}
describe('PanelEditorContainer composition', () => {
beforeEach(() => {
jest.clearAllMocks();
useScrollToPanelStore.setState({ scrollToPanelId: null });
});
beforeEach(() => jest.clearAllMocks());
it('renders the editor shell with preview, query builder, and config pane', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
@@ -308,32 +299,6 @@ describe('PanelEditorContainer composition', () => {
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
setup(makePanel('signoz/TimeSeriesPanel'));
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() =>
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('panel-1'),
);
});
it('marks an existing panel to be revealed when the editor is closed', async () => {
setup(makePanel('signoz/TimeSeriesPanel'));
await userEvent.click(screen.getByTestId('editor-close'));
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('panel-1');
});
it('does not mark a scroll target when a new, unsaved panel is closed', async () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
await userEvent.click(screen.getByTestId('editor-close'));
expect(useScrollToPanelStore.getState().scrollToPanelId).toBeNull();
});
it('offers Switch to View Mode for an existing panel', () => {
setup(makePanel('signoz/TimeSeriesPanel'));

View File

@@ -2,7 +2,6 @@ import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
@@ -30,36 +29,18 @@ jest.mock(
() => ({
flattenTimeSeries: jest.fn(),
getTimeSeriesResults: jest.fn(() => []),
getScalarResults: jest.fn(() => []),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn(() => []) }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData',
() => ({ preparePieData: jest.fn(() => []) }),
);
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const mockPreparePie = preparePieData as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const PIE_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/PieChartPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const HISTOGRAM_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
@@ -107,32 +88,6 @@ describe('useLegendSeries', () => {
]);
});
it('resolves histogram panels via the time-series path', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
const { result } = renderHook(() => useLegendSeries(HISTOGRAM_PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'a', defaultColor: 'color:a' },
{ label: 'b', defaultColor: 'color:b' },
]);
// The pie path must not run for a histogram panel.
expect(mockPreparePie).not.toHaveBeenCalled();
});
it('resolves pie panels from their scalar slices, deduped by label', () => {
mockPreparePie.mockReturnValue([
{ label: 'x', color: 'c1' },
{ label: 'y', color: 'c2' },
{ label: 'x', color: 'c1' },
]);
const { result } = renderHook(() => useLegendSeries(PIE_PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'x', defaultColor: 'c1' },
{ label: 'y', defaultColor: 'c2' },
]);
// The time-series path must not run for a pie panel.
expect(mockFlatten).not.toHaveBeenCalled();
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));

View File

@@ -0,0 +1,103 @@
import { renderHook } from '@testing-library/react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
describe('useMetricYAxisUnit', () => {
beforeEach(() => jest.clearAllMocks());
it('seeds the unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the metric has no unit', () => {
mockMetricUnit(undefined);
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
const { rerender } = renderHook(
(props: { unit: string | undefined }) =>
useMetricYAxisUnit({
isNewPanel: true,
unit: props.unit,
onSelectUnit,
}),
{ initialProps: { unit: undefined as string | undefined } },
);
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
// The metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ unit: 'bytes' });
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useMetricYAxisUnit({
isNewPanel: false,
unit: undefined,
onSelectUnit: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -24,8 +24,6 @@ jest.mock('api/generated/services/dashboard', () => ({
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
jest.mock('uuid', () => ({ v4: (): string => 'minted-panel-id' }));
describe('usePanelEditorSave', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -46,7 +44,7 @@ describe('usePanelEditorSave', () => {
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
const savedPanelId = await result.current.save(spec);
await result.current.save(spec);
expect(mockPatchAsync).toHaveBeenCalledWith([
{
@@ -55,25 +53,6 @@ describe('usePanelEditorSave', () => {
value: spec,
},
]);
// Editing resolves with the panel's own id.
expect(savedPanelId).toBe('panel-9');
});
it('mints and resolves with a fresh id when creating a new panel', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'new', isNew: true }),
);
const spec = {
display: { name: 'New panel' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
const savedPanelId = await result.current.save(spec);
expect(savedPanelId).toBe('minted-panel-id');
expect(mockPatchAsync).toHaveBeenCalled();
});
it('surfaces the patch in-flight state as isSaving', () => {

View File

@@ -1,265 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import type { TableColumnOption } from '../useTableColumns';
import { useSeedMetricUnit } from '../useSeedMetricUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
return {
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: formatting ? { formatting } : {},
},
} as unknown as DashboardtypesPanelSpecDTO;
}
function unit(spec: DashboardtypesPanelSpecDTO): unknown {
return (spec.plugin.spec as { formatting?: { unit?: unknown } }).formatting
?.unit;
}
function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
return (spec.plugin.spec as { formatting?: { columnUnits?: unknown } })
.formatting?.columnUnits;
}
const COLUMNS: TableColumnOption[] = [
{ key: 'A', label: 'A' },
{ key: 'B', label: 'B' },
];
const NO_COLUMNS: TableColumnOption[] = [];
describe('useSeedMetricUnit', () => {
beforeEach(() => jest.clearAllMocks());
describe('panel-wide unit (controls.unit)', () => {
it('seeds formatting.unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: makeSpec(),
onChangeSpec,
}),
);
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: false,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: makeSpec(),
onChangeSpec,
}),
);
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: makeSpec({ unit: 'bytes' }),
onChangeSpec,
}),
);
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
const { rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO }) =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: props.spec,
onChangeSpec,
}),
{ initialProps: { spec: makeSpec() } },
);
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
// Metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ spec: makeSpec({ unit: 'bytes' }) });
expect(unit(onChangeSpec.mock.calls[1][0])).toBe('ms');
});
});
describe('per-column units (controls.columnUnits)', () => {
it('seeds every value column with the metric unit once columns resolve', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
const { rerender } = renderHook(
(props: { columns: TableColumnOption[] }) =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true },
columns: props.columns,
spec: makeSpec(),
onChangeSpec,
}),
{ initialProps: { columns: NO_COLUMNS } },
);
// Waits for results to resolve the columns.
expect(onChangeSpec).not.toHaveBeenCalled();
rerender({ columns: COLUMNS });
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
A: 'bytes',
B: 'bytes',
});
});
it('never writes formatting.unit for a Table', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true, decimals: true },
columns: COLUMNS,
spec: makeSpec(),
onChangeSpec,
}),
);
expect(unit(onChangeSpec.mock.calls[0][0])).toBeUndefined();
});
it('only fills columns without a unit yet, keeping the user-set one', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true },
columns: COLUMNS,
spec: makeSpec({ columnUnits: { A: 'ms' } }),
onChangeSpec,
}),
);
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
A: 'ms',
B: 'bytes',
});
});
it('does not write when every column already has a unit', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true },
columns: COLUMNS,
spec: makeSpec({ columnUnits: { A: 'ms', B: 's' } }),
onChangeSpec,
}),
);
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('seeds once and does not re-run after the metric unit changes', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
const { rerender } = renderHook(
(props: { metric: string }) => {
mockMetricUnit(props.metric);
return useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true },
columns: COLUMNS,
spec: makeSpec(),
onChangeSpec,
});
},
{ initialProps: { metric: 'bytes' } },
);
expect(onChangeSpec).toHaveBeenCalledTimes(1);
rerender({ metric: 'ms' });
expect(onChangeSpec).toHaveBeenCalledTimes(1);
});
});
it('seeds nothing when the kind has no unit control (Histogram/List)', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: undefined,
columns: COLUMNS,
spec: makeSpec(),
onChangeSpec,
}),
);
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useSeedMetricUnit({
isNewPanel: false,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: makeSpec(),
onChangeSpec: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -1,19 +1,30 @@
import { useMemo } from 'react';
import { themeColors } from 'constants/theme';
import { useIsDarkMode } from 'hooks/useDarkMode';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import {
type LegendSeries,
resolvePieLegendSeries,
resolveTimeSeriesLegendSeries,
} from '../utils/legendSeries';
flattenTimeSeries,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
export interface LegendSeries {
/** Resolved display label — the key `legend.customColors` is indexed by. */
label: string;
/** The series' auto-assigned color, shown when no override is set. */
defaultColor: string;
}
/**
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
* legend-colors control can key overrides by the exact labels the chart draws. Only the
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
* Series from its flat series); every other kind returns none.
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs, using the
* exact label resolution the time-series renderer applies (`flattenTimeSeries` →
* `resolveSeriesLabelV5`) and the same `generateColor` default. The legend-colors control
* keys overrides by these labels, so they must match what the chart draws. Deduplicated,
* order-preserving; empty until data arrives or for kinds without flat time-series data.
*/
export function useLegendSeries(
panel: DashboardtypesPanelDTO,
@@ -22,15 +33,27 @@ export function useLegendSeries(
const isDarkMode = useIsDarkMode();
return useMemo(() => {
switch (panel.spec.plugin.kind) {
case 'signoz/PieChartPanel':
return resolvePieLegendSeries(data, isDarkMode);
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/HistogramPanel':
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
default:
return [];
}
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const series = flattenTimeSeries(
getTimeSeriesResults(data?.response),
data.legendMap,
);
const builderQueries = getBuilderQueries(panel.spec.queries);
const byLabel = new Map<string, string>();
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
if (label && !byLabel.has(label)) {
byLabel.set(label, generateColor(label, palette));
}
});
return Array.from(byLabel, ([label, defaultColor]) => ({
label,
defaultColor,
}));
}, [panel.spec.queries, data.response, data.legendMap, isDarkMode]);
}

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
interface UseMetricYAxisUnitArgs {
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
isNewPanel: boolean;
unit: string | undefined;
onSelectUnit: (unit: string) => void;
}
interface UseMetricYAxisUnitResult {
metricUnit: string | undefined;
isLoading: boolean;
}
/**
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
* from it (V1 parity); returns the unit for the selector's mismatch warning.
*/
export function useMetricYAxisUnit({
isNewPanel,
unit,
onSelectUnit,
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
useEffect(() => {
if (isNewPanel && metricUnit && metricUnit !== unit) {
onSelectUnit(metricUnit);
}
// Re-seed only when the resolved metric unit changes, not on every unit edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewPanel, metricUnit]);
return { metricUnit, isLoading };
}

View File

@@ -23,8 +23,7 @@ interface UsePanelEditorSaveArgs {
}
interface UsePanelEditorSaveApi {
/** Resolves with the saved panel's id (freshly minted when creating). */
save: (spec: DashboardtypesPanelSpecDTO) => Promise<string>;
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
isSaving: boolean;
error: Error | null;
}
@@ -45,20 +44,17 @@ export function usePanelEditorSave({
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<string> => {
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
let ops: DashboardtypesJSONPatchOperationDTO[];
// The id a new panel is persisted under (surfaced so the caller can reveal it).
let savedPanelId = panelId;
if (isNew) {
// Resolve the target section against the freshest dashboard we have.
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
const cached =
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
savedPanelId = uuid();
ops = createPanelOps({
layouts: cached?.data.spec.layouts ?? [],
layoutIndex,
panelId: savedPanelId,
panelId: uuid(),
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
});
} else {
@@ -73,7 +69,6 @@ export function usePanelEditorSave({
// Optimistic cache write + settle refetch (replaces the manual invalidate).
await patchAsync(ops);
return savedPanelId;
},
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
);

View File

@@ -1,95 +0,0 @@
import { useEffect, useRef } from 'react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import type {
SectionControls,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { readFormatting, writeFormatting } from '../utils/formattingSpec';
import type { TableColumnOption } from './useTableColumns';
type FormattingControls = SectionControls[SectionKind.Formatting];
interface UseSeedMetricUnitArgs {
/** Only a new panel auto-seeds; editing never overwrites a saved unit. */
isNewPanel: boolean;
/**
* The current kind's Formatting controls — the single source of truth for which
* field a metric unit seeds into: `unit` (panel-wide) vs `columnUnits` (Table).
* A kind with neither (Histogram/List) seeds nothing.
*/
formattingControls: FormattingControls | undefined;
/** Resolved value columns (Table only; empty before results arrive / for other kinds). */
columns: TableColumnOption[];
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UseSeedMetricUnitResult {
metricUnit: string | undefined;
isLoading: boolean;
}
/**
* Resolves the selected metric's unit and, on a new panel only, seeds it into the panel's
* formatting — into `formatting.unit` for kinds with a panel-wide unit control, or into
* `formatting.columnUnits` (per value column) for a Table, which has no panel-wide unit.
* The kind's Formatting `controls` decide which applies, mirroring `buildPluginSpec`'s
* switch-time seeding so the two never diverge. Returns the unit for the FormattingSection's
* mismatch warning.
*/
export function useSeedMetricUnit({
isNewPanel,
formattingControls,
columns,
spec,
onChangeSpec,
}: UseSeedMetricUnitArgs): UseSeedMetricUnitResult {
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
const seedsUnit = isNewPanel && !!formattingControls?.unit;
const seedsColumnUnits = isNewPanel && !!formattingControls?.columnUnits;
// Panel-wide unit: seed (and re-seed) whenever the resolved metric unit changes. Kept
// off `spec` so a manual unit edit doesn't re-run this and fight the user.
useEffect(() => {
if (!seedsUnit || !metricUnit || metricUnit === readFormatting(spec)?.unit) {
return;
}
onChangeSpec(writeFormatting(spec, { unit: metricUnit }));
// Re-seed only when the resolved metric unit changes, not on every unit edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seedsUnit, metricUnit]);
// Per-column units (Table): seed once, only for columns without a unit yet, so it
// never clobbers a user's edit or a cleared column. Waits for results to resolve them.
const seededColumnsRef = useRef(false);
useEffect(() => {
if (
!seedsColumnUnits ||
seededColumnsRef.current ||
!metricUnit ||
columns.length === 0
) {
return;
}
const columnUnits = readFormatting(spec)?.columnUnits ?? {};
const unset = columns.filter(
(column) => columnUnits[column.key] === undefined,
);
seededColumnsRef.current = true;
if (unset.length === 0) {
return;
}
const nextColumnUnits = { ...columnUnits };
unset.forEach((column) => {
nextColumnUnits[column.key] = metricUnit;
});
onChangeSpec(writeFormatting(spec, { columnUnits: nextColumnUnits }));
// Seed once columns first resolve with a unit; not on later spec edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seedsColumnUnits, metricUnit, columns]);
return { metricUnit, isLoading };
}

View File

@@ -8,29 +8,25 @@ import {
import { toast } from '@signozhq/ui/sonner';
import {
type DashboardtypesPanelDTO,
type DashboardtypesPanelFormattingDTO,
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollToPanelStore } from '../store/useScrollToPanelStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
@@ -128,20 +124,32 @@ function PanelEditorContainer({
const panelKind = draft.spec.plugin.kind;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
return section?.controls;
}, [panelDefinition]);
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
const formattingUnit = (
spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
}
).formatting?.unit;
const seedFormattingUnit = useCallback(
(unit: string): void => {
const pluginSpec = spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
};
setSpec({
...spec,
plugin: {
...spec.plugin,
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
},
} as DashboardtypesPanelSpecDTO);
},
[spec, setSpec],
);
const { metricUnit } = useMetricYAxisUnit({
isNewPanel: isNew,
unit: formattingUnit,
onSelectUnit: seedFormattingUnit,
});
// A new panel is savable once it has a query to run — List auto-seeds one; other
// kinds open query-less, so there's nothing to save until the user builds one.
@@ -180,17 +188,6 @@ function PanelEditorContainer({
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
@@ -206,33 +203,19 @@ function PanelEditorContainer({
query: currentQuery,
});
const setScrollToPanelId = useScrollToPanelStore((s) => s.setScrollToPanelId);
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
await save(buildSaveSpec(draft.spec));
toast.success('Panel saved');
// Reveal the saved panel once the dashboard re-renders.
setScrollToPanelId(savedPanelId);
onSaved();
} catch {
toast.error('Failed to save panel');
}
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollToPanelId, onSaved]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollToPanelId(panelId);
}
onClose();
}, [isNew, panelId, setScrollToPanelId, onClose]);
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
@@ -244,7 +227,7 @@ function PanelEditorContainer({
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={onSwitchToView}
onClose={onCloseEditor}
onClose={onClose}
/>
<ResizablePanelGroup
id="panel-editor-v2"

View File

@@ -1,37 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { readFormatting, writeFormatting } from '../formattingSpec';
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
return {
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: formatting ? { formatting } : {},
},
} as unknown as DashboardtypesPanelSpecDTO;
}
describe('formattingSpec', () => {
it('reads the formatting slice (undefined when absent)', () => {
expect(readFormatting(makeSpec())).toBeUndefined();
expect(readFormatting(makeSpec({ unit: 'bytes' }))).toStrictEqual({
unit: 'bytes',
});
});
it('merges the patch into the formatting slice, preserving other fields', () => {
const next = writeFormatting(makeSpec({ decimalPrecision: '2' }), {
unit: 'bytes',
});
expect(readFormatting(next)).toStrictEqual({
decimalPrecision: '2',
unit: 'bytes',
});
});
it('does not mutate the input spec', () => {
const spec = makeSpec({ unit: 'ms' });
writeFormatting(spec, { unit: 'bytes' });
expect(readFormatting(spec)).toStrictEqual({ unit: 'ms' });
});
});

View File

@@ -1,27 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
// `spec.plugin.spec` is a discriminated union over panel kinds; these helpers narrow
// to the shared `formatting` slice via a single localized cast at the boundary, so
// callers read/write it without repeating the spread.
export function readFormatting(
spec: DashboardtypesPanelSpecDTO,
): PanelFormattingSlice | undefined {
return (spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting;
}
/** Merges a partial formatting patch into the panel's `formatting` slice. */
export function writeFormatting(
spec: DashboardtypesPanelSpecDTO,
patch: Partial<PanelFormattingSlice>,
): DashboardtypesPanelSpecDTO {
const pluginSpec = spec.plugin.spec as { formatting?: PanelFormattingSlice };
return {
...spec,
plugin: {
...spec.plugin,
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, ...patch } },
},
} as DashboardtypesPanelSpecDTO;
}

View File

@@ -1,96 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { themeColors } from 'constants/theme';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import {
flattenTimeSeries,
getScalarResults,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
export interface LegendSeries {
/** Resolved display label — the key `legend.customColors` is indexed by. */
label: string;
/** The series' auto-assigned color, shown when no override is set. */
defaultColor: string;
}
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
/**
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
* pairs, resolving each unique label's color lazily via `colorFor` — so a repeated
* label never resolves a second color.
*/
function buildLegendSeries(
labels: readonly string[],
colorFor: (label: string, index: number) => string,
): LegendSeries[] {
const byLabel = new Map<string, string>();
labels.forEach((label, index) => {
if (label && !byLabel.has(label)) {
byLabel.set(label, colorFor(label, index));
}
});
return Array.from(byLabel, ([label, defaultColor]) => ({
label,
defaultColor,
}));
}
/**
* Pie is fed by scalar results, not time series. Reuse the exact slices the renderer
* draws (without overrides, so their colors are the defaults) so the color control keys
* overrides by the same labels the chart does.
*/
export function resolvePieLegendSeries(
data: PanelQueryData,
isDarkMode: boolean,
): LegendSeries[] {
const slices = preparePieData({
tables: prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap,
requestPayload: data.requestPayload,
}),
isDarkMode,
});
return buildLegendSeries(
slices.map((slice) => slice.label),
(_, index) => slices[index].color,
);
}
/**
* Time-series kinds: resolve each flattened series' label the way the renderer does
* (`getLabelName` → `resolveSeriesLabelV5`) and color it with `generateColor`.
*/
export function resolveTimeSeriesLegendSeries(
queries: PanelQueries,
data: PanelQueryData,
isDarkMode: boolean,
): LegendSeries[] {
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const builderQueries = getBuilderQueries(queries);
const series = flattenTimeSeries(
getTimeSeriesResults(data.response),
data.legendMap,
);
return buildLegendSeries(
series.map((s) =>
resolveSeriesLabelV5(
s,
builderQueries,
getLabelName(s.labels, s.queryName, s.legend),
),
),
(label) => generateColor(label, palette),
);
}

View File

@@ -1,7 +1,5 @@
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { panelHasFixedTimePreference } from '../../../hooks/resolvePanelTimeWindow';
import {
selectViewPanelExtendWindow,
useViewPanelStore,
@@ -17,8 +15,6 @@ interface NoDataProps {
isFetching?: boolean;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
panel?: DashboardtypesPanelDTO;
'data-testid'?: string;
}
@@ -33,30 +29,19 @@ function NoData({
description = 'Nothing in the selected window. Try widening the range.',
isFetching = false,
onRetry,
panel,
'data-testid': testId = 'panel-no-data',
}: NoDataProps): JSX.Element {
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
const globalExtend = useExtendTimeWindow();
// The View modal's local extender wins; the global one only applies to a panel that
// follows the ambient window (a fixed preference can't be widened by it).
const hasFixedTimePreference = panel
? panelHasFixedTimePreference(panel)
: false;
const activeExtend =
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
if (isFetching) {
return <PanelLoader />;
}
const extendAction: PanelMessageAction | undefined =
activeExtend?.canExtend && activeExtend.actionLabel
? {
label: activeExtend.actionLabel,
onClick: activeExtend.extend,
icon: <CalendarRange size={14} />,
}
canExtend && actionLabel
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
: undefined;
const retryAction: PanelMessageAction | undefined = onRetry

View File

@@ -1,7 +1,3 @@
import {
DashboardtypesTimePreferenceDTO,
type DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { fireEvent, render, screen } from '@testing-library/react';
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
@@ -28,15 +24,6 @@ function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
};
}
/** Minimal panel whose plugin spec carries the given time preference. */
function panelWith(
timePreference?: DashboardtypesTimePreferenceDTO,
): DashboardtypesPanelDTO {
return {
spec: { plugin: { spec: { visualization: { timePreference } } } },
} as unknown as DashboardtypesPanelDTO;
}
describe('NoData', () => {
beforeEach(() => {
mockUseExtendTimeWindow.mockReturnValue(inert);
@@ -132,47 +119,4 @@ describe('NoData', () => {
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
});
it('hides the global extend action for a panel with a fixed time preference', () => {
const onRetry = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
render(
<NoData
onRetry={onRetry}
panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)}
/>,
);
// Only Retry — extending the dashboard window can't widen a locked panel span.
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Retry');
expect(
screen.queryByTestId('panel-no-data-secondary-action'),
).not.toBeInTheDocument();
});
it('still offers the global extend action for a panel that follows the global window', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.global_time)} />,
);
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
'Extend time range',
);
});
it('keeps the View modal extender even when the panel has a fixed time preference', () => {
const storeExtend = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
useViewPanelStore.setState({
viewPanelExtendWindow: extender({ extend: storeExtend }),
});
render(
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)} />,
);
fireEvent.click(screen.getByTestId('panel-no-data-action'));
expect(storeExtend).toHaveBeenCalledTimes(1);
});
});

View File

@@ -193,7 +193,7 @@ function BarPanelRenderer({
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&

View File

@@ -13,7 +13,7 @@ export const sections: SectionConfig[] = [
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },

View File

@@ -106,7 +106,7 @@ function HistogramPanelRenderer({
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&

View File

@@ -9,7 +9,7 @@ export const sections: SectionConfig[] = [
},
{
kind: SectionKind.Legend,
controls: { position: true, colors: true },
controls: { position: true },
// Merging all queries collapses to one distribution with no legend.
isHidden: (spec): boolean =>
Boolean(

View File

@@ -143,7 +143,7 @@ function ListPanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<>
<div

View File

@@ -130,7 +130,6 @@ function NumberPanelRenderer({
data-testid="number-panel-no-data"
isFetching={isFetching}
onRetry={refetch}
panel={panel}
/>
) : (
<ValueDisplay

View File

@@ -95,7 +95,7 @@ function PiePanelRenderer({
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
{slices.length === 0 ? (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<Pie
data={slices}

View File

@@ -1,13 +1,13 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
// (position + per-slice color overrides).
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
// Legend `colors` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -160,7 +160,7 @@ function TablePanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<div className={styles.container}>
<Table

View File

@@ -194,7 +194,7 @@ function TimeSeriesPanelRenderer({
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&

View File

@@ -47,8 +47,9 @@ describe('buildPluginSpec', () => {
expect(buildPluginSpec([])).toStrictEqual({});
});
it('seeds nothing for sections with no seed (Buckets, ContextLinks)', () => {
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
{ kind: SectionKind.ContextLinks },
];
@@ -111,108 +112,6 @@ describe('buildPluginSpec', () => {
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
it('carries old timePreference / stacking / fillSpans the target declares', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: {
switchPanelKind: true,
timePreference: true,
stacking: true,
fillSpans: true,
},
},
];
const oldSpec = oldSpecWith({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
stackedBarChart: true,
fillSpans: true,
},
});
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
stackedBarChart: true,
fillSpans: true,
});
});
it('drops visualization fields the target does not declare (Bar → TimeSeries)', () => {
// TimeSeries has no stacking control, so Bar's stackedBarChart must not carry.
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
},
];
const oldSpec = oldSpecWith({
visualization: { stackedBarChart: true },
});
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
timePreference: DashboardtypesTimePreferenceDTO.global_time,
});
});
it('carries old legend position but never customColors', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
];
const oldSpec = oldSpecWith({
legend: {
position: DashboardtypesLegendPositionDTO.right,
customColors: { 'series-a': '#F1575F' },
},
});
expect(buildPluginSpec(sections, { oldSpec }).legend).toStrictEqual({
position: DashboardtypesLegendPositionDTO.right,
});
});
});
describe('axes seed (carry, gated by controls)', () => {
it('carries softMin/softMax/isLogScale when the kind declares both controls', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
];
const oldSpec = oldSpecWith({
axes: { softMin: 0, softMax: 100, isLogScale: true },
});
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
softMin: 0,
softMax: 100,
isLogScale: true,
});
});
it('carries only the fields the target controls declare', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { logScale: true } },
];
const oldSpec = oldSpecWith({
axes: { softMin: 0, softMax: 100, isLogScale: true },
});
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
isLogScale: true,
});
});
it('skips null soft bounds and seeds nothing on a new panel or empty axes', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
expect(
buildPluginSpec(sections, {
oldSpec: oldSpecWith({ axes: { softMin: null, softMax: null } }),
}),
).toStrictEqual({});
});
});
describe('chartAppearance seed', () => {
@@ -238,30 +137,6 @@ describe('buildPluginSpec', () => {
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
it('carries old values over the defaults, gated by the declared controls', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { lineStyle: true, lineInterpolation: true, showPoints: true },
},
];
const oldSpec = oldSpecWith({
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.dashed,
fillMode: DashboardtypesFillModeDTO.gradient,
showPoints: false,
},
});
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
{
lineStyle: DashboardtypesLineStyleDTO.dashed,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
showPoints: false,
},
);
});
});
describe('formatting seed (carry, gated by controls)', () => {

View File

@@ -24,7 +24,6 @@ import {
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
export interface SeededPluginSpec {
visualization?: SectionSpecMap[SectionKind.Visualization];
axes?: SectionSpecMap[SectionKind.Axes];
legend?: SectionSpecMap[SectionKind.Legend];
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
@@ -91,103 +90,35 @@ interface SectionSeed {
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
[SectionKind.Visualization]: {
specKey: 'visualization',
seed: (
controls,
{ oldSpec },
): SectionSpecMap[SectionKind.Visualization] | undefined => {
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
const c = controls as SectionControls[SectionKind.Visualization];
const old = (
oldSpec?.plugin.spec as {
visualization?: SectionSpecMap[SectionKind.Visualization];
}
)?.visualization;
const visualization: SectionSpecMap[SectionKind.Visualization] = {
...(c.timePreference && {
timePreference:
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
}),
...(c.stacking &&
old?.stackedBarChart !== undefined && {
stackedBarChart: old.stackedBarChart,
}),
...(c.fillSpans &&
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
};
return Object.keys(visualization).length > 0 ? visualization : undefined;
},
},
[SectionKind.Axes]: {
specKey: 'axes',
seed: (
controls,
{ oldSpec },
): SectionSpecMap[SectionKind.Axes] | undefined => {
const c = controls as SectionControls[SectionKind.Axes];
const old = (
oldSpec?.plugin.spec as {
axes?: SectionSpecMap[SectionKind.Axes];
}
)?.axes;
if (!old) {
return undefined;
}
const axes: SectionSpecMap[SectionKind.Axes] = {
...(c.minMax &&
typeof old.softMin === 'number' && { softMin: old.softMin }),
...(c.minMax &&
typeof old.softMax === 'number' && { softMax: old.softMax }),
...(c.logScale &&
old.isLogScale !== undefined && { isLogScale: old.isLogScale }),
};
return Object.keys(axes).length > 0 ? axes : undefined;
return c.timePreference
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
: undefined;
},
},
[SectionKind.Legend]: {
specKey: 'legend',
seed: (
controls,
{ oldSpec },
): SectionSpecMap[SectionKind.Legend] | undefined => {
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
const c = controls as SectionControls[SectionKind.Legend];
const old = (
oldSpec?.plugin.spec as {
legend?: SectionSpecMap[SectionKind.Legend];
}
)?.legend;
// customColors is keyed by series label, which the new kind may not reproduce.
return c.position
? { position: old?.position ?? DashboardtypesLegendPositionDTO.bottom }
? { position: DashboardtypesLegendPositionDTO.bottom }
: undefined;
},
},
[SectionKind.ChartAppearance]: {
specKey: 'chartAppearance',
seed: (
controls,
{ oldSpec },
): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
const c = controls as SectionControls[SectionKind.ChartAppearance];
const old = (
oldSpec?.plugin.spec as {
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
}
)?.chartAppearance;
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
if (c.lineStyle) {
appearance.lineStyle = old?.lineStyle ?? DashboardtypesLineStyleDTO.solid;
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
}
if (c.lineInterpolation) {
appearance.lineInterpolation =
old?.lineInterpolation ?? DashboardtypesLineInterpolationDTO.spline;
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
}
if (c.fillMode) {
appearance.fillMode = old?.fillMode ?? DashboardtypesFillModeDTO.none;
}
if (c.showPoints && old?.showPoints !== undefined) {
appearance.showPoints = old.showPoints;
}
if (c.spanGaps && old?.spanGaps !== undefined) {
appearance.spanGaps = old.spanGaps;
appearance.fillMode = DashboardtypesFillModeDTO.none;
}
return Object.keys(appearance).length > 0 ? appearance : undefined;
},

View File

@@ -1,11 +1,11 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
@@ -27,7 +27,7 @@ export interface PanelActionsConfig {
interface PanelProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
/** True once this panel's section enters the viewport — gates the fetch. */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
@@ -43,7 +43,15 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
// A per-panel time preference is surfaced as a header pill. `visualization` is
// common to every plugin-spec variant — localized cast reads it without
// narrowing on kind.
const timePreference = (
panel.spec.plugin.spec as
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
| undefined
)?.visualization?.timePreference;
const timeLabel = panelTimePreferenceLabel(timePreference);
const panelKind = panel.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);

View File

@@ -89,14 +89,6 @@ function section(
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
// Untitled root plus two titled sections — exercises the multi-target submenu.
const ROOT_AND_TWO_TITLED = [
section(0, undefined),
section(1, 'A'),
section(2, 'B'),
];
// Just the free-flow root: an ungrouped board with no sections to move between.
const ONLY_ROOT = [section(0, undefined)];
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
// translation it drives is covered by buildCreateAlertUrl's own tests.
@@ -119,9 +111,7 @@ const baseArgs = {
panelId: 'panel-1',
panel: mockPanel,
data: mockData,
// Panel sits in a titled section with an untitled root present, so every
// action — including "Move to section" (→ Dashboard root) — is available.
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
};
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
@@ -220,7 +210,7 @@ describe('usePanelActionItems', () => {
]);
});
it('hides "Move to section" when the only untitled section is not the root (index 0)', () => {
it('move is disabled when there is no other titled section to move to', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
@@ -230,8 +220,8 @@ describe('usePanelActionItems', () => {
},
}),
);
// An untitled section only counts as the root at layoutIndex 0.
expect(itemKeys(result.current)).not.toContain('move');
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
expect(move).toMatchObject({ disabled: true });
});
it('edit opens the panel editor for this panel', () => {
@@ -243,77 +233,14 @@ describe('usePanelActionItems', () => {
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
});
it('"Move to section" offers a single "Dashboard (root)" target', () => {
it('move targets call the mutation with from/to layout indexes', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as {
children: { key: string; onClick: () => void }[];
};
expect(move.children.map((c) => c.key)).toStrictEqual(['move-to-root']);
});
it('the "Dashboard (root)" target moves the panel to the untitled root section', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as {
children: { onClick: () => void }[];
};
move.children[0].onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 1,
toLayoutIndex: 0,
});
});
it('an ungrouped panel (in the root) can move into each titled section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: ROOT_AND_TWO_TITLED },
}),
);
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as { children: { key: string; label: string }[] };
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1', 'move-2']);
expect(move.children.map((c) => c.label)).toStrictEqual(['A', 'B']);
});
it('a panel in a titled section can move to the root and the other titled sections', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: ROOT_AND_TWO_TITLED },
}),
);
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as { children: { key: string; label: string }[] };
// Root leads, then the other titled section — never the current one (A).
expect(move.children.map((c) => c.key)).toStrictEqual([
'move-to-root',
'move-2',
]);
expect(move.children.map((c) => c.label)).toStrictEqual([
'Dashboard (root)',
'B',
]);
});
it('moves between titled sections even when the board has no untitled root', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
}),
);
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as { children: { key: string; onClick: () => void }[] };
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1']);
expect(move.children).toHaveLength(1);
move.children[0].onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
@@ -322,14 +249,47 @@ describe('usePanelActionItems', () => {
});
});
it('hides "Move to section" when the board has no sections (only the root)', () => {
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: ONLY_ROOT },
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).not.toContain('move');
expect(itemKeys(result.current)).toContain('move-to-root');
});
it('"Move out of section" moves the panel to the untitled root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
const moveOut = result.current.items.find(
(i) => 'key' in i && i.key === 'move-to-root',
);
(moveOut as { onClick: () => void }).onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 1,
toLayoutIndex: 0,
});
});
it('hides "Move out of section" when the panel already sits in the root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('hides "Move out of section" when every section is titled (no root)', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
@@ -352,7 +312,7 @@ describe('usePanelActionItems', () => {
});
expect(mockDeletePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 1,
layoutIndex: 0,
});
expect(result.current.deleteConfirm.open).toBe(false);
});
@@ -365,7 +325,7 @@ describe('usePanelActionItems', () => {
(clone as { onClick: () => void }).onClick();
expect(mockClonePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 1,
layoutIndex: 0,
});
});

View File

@@ -5,7 +5,6 @@
padding: 8px 12px;
border-bottom: 1px solid var(--l2-border);
cursor: grab;
min-height: 32px;
}
.headerLeft {

View File

@@ -1,4 +1,4 @@
import { Fragment, useMemo } from 'react';
import { useMemo } from 'react';
import { Info, Loader } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type {
@@ -74,14 +74,6 @@ function PanelHeader({
[warning],
);
/**
* Hide the entire header when there's no title, description, or status to show,
* and the actions menu is suppressed (editor preview).
*/
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
return <Fragment />;
}
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>

View File

@@ -1,8 +1,9 @@
@use '../../../../../../styles/scrollbar' as *;
.dialog[data-width] {
max-width: 85%;
width: 85%;
.modal {
:global(.ant-modal-body) {
padding: 0px;
}
}
// Truncate a long panel name so the header stays on one line; the wrapping tooltip

View File

@@ -1,15 +1,7 @@
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogHeader,
DialogTitle,
} from '@signozhq/ui/dialog';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { ConfigProvider } from 'antd';
import { Modal } from 'antd';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useRef } from 'react';
import ViewPanelModalContent from './ViewPanelModalContent';
import styles from './ViewPanelModal.module.scss';
@@ -33,48 +25,27 @@ function ViewPanelModal({
}: ViewPanelModalProps): JSX.Element {
const name = panel?.spec.display.name ?? '';
// Render antd popups into the dialog (not document.body) so they stay inside the
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
const contentRef = useRef<HTMLDivElement>(null);
return (
<Dialog
<Modal
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
onCancel={onClose}
footer={null}
centered
width="85%"
destroyOnClose
className={styles.modal}
title={
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name} - (View mode)
</Typography.Text>
</TooltipSimple>
}
>
<DialogContent
ref={contentRef}
position="center"
width="extra-wide"
className={styles.dialog}
>
<DialogHeader>
<DialogTitle>
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name ? `${name} - (View mode)` : 'View mode'}
</Typography.Text>
</TooltipSimple>
</DialogTitle>
</DialogHeader>
<DialogCloseButton />
{open && panel && panelId && (
<ConfigProvider
getPopupContainer={(): HTMLElement => contentRef.current ?? document.body}
>
<ViewPanelModalContent
panel={panel}
panelId={panelId}
onClose={onClose}
/>
</ConfigProvider>
)}
</DialogContent>
</Dialog>
{open && panel && panelId && (
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
)}
</Modal>
);
}

View File

@@ -3,7 +3,6 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ContextMenu from 'periscope/components/ContextMenu';
import ListColumnsEditor from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
@@ -45,7 +44,6 @@ function ViewPanelModalContent({
const {
draft,
setSpec,
panelDefinition,
signal,
queryType,
@@ -56,17 +54,7 @@ function ViewPanelModalContent({
buildSaveSpec,
applyDrilldownQuery,
} = useViewPanelMode({ panel, panelId, time: timeOverride });
const {
data,
isFetching,
isPreviousData,
error,
refetch,
cancelQuery,
pagination,
} = query;
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
// so it reflects in-modal edits (and the click's time range follows the per-view window).
@@ -131,15 +119,6 @@ function ViewPanelModalContent({
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={draft.spec}
onChangeSpec={setSpec}
signal={signal}
/>
) : undefined
}
/>
</div>
<div className={styles.body}>
@@ -149,7 +128,6 @@ function ViewPanelModalContent({
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -35,8 +35,6 @@ interface UseViewPanelModeArgs {
export interface UseViewPanelModeReturn {
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
draft: DashboardtypesPanelDTO;
/** Update the draft's spec in place (e.g. the List columns editor). */
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
panelDefinition: RenderablePanelDefinition;
/**
@@ -161,7 +159,6 @@ export function useViewPanelMode({
return {
draft,
setSpec,
panelDefinition,
signal,
queryType: currentQuery.queryType,

View File

@@ -19,10 +19,6 @@ jest.mock('@signozhq/ui/sonner', () => ({
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
// jsdom has no layout engine, so scrollIntoView is undefined by default.
const mockScrollIntoView = jest.fn();
window.HTMLElement.prototype.scrollIntoView = mockScrollIntoView;
const sourcePanel = {
kind: 'Panel',
spec: {
@@ -136,29 +132,6 @@ describe('useClonePanel', () => {
);
});
it('scrolls the cloned panel into view when the toast auto-closes', async () => {
const clonedNode = document.createElement('div');
clonedNode.setAttribute('data-panel-root', 'cloned-id');
document.body.appendChild(clonedNode);
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'p1', layoutIndex: 0 });
// The scroll is deferred to the toast's auto-close, not fired inline.
const { onAutoClose } = mockToastPromise.mock.calls[0][1] as {
onAutoClose: () => void;
};
onAutoClose();
expect(mockScrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
document.body.removeChild(clonedNode);
});
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useClonePanel({ sections: sections() }));

View File

@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { cloneDeep } from 'lodash-es';
import { scrollIntoViewWhenReady } from 'utils/scrollIntoViewWhenReady';
import { v4 as uuid } from 'uuid';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
@@ -65,14 +64,6 @@ export function useClonePanel({
success: 'Panel cloned',
error: 'Failed to clone panel',
position: 'top-center',
duration: 2000,
// Defer the scroll to the toast's auto-close so the "Panel cloned"
// confirmation is seen first, then reveal the clone.
onAutoClose: () => {
scrollIntoViewWhenReady(() =>
document.querySelector(`[data-panel-root="${newPanelId}"]`),
);
},
});
// toast.promise owns the error UX; swallow here to avoid an unhandled

View File

@@ -4,7 +4,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { findFreeSlot, movePanelBetweenSectionsOps } from '../../../patchOps';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -20,8 +20,8 @@ interface Params {
/**
* Relocates a panel's item ref from one section to another. The panel itself
* stays in `spec.panels`; only the grid item moves, dropped into the target
* section's first free slot (`findFreeSlot`). Persisted as one atomic patch.
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
* the bottom of the target section. Persisted as one atomic patch.
*/
export function useMovePanelToSection({
sections,
@@ -52,10 +52,12 @@ export function useMovePanelToSection({
}
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Reuse the shared placement primitive: fills the last row's free edge
// if the panel fits, else drops to a fresh row at the bottom.
const { x, y } = findFreeSlot(target.items, moved.width);
const targetItems = [...target.items, { ...moved, x, y }];
// Place at a fresh row at the bottom of the target section.
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchAsync(

View File

@@ -1,12 +1,9 @@
import { FolderInput } from '@signozhq/icons';
import { FolderInput, FolderOutput } from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { findRootSection, type DashboardSection } from '../../../utils';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
// Matches the root option label in the New Panel picker (SectionPicker).
const ROOT_LABEL = 'Dashboard (root)';
interface MoveItemsArgs {
sections: DashboardSection[];
currentLayoutIndex: number;
@@ -15,11 +12,9 @@ interface MoveItemsArgs {
}
/**
* "Move to section" submenu listing every section the panel can move to the
* dashboard root (untitled top level, labelled "Dashboard (root)") plus every
* titled section, minus the one the panel already sits in. Hidden entirely when
* there is nowhere to move: an ungrouped board with no titled sections, or a
* panel that is alone in the only section.
* The "Move to section" submenu plus a direct "Move out of section" to the
* untitled root, shown only when the panel sits in a titled section and a root
* section exists to receive it.
*/
export function buildMoveItems({
sections,
@@ -27,38 +22,44 @@ export function buildMoveItems({
panelId,
movePanel,
}: MoveItemsArgs): MenuItem[] {
const rootSection = findRootSection(sections);
// Sections are already in layout order, so the root (index 0, when present)
// naturally leads. Untitled non-root layouts are never a move target.
const targets = sections.filter(
(section) =>
section.layoutIndex !== currentLayoutIndex &&
(section === rootSection || Boolean(section.title)),
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
return [];
}
return [
const items: MenuItem[] = [
{
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((section) => {
const isRoot = section === rootSection;
return {
key: isRoot ? 'move-to-root' : `move-${section.layoutIndex}`,
label: isRoot ? ROOT_LABEL : (section.title as string),
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: section.layoutIndex,
}),
};
}),
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
},
];
const rootSection = findRootSection(sections);
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
items.push({
key: 'move-to-root',
label: 'Move out of section',
icon: <FolderOutput size={14} />,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: rootSection.layoutIndex,
}),
});
}
return items;
}

View File

@@ -1,7 +1,9 @@
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
@@ -39,6 +41,12 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
targetLayoutIndex,
} = useCreatePanel();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
const isVisible = useIntersectionObserver(containerRef, {
rootMargin: '200px',
});
const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id });
@@ -69,14 +77,17 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
/>
);
if (!section.title) {
// Untitled section — just the grid, no header chrome.
// Untitled section — just the grid (no header chrome), but still observed
// for the viewport signal.
return (
<div
ref={containerRef}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
@@ -87,6 +98,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
return (
<div
ref={containerRef}
className={styles.section}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}

View File

@@ -20,9 +20,3 @@
}
}
}
// Lazy-load observer boundary (SectionGridItem); fills the grid cell.
.panelWrapper {
height: 100%;
width: 100%;
}

View File

@@ -2,9 +2,9 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import Panel from '../../Panel/Panel';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
import SectionGridItem from './SectionGridItem';
import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
@@ -12,6 +12,8 @@ const ResponsiveGridLayout = WidthProvider(GridLayout);
interface SectionGridProps {
items: DashboardSection['items'];
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
}
@@ -19,6 +21,7 @@ interface SectionGridProps {
function SectionGrid({
items,
layoutIndex,
isVisible,
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
@@ -58,9 +61,10 @@ function SectionGrid({
// panel with no content.
<div key={item.id}>
{item.panel && (
<SectionGridItem
<Panel
panel={item.panel}
panelId={item.id}
isVisible={isVisible}
panelActions={
isEditable
? {

View File

@@ -1,49 +0,0 @@
import { useRef } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
import { useScrollPanelIntoView } from './useScrollPanelIntoView';
import styles from './SectionGrid.module.scss';
const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
rootMargin: '200px',
};
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
panelActions?: PanelActionsConfig;
}
/**
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
*/
function SectionGridItem({
panel,
panelId,
panelActions,
}: SectionGridItemProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(
containerRef,
VIEWPORT_OBSERVER_OPTIONS,
true,
);
useScrollPanelIntoView(panelId, containerRef);
return (
<div ref={containerRef} className={styles.panelWrapper}>
<Panel
panel={panel}
panelId={panelId}
isVisible={isVisible}
panelActions={panelActions}
/>
</div>
);
}
export default SectionGridItem;

View File

@@ -1,40 +0,0 @@
import type { RefObject } from 'react';
import { renderHook } from '@testing-library/react';
import { useScrollToPanelStore } from '../../../../store/useScrollToPanelStore';
import { useScrollPanelIntoView } from '../useScrollPanelIntoView';
function refWithScroll(scrollIntoView: jest.Mock): RefObject<HTMLElement> {
return {
current: { scrollIntoView } as unknown as HTMLElement,
} as RefObject<HTMLElement>;
}
describe('useScrollPanelIntoView', () => {
beforeEach(() => {
useScrollToPanelStore.setState({ scrollToPanelId: null });
});
it('scrolls into view and clears the request when the store targets this panel', () => {
const scrollIntoView = jest.fn();
useScrollToPanelStore.setState({ scrollToPanelId: 'p1' });
renderHook(() => useScrollPanelIntoView('p1', refWithScroll(scrollIntoView)));
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
expect(useScrollToPanelStore.getState().scrollToPanelId).toBeNull();
});
it('does nothing when the store targets a different panel', () => {
const scrollIntoView = jest.fn();
useScrollToPanelStore.setState({ scrollToPanelId: 'other' });
renderHook(() => useScrollPanelIntoView('p1', refWithScroll(scrollIntoView)));
expect(scrollIntoView).not.toHaveBeenCalled();
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('other');
});
});

View File

@@ -1,24 +0,0 @@
import { RefObject, useEffect } from 'react';
import { useScrollToPanelStore } from '../../../store/useScrollToPanelStore';
/**
* Scrolls this panel into view when the store requests it (e.g. returning from
* the editor or after creating a panel), then clears the request so it fires
* once. Runs on mount, so it catches the panel as soon as the grid renders it.
*/
export function useScrollPanelIntoView(
panelId: string,
ref: RefObject<HTMLElement>,
): void {
const scrollToPanelId = useScrollToPanelStore((s) => s.scrollToPanelId);
const setScrollToPanelId = useScrollToPanelStore((s) => s.setScrollToPanelId);
useEffect(() => {
if (scrollToPanelId !== panelId) {
return;
}
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
setScrollToPanelId(null);
}, [scrollToPanelId, panelId, ref, setScrollToPanelId]);
}

View File

@@ -3,7 +3,6 @@ import { useCallback, useState } from 'react';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { scrollIntoViewWhenReady } from 'utils/scrollIntoViewWhenReady';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import {
@@ -15,6 +14,25 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
/**
* Waits (via rAF) for the appended section to render, then scrolls it into view.
* Polls because the optimistic cache write commits to the DOM a frame or two after
* the patch call; bails after ~40 frames.
*/
function scrollToNewSection(prevCount: number, attempts = 40): void {
const sections = document.querySelectorAll(SECTION_SELECTOR);
if (sections.length > prevCount) {
sections[sections.length - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
return;
}
if (attempts > 0) {
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
}
}
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
@@ -49,13 +67,7 @@ export function useAddSection({ layouts }: Params): Result {
try {
setIsSaving(true);
await patchAsync([op]);
// Once the optimistic write appends the new section, scroll to it.
scrollIntoViewWhenReady(() => {
const sections = document.querySelectorAll(SECTION_SELECTOR);
return sections.length > prevSectionCount
? sections[sections.length - 1]
: null;
});
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
} finally {

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { ChevronLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
@@ -5,7 +6,6 @@ import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { selectVariablesExpanded } from '../store/slices/collapseSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import AddVariableFull from './AddVariableFull';
import AddVariableIcon from './AddVariableIcon';
@@ -45,13 +45,10 @@ interface VariablesBarProps {
* either way so auto-selection and option fetching keep driving the panels.
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const dashboardId = dashboard.id ?? '';
const { variables, selection, setSelection, autoSelect } =
useVariableSelection(dashboard);
const isEditable = useDashboardStore((s) => s.isEditable);
// Persisted per dashboard so the full/collapsed view survives reloads.
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
@@ -78,7 +75,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setVariablesExpanded(dashboardId, !expanded)}
onClick={(): void => setExpanded((prev) => !prev)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
@@ -123,7 +120,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
</div>
))}
{(expanded || hasOverflow) && (
{hasOverflow && (
<span className={styles.moreButton}>
{expanded ? (
moreButton

View File

@@ -57,8 +57,6 @@ function ValueSelector({
onRetry={onRetry}
showSearch
placeholder="Select value"
maxTagCount={2}
maxTagTextLength={20}
enableAllSelection={showAllOption}
onChange={(next): void => {
const values = Array.isArray(next)

View File

@@ -7,8 +7,6 @@ import {
cloneSectionOps,
createDefaultPanel,
createPanelOps,
findFreeSlot,
itemsOverlap,
} from '../patchOps';
function item(y: number, height: number): DashboardGridItemDTO {
@@ -145,59 +143,6 @@ describe('createPanelOps', () => {
expect(ops[2].path).toBe('/spec/layouts/0/spec/items/-');
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
});
it('wraps to the bottom when the last-row slot is blocked by a taller earlier-row panel', () => {
// Regression: the last row (top-y 6) has room at x:3, but the tall right
// panel spans y:0..12 into it. Placing at x:3,y:6 would overlap it, so the
// panel must drop to a fresh row at the bottom (y:12) instead.
const layouts = [
section([
itemAt(0, 0, 6, 6),
itemAt(6, 0, 6, 12), // tall, reaches down into the last row
itemAt(0, 6, 3, 6),
]),
];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
expect(value.y).toBe(12);
});
});
describe('itemsOverlap', () => {
it('is true only when rectangles intersect on both axes', () => {
const a = { x: 0, y: 0, width: 6, height: 6 };
expect(itemsOverlap(a, { x: 3, y: 3, width: 6, height: 6 })).toBe(true);
// Touching edges do not overlap (half-open intervals).
expect(itemsOverlap(a, { x: 6, y: 0, width: 6, height: 6 })).toBe(false);
expect(itemsOverlap(a, { x: 0, y: 6, width: 6, height: 6 })).toBe(false);
// Overlaps on x only (disjoint on y) → no overlap.
expect(itemsOverlap(a, { x: 3, y: 6, width: 6, height: 6 })).toBe(false);
});
});
describe('findFreeSlot', () => {
it('places the first item at the origin', () => {
expect(findFreeSlot([], 6)).toStrictEqual({ x: 0, y: 0 });
});
it('fills the right of the last row when it fits and is clear', () => {
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 6)).toStrictEqual({ x: 6, y: 0 });
});
it('never returns a slot that overlaps an existing item', () => {
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12), itemAt(0, 6, 3, 6)];
const slot = findFreeSlot(items, 6);
const placed = { ...slot, width: 6, height: 6 };
expect(items.some((it) => itemsOverlap(placed, it))).toBe(false);
expect(slot).toStrictEqual({ x: 0, y: 12 });
});
it('clamps a too-wide panel to the grid width', () => {
// width 20 > 12 cols → clamped to 12, so it wraps below the first row.
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 20)).toStrictEqual({ x: 0, y: 6 });
});
});
describe('cloneSectionOps', () => {

View File

@@ -1,196 +1,74 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { getDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { act, renderHook } from '@testing-library/react';
import { useDashboardStaleCheck } from '../useDashboardStaleCheck';
const mockUseQuery = jest.fn();
const mockUseIsMutating = jest.fn();
jest.mock('react-query', () => ({
useQuery: (...args: unknown[]): unknown => mockUseQuery(...args),
useIsMutating: (): number => mockUseIsMutating(),
useQueryClient: (): unknown => ({ getQueryData: jest.fn() }),
}));
jest.mock('api/generated/services/dashboard', () => ({
getDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/d1']),
}));
const mockGetDashboard = getDashboardV2 as jest.Mock;
const SPEC_A = { panels: { p1: {} } };
const SPEC_B = { panels: { p1: {}, p2: {} } };
interface Content {
spec?: unknown;
tags?: unknown;
locked?: boolean;
}
// Defaults make loaded and server content identical unless a field is overridden.
function withDefaults(content: Content): Record<string, unknown> {
return { spec: SPEC_A, tags: [], locked: false, ...content };
}
function loaded(
updatedAt: string,
content: Content = {},
): DashboardtypesGettableDashboardV2DTO {
return {
id: 'd1',
updatedAt,
...withDefaults(content),
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
function setVisibility(state: 'visible' | 'hidden'): void {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => state,
});
}
/** Simulate the tab/window coming back with the given server copy. */
async function comeBack(
serverUpdatedAt: string,
content: Content = {},
via: 'visibilitychange' | 'focus' = 'visibilitychange',
): Promise<void> {
mockGetDashboard.mockResolvedValueOnce({
data: { updatedAt: serverUpdatedAt, ...withDefaults(content) },
});
setVisibility('visible');
await act(async () => {
if (via === 'focus') {
window.dispatchEvent(new Event('focus'));
} else {
document.dispatchEvent(new Event('visibilitychange'));
}
});
function setServerUpdatedAt(updatedAt: string | undefined): void {
mockUseQuery.mockReturnValue({ data: { data: { updatedAt } } });
}
describe('useDashboardStaleCheck', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsMutating.mockReturnValue(0);
setVisibility('visible');
});
it('does not fetch on first load — only when the tab/window returns', async () => {
renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
expect(mockGetDashboard).not.toHaveBeenCalled();
await comeBack('2026-07-08T09:00:00Z');
expect(mockGetDashboard).toHaveBeenCalledWith({ id: 'd1' });
});
it('also probes on window focus (returning from another app)', async () => {
renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T09:00:00Z', {}, 'focus');
expect(mockGetDashboard).toHaveBeenCalledTimes(1);
});
it('prompts when newer and the spec differs', async () => {
it('prompts when the server copy is newer than the loaded one', () => {
setServerUpdatedAt('2026-07-08T10:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
expect(result.current.showPrompt).toBe(true);
});
it('prompts when newer and the lock state differs (external lock)', async () => {
it('does not prompt when the versions match', () => {
setServerUpdatedAt('2026-07-08T09:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck(
loaded('2026-07-08T09:00:00Z', { locked: false }),
jest.fn(),
),
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { locked: true });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
});
it('prompts when newer and the tags differ', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(
loaded('2026-07-08T09:00:00Z', { tags: [] }),
jest.fn(),
),
);
await comeBack('2026-07-08T10:00:00Z', {
tags: [{ key: 'env', value: 'prod' }],
});
await waitFor(() => expect(result.current.showPrompt).toBe(true));
});
it('does NOT prompt when newer but content (spec/tags/locked) is unchanged', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
// updatedAt bumped but every content field matches — e.g. our own lock, already
// reflected in the render cache.
await comeBack('2026-07-08T10:00:00Z');
expect(result.current.showPrompt).toBe(false);
});
it('does not prompt when the loaded copy is newer (own edit)', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T10:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T09:00:00Z', { spec: SPEC_B });
expect(result.current.showPrompt).toBe(false);
});
it('does not prompt while a mutation is in flight (optimistic save)', async () => {
it('does not prompt while a mutation is in flight (optimistic save)', () => {
mockUseIsMutating.mockReturnValue(1);
setServerUpdatedAt('2026-07-08T10:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
expect(result.current.showPrompt).toBe(false);
});
it('does not probe while the tab is hidden', async () => {
renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
setVisibility('hidden');
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
expect(mockGetDashboard).not.toHaveBeenCalled();
});
it('stops prompting for a version once dismissed', async () => {
it('stops prompting for a version once dismissed', () => {
setServerUpdatedAt('2026-07-08T10:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
expect(result.current.showPrompt).toBe(true);
act(() => result.current.dismiss());
expect(result.current.showPrompt).toBe(false);
});
it('reload refetches and closes the prompt immediately', async () => {
it('reload refetches and clears the dismissed state', () => {
setServerUpdatedAt('2026-07-08T10:00:00Z');
const refetch = jest.fn();
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), refetch),
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', refetch),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
act(() => result.current.dismiss());
expect(result.current.showPrompt).toBe(false);
act(() => result.current.reload());
expect(refetch).toHaveBeenCalledTimes(1);
expect(result.current.showPrompt).toBe(false);
});
it('re-prompts when a newer, different version appears after a reload', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
act(() => result.current.reload());
expect(result.current.showPrompt).toBe(false);
await comeBack('2026-07-08T10:05:00Z', { spec: { panels: { p3: {} } } });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
expect(result.current.showPrompt).toBe(true);
});
});

View File

@@ -1,7 +1,4 @@
import {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
/** Absolute time window in epoch milliseconds — the V5 request's native unit. */
export interface PanelTimeWindow {
@@ -68,28 +65,6 @@ const TIME_PREFERENCE_LABEL: Partial<
},
};
/** A panel's saved `visualization.timePreference`, or `undefined` when the kind has none. */
export function getPanelTimePreference(
panel: DashboardtypesPanelDTO,
): DashboardtypesTimePreferenceDTO | undefined {
const pluginSpec = panel.spec.plugin.spec;
if (pluginSpec && 'visualization' in pluginSpec) {
return pluginSpec.visualization?.timePreference;
}
return undefined;
}
/** True when the panel is locked to a fixed relative window, so it ignores the dashboard's ambient time. */
export function panelHasFixedTimePreference(
panel: DashboardtypesPanelDTO,
): boolean {
const timePreference = getPanelTimePreference(panel);
return (
timePreference !== undefined &&
timePreference !== DashboardtypesTimePreferenceDTO.global_time
);
}
export interface PanelTimePreferenceLabel {
/** Compact pill label, e.g. `6h`. */
short: string;

View File

@@ -1,92 +1,65 @@
import { getDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useIsMutating } from 'react-query';
import { useCallback, useState } from 'react';
import { useIsMutating, useQuery, useQueryClient } from 'react-query';
import {
getDashboardV2,
getGetDashboardV2QueryKey,
} from 'api/generated/services/dashboard';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
interface UseDashboardStaleCheck {
// Server copy is a newer version with different content, and not yet dismissed.
// Server copy has a newer updatedAt than the loaded one, and not yet dismissed.
showPrompt: boolean;
reload: () => void;
dismiss: () => void;
}
/**
* Prompts when another tab/user changed the open dashboard. The page's own query is frozen
* (`staleTime: Infinity`), so we re-probe the server on tab/app return (not on first load) and
* prompt only when it's strictly newer AND its content (spec, tags, or lock state) differs from
* what we're viewing — so our own edits, which advance the render cache, don't trip it.
* In-flight mutations are skipped.
* Detects when the open dashboard changed on the server (another tab/user) without
* touching the render cache: a separate query (own key) refetches on window focus
* and its updatedAt is compared to the loaded copy's. Guarded by in-flight
* mutations so an optimistic save doesn't false-positive.
*/
export function useDashboardStaleCheck(
dashboard: DashboardtypesGettableDashboardV2DTO,
dashboardId: string,
loadedUpdatedAt: string | undefined,
refetch: () => void,
): UseDashboardStaleCheck {
const {
id: dashboardId,
updatedAt: loadedUpdatedAt,
spec: loadedSpec,
tags: loadedTags,
locked: loadedLocked,
} = dashboard;
const isMutating = useIsMutating() > 0;
const [server, setServer] = useState<DashboardtypesGettableDashboardV2DTO>();
const queryClient = useQueryClient();
const [dismissedAt, setDismissedAt] = useState<string | null>(null);
useEffect(() => {
if (!dashboardId) {
return undefined;
}
const probe = async (): Promise<void> => {
// `visibilitychange` also fires on the hidden transition — only probe on return.
if (document.visibilityState !== 'visible') {
return;
}
try {
const response = await getDashboardV2({ id: dashboardId });
setServer(response.data);
} catch {
// A failed freshness probe must never surface to the viewer.
}
};
// `visibilitychange` catches tab switches; `focus` catches app/window switches.
document.addEventListener('visibilitychange', probe);
window.addEventListener('focus', probe);
return (): void => {
document.removeEventListener('visibilitychange', probe);
window.removeEventListener('focus', probe);
};
}, [dashboardId]);
const serverUpdatedAt = server?.updatedAt;
// Content the viewer cares about: panels/layout/variables (spec), tags, and lock state.
// Deep, order-independent so an optimistic patch's key reordering isn't read as a change.
const contentDiffers = useMemo(
() =>
!!server &&
(!isEqual(server.spec, loadedSpec) ||
!isEqual(server.tags, loadedTags) ||
server.locked !== loadedLocked),
[server, loadedSpec, loadedTags, loadedLocked],
const { data } = useQuery(
['dashboard-freshness', dashboardId],
({ signal }) => getDashboardV2({ id: dashboardId }, signal),
{
enabled: !!dashboardId,
refetchOnWindowFocus: true,
refetchOnMount: false,
retry: false,
// Seed from the already-loaded dashboard so mount makes no extra GET; the
// query only hits the network on a later window focus.
initialData: () =>
queryClient.getQueryData<GetDashboardV2200>(
getGetDashboardV2QueryKey({ id: dashboardId }),
),
},
);
const serverUpdatedAt = data?.data?.updatedAt;
const changed =
!isMutating &&
!!serverUpdatedAt &&
!!loadedUpdatedAt &&
new Date(serverUpdatedAt).getTime() > new Date(loadedUpdatedAt).getTime() &&
contentDiffers;
serverUpdatedAt !== loadedUpdatedAt;
const dismiss = useCallback(
(): void => setDismissedAt(serverUpdatedAt ?? null),
[serverUpdatedAt],
);
// Dismiss + refetch: close on click and pull the latest; a newer, different version re-prompts.
const reload = useCallback((): void => {
setDismissedAt(serverUpdatedAt ?? null);
refetch();
}, [refetch, serverUpdatedAt]);
setDismissedAt(null);
}, [refetch]);
return {
showPrompt: changed && serverUpdatedAt !== dismissedAt,

View File

@@ -55,7 +55,11 @@ function DashboardContainer({
// suggests them ($variable) in the panel editor and dashboards-page builder.
useSyncVariablesForSuggestions(dashboard);
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
const staleCheck = useDashboardStaleCheck(
dashboard.id,
dashboard.updatedAt,
refetch,
);
// In full screen show only the sections and panels — the header/toolbar chrome
// is hidden for a clean presentation view (exit with Esc).

View File

@@ -172,31 +172,9 @@ const GRID_COLS = 12;
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
/**
* Whether two grid rectangles intersect on both axes. Mirrors the backend's
* overlap check (a patch placing two intersecting items is rejected), so this is
* the authority the frontend must satisfy before adding an item.
*/
export function itemsOverlap(a: PlacedItem, b: PlacedItem): boolean {
const ax = a.x ?? 0;
const ay = a.y ?? 0;
const aw = a.width ?? 0;
const ah = a.height ?? 0;
const bx = b.x ?? 0;
const by = b.y ?? 0;
const bw = b.width ?? 0;
const bh = b.height ?? 0;
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
}
/**
* Placement for a new grid item: drop it right of the last row if it both fits
* the grid width and clears every existing item, else wrap to a fresh row at the
* bottom. Only the last row is preferred (items sharing the greatest top-y);
* gaps in earlier rows are left alone. The overlap guard is what keeps this
* safe — a tall panel from an earlier row can reach down into the last row, so
* "right of the last row" is not automatically free. The bottom fallback sits
* below every item and so can never overlap. This is the single placement
* primitive for create / clone / move.
* Placement for a new grid item: drop it right of the last row if there's room,
* else wrap to a fresh row at the bottom. Only the last row is considered (items
* sharing the greatest top-y); gaps in earlier rows are left alone.
*/
export function findFreeSlot(
items: PlacedItem[],
@@ -216,17 +194,7 @@ export function findFreeSlot(
.filter((it) => (it.y ?? 0) === lastRowY)
.reduce((max, it) => Math.max(max, (it.x ?? 0) + (it.width ?? 0)), 0);
// height is unbounded downward, so use 1 for the fit probe: overlap on the
// y-axis is decided by items reaching below `lastRowY`, not by the new
// panel's own height (its top sits at the greatest top-y of all items).
const candidate: PlacedItem = {
x: lastRowRightEdge,
y: lastRowY,
width: w,
height: 1,
};
const fitsWidth = lastRowRightEdge + w <= GRID_COLS;
if (fitsWidth && !items.some((it) => itemsOverlap(candidate, it))) {
if (lastRowRightEdge + w <= GRID_COLS) {
return { x: lastRowRightEdge, y: lastRowY };
}
return { x: 0, y: bottom };

View File

@@ -11,10 +11,6 @@ import type { DashboardStore } from '../useDashboardStore';
export interface CollapseSlice {
collapsed: Record<string, Record<string, boolean>>;
toggleSectionCollapse: (dashboardId: string, sectionId: string) => void;
// Whether the variables bar shows its full (expanded) list, per dashboard.
// Absent → collapsed (the default).
variablesExpanded: Record<string, boolean>;
setVariablesExpanded: (dashboardId: string, expanded: boolean) => void;
}
export const createCollapseSlice: StateCreator<
@@ -36,17 +32,4 @@ export const createCollapseSlice: StateCreator<
},
});
},
variablesExpanded: {},
setVariablesExpanded: (dashboardId, expanded): void => {
const { variablesExpanded } = get();
set({
variablesExpanded: { ...variablesExpanded, [dashboardId]: expanded },
});
},
});
/** Selector: is the variables bar expanded for this dashboard? Default collapsed. */
export const selectVariablesExpanded =
(dashboardId: string) =>
(state: DashboardStore): boolean =>
Boolean(state.variablesExpanded[dashboardId]);

View File

@@ -49,7 +49,6 @@ export const useDashboardStore = create<DashboardStore>()(
// Persist UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variablesExpanded: state.variablesExpanded,
variableValues: state.variableValues,
}),
},

View File

@@ -1,19 +0,0 @@
import { create } from 'zustand';
/**
* Ephemeral cross-route signal: the panel editor records which panel to reveal
* on the way back, and the dashboard grid scrolls that panel into view once it
* mounts, then clears the id. Kept out of `useDashboardStore` (never persisted)
* and off the URL so a refresh doesn't re-trigger the scroll.
*/
export interface ScrollToPanelStore {
scrollToPanelId: string | null;
setScrollToPanelId: (panelId: string | null) => void;
}
export const useScrollToPanelStore = create<ScrollToPanelStore>((set) => ({
scrollToPanelId: null,
setScrollToPanelId: (scrollToPanelId): void => {
set({ scrollToPanelId });
},
}));

View File

@@ -12,7 +12,6 @@ import {
LockKeyhole,
PenLine,
SquareArrowOutUpRight,
Tag,
} from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import {
@@ -31,7 +30,6 @@ import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import DeleteActionItem from './DeleteActionItem';
import EditTagsModal from './EditTagsModal';
import RenameDashboardModal from './RenameDashboardModal';
import styles from './ActionsPopover.module.scss';
@@ -41,8 +39,6 @@ interface Props {
dashboardName: string;
createdBy: string;
isLocked: boolean;
// Current tags as `key:value` strings, for the inline tag editor.
tags: string[];
// Edit permission (edit_dashboard). Read actions show regardless; edit actions are hidden without it.
canEdit: boolean;
onView: (event: React.MouseEvent<HTMLElement>) => void;
@@ -54,7 +50,6 @@ function ActionsPopover({
dashboardName,
createdBy,
isLocked,
tags,
canEdit,
onView,
}: Props): JSX.Element {
@@ -62,7 +57,6 @@ function ActionsPopover({
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [isEditTagsOpen, setIsEditTagsOpen] = useState(false);
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
// by the caller; open the copy so it can be tweaked right away.
@@ -171,35 +165,6 @@ function ActionsPopover({
</span>
</Tooltip>
)}
{canEdit && (
<Tooltip
placement="left"
title={
isLocked
? 'This dashboard is locked, so its tags cannot be edited.'
: ''
}
>
<span className={styles.menuItemWrap}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Tag size={14} />}
disabled={isLocked}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsEditTagsOpen(true);
}
}}
testId="dashboard-action-edit-tags"
>
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
</Button>
</span>
</Tooltip>
)}
{canEdit && (
<Button
color="secondary"
@@ -266,12 +231,6 @@ function ActionsPopover({
currentName={dashboardName}
onClose={(): void => setIsRenameOpen(false)}
/>
<EditTagsModal
open={isEditTagsOpen}
dashboardId={dashboardId}
currentTags={tags}
onClose={(): void => setIsEditTagsOpen(false)}
/>
</>
);
}

View File

@@ -1,137 +0,0 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
// eslint-disable-next-line no-restricted-imports -- list tag-edit targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
patchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { keyValueStringsToTags } from '../../utils/helpers';
import styles from './ActionsPopover.module.scss';
interface Props {
open: boolean;
dashboardId: string;
// Current tags as `key:value` strings.
currentTags: string[];
onClose: () => void;
}
/**
* Edits a dashboard's tags from the list via an `add /tags` patch, then refreshes
* the list. `add` is used (not `replace`) so it works whether the tags field is
* already present or absent on the stored dashboard.
*/
function EditTagsModal({
open,
dashboardId,
currentTags,
onClose,
}: Props): JSX.Element {
const [tags, setTags] = useState<string[]>(currentTags);
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
// Reset to the dashboard's tags when the modal opens. Keyed by content
// (`tagsKey`) rather than array identity, since `currentTags` is a fresh array
// on each parent render; reconstructing from the key keeps the effect deps
// stable and the reset off the per-render path.
const tagsKey = currentTags.join('\n');
useEffect(() => {
if (open) {
setTags(tagsKey ? tagsKey.split('\n') : []);
}
}, [open, tagsKey]);
const { mutate: runSave, isLoading } = useMutation({
mutationFn: () => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/tags',
value: keyValueStringsToTags(tags),
},
];
return patchDashboardV2({ id: dashboardId }, ops);
},
onSuccess: async () => {
toast.success('Tags updated');
await invalidateListDashboardsForUserV2(queryClient);
onClose();
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
// Save on Cmd/Ctrl+Enter from anywhere in the modal (not just the tag input),
// while it is open.
useEffect(() => {
if (!open) {
return undefined;
}
const onKeyDown = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
runSave();
}
};
window.addEventListener('keydown', onKeyDown);
return (): void => window.removeEventListener('keydown', onKeyDown);
}, [open, runSave]);
const title = currentTags.length > 0 ? 'Edit tags' : 'Add tags';
return (
<DialogWrapper
title={title}
open={open}
width="narrow"
onOpenChange={(next): void => {
if (!next) {
onClose();
}
}}
footer={
<div className={styles.renameFooter}>
<Button
variant="ghost"
color="secondary"
size="md"
onClick={onClose}
testId="edit-tags-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
loading={isLoading}
disabled={isLoading}
onClick={(): void => runSave()}
testId="edit-tags-submit"
>
Save
</Button>
</div>
}
>
<TagKeyValueInput
tags={tags}
onTagsChange={setTags}
placeholder="key:value (press Enter)"
testId="edit-dashboard-tags"
/>
</DialogWrapper>
);
}
export default EditTagsModal;

View File

@@ -60,12 +60,6 @@ function DashboardRow({
);
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
// Clicks inside portaled overlays (the actions menu, edit modals) bubble here
// through React's tree even though they render outside the row in the DOM.
// Only navigate when the click actually landed inside the row.
if (!event.currentTarget.contains(event.target as Node)) {
return;
}
event.stopPropagation();
markViewed(id);
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
@@ -165,7 +159,6 @@ function DashboardRow({
dashboardName={name}
createdBy={createdBy}
isLocked={isLocked}
tags={tags}
canEdit={canEdit}
onView={onClickHandler}
/>

View File

@@ -17,8 +17,6 @@ import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { keyValueStringsToTags } from '../../utils/helpers';
import DashboardImagePicker from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker';
import { Base64Icons } from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/utils';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
import styles from './NewDashboardModal.module.scss';
@@ -34,7 +32,6 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
const [name, setName] = useState(DEFAULT_NAME);
const [description, setDescription] = useState('');
const [image, setImage] = useState<string>(Base64Icons[0]);
const [tags, setTags] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
@@ -51,7 +48,6 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
image,
tags: postableTags.length ? postableTags : null,
spec: {
display: {
@@ -81,29 +77,21 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
<Typography.Text className={styles.label}>
Title <Typography.Text className={styles.required}>*</Typography.Text>
</Typography.Text>
<div className={styles.titleRow}>
<DashboardImagePicker
image={image}
onChange={setImage}
triggerClassName={styles.imageTrigger}
/>
<Input
className={styles.titleInput}
value={name}
autoFocus
maxLength={DASHBOARD_NAME_MAX_LENGTH}
placeholder="e.g. Sample Dashboard"
testId="create-dashboard-name"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setName(e.target.value)
<Input
value={name}
autoFocus
maxLength={DASHBOARD_NAME_MAX_LENGTH}
placeholder="e.g. Sample Dashboard"
testId="create-dashboard-name"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setName(e.target.value)
}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
void handleCreate();
}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
void handleCreate();
}
}}
/>
</div>
}}
/>
</div>
<div className={styles.field}>

View File

@@ -35,28 +35,6 @@
color: var(--danger-background);
}
// Title row: icon picker joined flush to the left of the name input.
.titleRow {
display: flex;
gap: 4px;
align-items: center;
// The icon picker renders its dropdown inline (no portal); lift it above the
// description/tags fields below it.
:global([data-radix-popper-content-wrapper]) {
z-index: 1100 !important;
}
}
.imageTrigger {
border-radius: 2px 0 0 2px;
}
.titleInput {
flex: 1;
border-radius: 0 2px 2px 0;
}
.hint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
@@ -82,62 +60,26 @@
}
}
// "From a template" tab: a centered browse-and-request placeholder until the
// templates BE lands. Fixed height matches .panel so switching tabs won't resize.
.templatesPanel {
height: 460px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 6px 24px;
text-align: center;
}
.templatesIcon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-bottom: 4px;
border-radius: 10px;
color: var(--bg-robin-400);
background: var(--callout-primary-background);
border: 1px solid var(--callout-primary-border);
}
.templatesDesc {
max-width: 360px;
}
.browseLink {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: var(--bg-robin-400);
.cardName {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
&:hover {
color: var(--bg-robin-300);
}
}
.requestForm {
display: flex;
gap: 8px;
width: 100%;
max-width: 380px;
margin-top: 16px;
padding-top: 16px;
.cardDesc {
color: var(--l2-foreground);
font-size: var(--font-size-xs);
line-height: 1.45;
}
.requestRow {
border-top: 1px solid var(--l2-border);
}
padding-top: 12px;
.requestInput {
flex: 1;
:global(.request-entity-container) {
gap: 10px 16px;
padding: 14px 16px;
}
}
.importHeader {
@@ -166,6 +108,73 @@
margin-top: 8px;
}
.templatesLayout {
display: flex;
gap: 12px;
flex: 1;
min-height: 0;
}
.templatesList {
flex: none;
width: 200px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
padding-right: 4px;
}
.templateItem {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
text-align: left;
cursor: pointer;
transition:
background 0.12s,
border-color 0.12s;
}
.templateItem:hover {
background: var(--l2-background);
}
.templateItemActive {
background: var(--l2-background);
border-color: var(--primary-background);
}
.templateName {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.templateCat {
color: var(--l3-foreground);
font-size: var(--font-size-xs);
}
.templatesPreview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.previewHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.jsonError {
display: flex;
align-items: center;

View File

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

View File

@@ -1,120 +1,67 @@
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import {
Check,
LayoutDashboard,
LoaderCircle,
SquareArrowOutUpRight,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useState } from 'react';
import { generatePath } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import styles from './NewDashboardModal.module.scss';
const TEMPLATES_DOCS_URL =
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
interface Props {
onClose: () => void;
}
// Templates aren't served by the BE yet, so this tab is a browse-and-request
// placeholder: link out to the published template library, and let cloud users
// request one we haven't built.
function TemplatesPanel(): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
// so the action creates a blank dashboard.
function TemplatesPanel({ onClose }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [creating, setCreating] = useState(false);
const requestName = name.trim();
const handleRequest = async (): Promise<void> => {
if (!requestName || submitting) {
const handleCreate = async (): Promise<void> => {
if (creating) {
return;
}
try {
setSubmitting(true);
const response = await logEvent('Dashboard Requested', {
screen: 'Dashboard list page',
dashboard: requestName,
setCreating(true);
logEvent('Dashboard List: Use template clicked', {});
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: 'Sample Dashboard' },
layouts: [],
panels: {},
variables: [],
},
});
if (response.statusCode === 200) {
toast.success('Dashboard request submitted');
setName('');
} else {
toast.error(response.error || 'Something went wrong');
}
} catch {
toast.error('Something went wrong');
} finally {
setSubmitting(false);
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);
} catch (e) {
showErrorModal(e as APIError);
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
setCreating(false);
}
};
return (
<div className={styles.templatesPanel}>
<span className={styles.templatesIcon}>
<LayoutDashboard size={20} />
</span>
<Typography variant="title" size="lg" weight="semibold">
Dashboard templates
</Typography>
<Typography
variant="text"
size="sm"
color="muted"
className={styles.templatesDesc}
>
Browse our library of ready-made dashboards, or request a new one and
we&apos;ll build it for you.
</Typography>
<a
className={styles.browseLink}
href={TEMPLATES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
Browse dashboard templates
<SquareArrowOutUpRight size={14} />
</a>
{isCloudUser && (
<div className={styles.requestForm}>
<Input
className={styles.requestInput}
placeholder="Enter dashboard name..."
value={name}
testId="request-dashboard-name"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setName(e.target.value)
}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
void handleRequest();
}
}}
/>
<Button
variant="solid"
color="primary"
size="md"
disabled={submitting || requestName.length === 0}
testId="request-dashboard-submit"
prefix={
submitting ? (
<LoaderCircle size={14} className={styles.spinner} />
) : (
<Check size={14} />
)
}
onClick={(): void => {
void handleRequest();
}}
>
Submit
</Button>
</div>
)}
<div className={styles.panel}>
<div className="new-dashboard-templates-modal">
<DashboardTemplatesContent
onCreateNewDashboard={(): void => {
void handleCreate();
}}
/>
</div>
</div>
);
}

View File

@@ -11,9 +11,9 @@ export type DashboardListItem = DashboardtypesListedDashboardForUserV2DTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,
): string[] =>
// Always render both sides (so an env:env tag reads "env:env", not a lone
// "env"); fall back to the bare key only when there's no value.
(tags ?? []).map((tag) => (tag.value ? `${tag.key}:${tag.value}` : tag.key));
(tags ?? []).map((tag) =>
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
);
// Convert validated `key:value` tag strings (from TagKeyValueInput) into the
// postable tag DTO shape. The first colon separates key from value.

View File

@@ -1,56 +0,0 @@
import { scrollIntoViewWhenReady } from '../scrollIntoViewWhenReady';
describe('scrollIntoViewWhenReady', () => {
let rafSpy: jest.SpyInstance;
beforeEach(() => {
// Run rAF callbacks synchronously so the poll loop resolves within the test.
rafSpy = jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb: FrameRequestCallback): number => {
cb(0);
return 0;
});
});
afterEach(() => {
rafSpy.mockRestore();
});
it('scrolls the resolved element into view immediately when it is ready', () => {
const scrollIntoView = jest.fn();
const el = { scrollIntoView } as unknown as Element;
scrollIntoViewWhenReady(() => el);
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
expect(rafSpy).not.toHaveBeenCalled();
});
it('polls via rAF until the target appears, then scrolls it once', () => {
const scrollIntoView = jest.fn();
const el = { scrollIntoView } as unknown as Element;
let calls = 0;
const resolveTarget = jest.fn(() => {
calls += 1;
return calls >= 3 ? el : null;
});
scrollIntoViewWhenReady(resolveTarget);
expect(resolveTarget).toHaveBeenCalledTimes(3);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
});
it('bails after the attempt budget when the target never appears', () => {
const resolveTarget = jest.fn(() => null);
scrollIntoViewWhenReady(resolveTarget, 5);
// Initial call + 5 rAF retries.
expect(resolveTarget).toHaveBeenCalledTimes(6);
});
});

View File

@@ -1,20 +0,0 @@
/**
* Polls via rAF until `resolveTarget` returns an element, then scrolls it into
* view — for nodes that mount a frame or two after the call (e.g. an optimistic
* write). Return `null`/`undefined` while not ready; bails after `attempts`.
*/
export function scrollIntoViewWhenReady(
resolveTarget: () => Element | null | undefined,
attempts = 40,
): void {
const target = resolveTarget();
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (attempts > 0) {
requestAnimationFrame(() =>
scrollIntoViewWhenReady(resolveTarget, attempts - 1),
);
}
}

View File

@@ -42,6 +42,7 @@ type querier struct {
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
@@ -61,6 +62,7 @@ func New(
metadataStore telemetrytypes.MetadataStore,
promEngine prometheus.Prometheus,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
@@ -82,6 +84,7 @@ func New(
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
aiTraceStmtBuilder: aiTraceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
@@ -235,10 +238,18 @@ func (q *querier) buildQueries(
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
stmtBuilder := q.traceStmtBuilder
if spec.Source == telemetrytypes.SourceAI {
event.Source = telemetrytypes.SourceAI.StringValue()
stmtBuilder = q.aiTraceStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if spec.Source == telemetrytypes.SourceAI {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "source \"ai\" is only supported for the traces signal, not logs")
}
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
stmtBuilder := q.logStmtBuilder
@@ -249,6 +260,9 @@ func (q *querier) buildQueries(
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if spec.Source == telemetrytypes.SourceAI {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "source \"ai\" is only supported for the traces signal, not metrics")
}
// Spec was already patched by resolveMetricMetadata. Queries
// whose every aggregation was missing live in
// missingMetricQuerySet and produce empty preseeded results
@@ -858,7 +872,11 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
return newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
shiftStmtBuilder := q.traceStmtBuilder
if qt.spec.Source == telemetrytypes.SourceAI {
shiftStmtBuilder = q.aiTraceStmtBuilder
}
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
case *builderQuery[qbtypes.LogAggregation]:
specCopy := qt.spec.Copy()

View File

@@ -49,6 +49,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
@@ -121,6 +122,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
&mockMetricStmtBuilder{}, // metricStmtBuilder

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryai"
"github.com/SigNoz/signoz/pkg/telemetryaudit"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
@@ -92,6 +93,30 @@ func newProvider(
cfg.SkipResourceFingerprint.Threshold,
)
// AI trace statement builder (source=ai). The gen_ai gate/column keys are
// surfaced by the metadata store itself (enrichWithGenAIKeys), so queries work
// before any gen_ai metadata is ingested — no per-builder decoration needed.
aiBaseCondition := telemetryai.NewGenAIBaseConditionProvider()
aiDelegateTraceStmtBuilder := telemetrytraces.NewTraceQueryStatementBuilder(
settings,
telemetryMetadataStore,
traceFieldMapper,
traceConditionBuilder,
traceAggExprRewriter,
telemetryStore,
flagger,
cfg.SkipResourceFingerprint.Enabled,
cfg.SkipResourceFingerprint.Threshold,
)
aiTraceStmtBuilder := telemetryai.NewAITraceStatementBuilder(
settings,
telemetryMetadataStore,
traceFieldMapper,
traceConditionBuilder,
aiBaseCondition,
aiDelegateTraceStmtBuilder,
)
// Create trace operator statement builder
traceOperatorStmtBuilder := telemetrytraces.NewTraceOperatorStatementBuilder(
settings,
@@ -185,6 +210,7 @@ func newProvider(
telemetryMetadataStore,
prometheus,
traceStmtBuilder,
aiTraceStmtBuilder,
logStmtBuilder,
auditStmtBuilder,
metricStmtBuilder,

View File

@@ -48,6 +48,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
metricStmtBuilder,
@@ -103,6 +104,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
logStmtBuilder, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
@@ -152,6 +154,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
metadataStore,
nil, // prometheus
traceStmtBuilder, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder

View File

@@ -0,0 +1,147 @@
package querybuilder
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
)
// SplitFilterForAggregates partitions a single filter expression into a span-level
// part (a WHERE over spans) and a trace-level part (a HAVING over per-trace
// aggregates), splitting on the top-level AND.
//
// A key is trace-level when it carries the `trace`/`tracefield` field context (e.g.
// `trace.completion_tokens`) or, with no explicit context, its name is in
// aggregateNames. Trace-level and span-level keys may be AND-combined (they run at
// different query stages) but not OR-combined; an OR that mixes the two is an error.
//
// Syntax errors are ignored here — each part is re-parsed downstream (PrepareWhereClause
// for the span part, the HAVING rewriter for the trace part), which surface them.
func SplitFilterForAggregates(query string, aggregateNames map[string]struct{}) (spanExpr string, havingExpr string, err error) {
if strings.TrimSpace(query) == "" {
return "", "", nil
}
s := filterSplitter{query: query, aggregateNames: aggregateNames}
s.visit(parseFilterQuery(query))
if s.mixedOR {
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"trace-level and span-level filters cannot be combined with OR; use AND")
}
return strings.Join(s.span, " AND "), strings.Join(s.having, " AND "), nil
}
func parseFilterQuery(query string) antlr.Tree {
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
lexer.RemoveErrorListeners()
parser := grammar.NewFilterQueryParser(antlr.NewCommonTokenStream(lexer, 0))
parser.RemoveErrorListeners()
return parser.Query()
}
// filterSplitter walks the parse tree once, flattening the top-level AND chain and
// routing each atom (a comparison, a NOT expression, or a whole multi-branch OR group)
// to the span or having bucket by the class of the keys it references.
type filterSplitter struct {
query string
aggregateNames map[string]struct{}
span []string
having []string
mixedOR bool
}
func (s *filterSplitter) visit(node antlr.Tree) {
switch n := node.(type) {
case *grammar.QueryContext:
if n.Expression() != nil {
s.visit(n.Expression())
}
case *grammar.ExpressionContext:
if n.OrExpression() != nil {
s.visit(n.OrExpression())
}
case *grammar.OrExpressionContext:
// a single branch is just an AND chain; multiple branches are a real OR, kept
// whole so a class-mixing OR can be rejected.
if ands := n.AllAndExpression(); len(ands) == 1 {
s.visit(ands[0])
} else {
s.route(n)
}
case *grammar.AndExpressionContext:
for _, u := range n.AllUnaryExpression() {
s.visit(u)
}
case *grammar.UnaryExpressionContext:
if n.NOT() != nil {
s.route(n)
} else if n.Primary() != nil {
s.visit(n.Primary())
}
case *grammar.PrimaryContext:
if n.OrExpression() != nil { // parenthesized sub-expression
s.visit(n.OrExpression())
} else {
s.route(n)
}
}
}
// route classifies an atom and appends its original source text to the right bucket.
func (s *filterSplitter) route(atom antlr.ParserRuleContext) {
isTrace, isSpan := classifyKeys(atom, s.aggregateNames)
if isTrace && isSpan {
s.mixedOR = true
return
}
text := atomSourceText(s.query, atom)
if isTrace {
s.having = append(s.having, text)
} else {
s.span = append(s.span, text)
}
}
// classifyKeys reports whether a subtree references trace-level and/or span-level keys.
// Explicit contexts win; an unspecified-context name is trace-level if it is an
// aggregate name (bare or with the `trace.` prefix).
func classifyKeys(node antlr.Tree, aggregateNames map[string]struct{}) (isTrace, isSpan bool) {
kc, ok := node.(*grammar.KeyContext)
if ok {
key := telemetrytypes.GetFieldKeyFromKeyText(kc.GetText())
switch {
case key.FieldContext == telemetrytypes.FieldContextTrace:
isTrace = true
case key.FieldContext == telemetrytypes.FieldContextUnspecified:
if _, agg := aggregateNames[strings.TrimPrefix(key.Name, "trace.")]; agg {
isTrace = true
} else {
isSpan = true
}
default:
isSpan = true
}
return
}
for i := 0; i < node.GetChildCount(); i++ {
t, s := classifyKeys(node.GetChild(i), aggregateNames)
isTrace = isTrace || t
isSpan = isSpan || s
}
return
}
// atomSourceText returns the original source substring for an atom, preserving
// whitespace. The token stream drops skipped whitespace, which would glue word
// operators (OR/AND/NOT) to their operands, so slice the input by char offsets.
func atomSourceText(query string, atom antlr.ParserRuleContext) string {
start, stop := atom.GetStart(), atom.GetStop()
if start == nil || stop == nil || start.GetStart() < 0 || stop.GetStop() >= len(query) || stop.GetStop() < start.GetStart() {
return atom.GetText()
}
return query[start.GetStart() : stop.GetStop()+1]
}

View File

@@ -0,0 +1,51 @@
package querybuilder
import (
"strings"
"testing"
)
func TestSplitFilterForAggregates(t *testing.T) {
agg := map[string]struct{}{"completion_tokens": {}, "span_count": {}, "prompt_tokens": {}}
cases := []struct {
name string
query string
wantSpan string // substring expected in span part ("" => span empty)
wantHaving string // substring expected in having part ("" => having empty)
wantErr bool
}{
{"span only", "service.name = 'x'", "service.name = 'x'", "", false},
{"agg only bare", "completion_tokens > 1000", "", "completion_tokens > 1000", false},
{"agg only trace ctx", "trace.completion_tokens > 1000", "", "trace.completion_tokens > 1000", false},
{"span AND agg", "service.name = 'x' AND completion_tokens > 1000", "service.name = 'x'", "completion_tokens > 1000", false},
// the whitespace-preservation case: OR between two aggregates must keep spaces
{"agg OR agg", "completion_tokens > 1000 OR span_count > 3", "", "completion_tokens > 1000 OR span_count > 3", false},
{"span OR span", "service.name = 'x' OR kind_string = 'Internal'", "service.name = 'x' OR kind_string = 'Internal'", "", false},
{"agg OR span rejected", "completion_tokens > 1000 OR service.name = 'x'", "", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
span, having, err := SplitFilterForAggregates(tc.query, agg)
if (err != nil) != tc.wantErr {
t.Fatalf("query %q: err=%v wantErr=%v", tc.query, err, tc.wantErr)
}
if tc.wantErr {
return
}
if tc.wantSpan == "" && span != "" {
t.Errorf("query %q: expected empty span, got %q", tc.query, span)
}
if tc.wantSpan != "" && !strings.Contains(span, tc.wantSpan) {
t.Errorf("query %q: span %q missing %q", tc.query, span, tc.wantSpan)
}
if tc.wantHaving == "" && having != "" {
t.Errorf("query %q: expected empty having, got %q", tc.query, having)
}
if tc.wantHaving != "" && !strings.Contains(having, tc.wantHaving) {
t.Errorf("query %q: having %q missing %q", tc.query, having, tc.wantHaving)
}
})
}
}

View File

@@ -18,6 +18,19 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
}
}
// Rewrite rewrites and validates a HAVING expression against a caller-supplied
// column map (user-facing name -> SQL identifier/expression). Values are inlined, so
// the result is a bare SQL boolean expression with no bound args. Used by callers
// that project their own aggregate columns (e.g. the AI trace list) rather than the
// query's Aggregations.
func (r *HavingExpressionRewriter) Rewrite(expression string, columnMap map[string]string) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {
return "", nil
}
r.columnMap = columnMap
return r.rewriteAndValidate(expression)
}
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {

View File

@@ -0,0 +1,55 @@
package telemetryai
import (
"strings"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// BaseConditionProvider defines which spans (and traces) are in scope, decoupled
// from the query. Swap it to redefine "an AI trace" without touching the builder.
//
// It only *declares* the gate (a grammar expression + its field keys); the builder
// resolves those keys through the field mapper, so all attribute access is
// materialization/evolution aware — no hardcoded map lookups.
type BaseConditionProvider interface {
// FilterExpression is the grammar-level (EXISTS) gate, resolved via the visitor.
FilterExpression() string
// FieldKeys are the gate's keys: registered in metadata and used to build the
// per-span mask (OR of resolved EXISTS conditions).
FieldKeys() []*telemetrytypes.TelemetryFieldKey
}
// genAIBaseConditionProvider: an AI trace has >=1 gen_ai LLM, tool, or agent span.
type genAIBaseConditionProvider struct {
keys []string
}
var _ BaseConditionProvider = (*genAIBaseConditionProvider)(nil)
func NewGenAIBaseConditionProvider() BaseConditionProvider {
return &genAIBaseConditionProvider{
keys: []string{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName},
}
}
func (p *genAIBaseConditionProvider) FilterExpression() string {
parts := make([]string, 0, len(p.keys))
for _, k := range p.keys {
parts = append(parts, k+" EXISTS")
}
return strings.Join(parts, " OR ")
}
func (p *genAIBaseConditionProvider) FieldKeys() []*telemetrytypes.TelemetryFieldKey {
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(p.keys))
for _, k := range p.keys {
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
Name: k,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
}
return keys
}

View File

@@ -0,0 +1,99 @@
package telemetryai
import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
type TraceColumn struct {
Alias string
// Orderable marks a column computable in the mask-pruned `matched` pass (a
// gen_ai-scoped aggregate): the only columns usable for ORDER BY and the
// aggregate filter. All-span columns (span_count, duration_nano, …) are false —
// they are output-only, since the matched pass sees only gen_ai spans.
Orderable bool
Intrinsic string // fixed SQL over intrinsic columns; used verbatim (nil Attr)
Attr *AttrAgg // field-mapper-resolved aggregate (nil Intrinsic)
}
// AttrAgg describes an aggregate the builder assembles after resolving fields:
// - Func "count" + ExistsKey -> countIf(<ExistsKey EXISTS>) (e.g. llm_call_count)
// - Func + ValueKey -> Func(<resolved value>) (e.g. sum tokens)
// - Func + ValueExpr + Scoped -> FuncIf(<value>, <gate mask>) (e.g. last_activity_time)
type AttrAgg struct {
Func string // sum | max | min | count
ValueKey *telemetrytypes.TelemetryFieldKey // attribute value to aggregate (resolved as Float64)
ValueExpr string // fixed value expr when ValueKey is nil (e.g. "timestamp")
Scoped bool // wrap as <Func>If(value, <gate mask>)
ExistsKey *telemetrytypes.TelemetryFieldKey // count spans where this key exists (countIf)
}
// ProjectionProvider decides which per-trace columns the list computes and which
// are sortable, decoupled from selection and topology.
type ProjectionProvider interface {
Columns() []TraceColumn
// DefaultOrderAlias is sorted by (desc) when the query gives no order.
DefaultOrderAlias() string
// AggregateAliases are the computed per-trace (trace-level) column names, used to
// classify which filter keys are trace-level vs span-level. Only the Orderable
// subset is actually usable in ORDER BY / the aggregate filter; the rest are
// output-only. Excludes aliases that are also real span/resource keys (service.name).
AggregateAliases() []string
}
// CommonTraceColumns are domain-neutral intrinsic columns any trace list can reuse.
// All are over-all-spans intrinsics, so none is Orderable (not computable in the
// mask-pruned matched pass) — they are output-only, computed in the enrichment scan.
func CommonTraceColumns() []TraceColumn {
return []TraceColumn{
{Alias: "start_time", Intrinsic: "min(timestamp)", Orderable: false},
{Alias: "end_time", Intrinsic: "max(timestamp)", Orderable: false},
{Alias: "duration_nano", Intrinsic: "(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp)))", Orderable: false},
{Alias: "span_count", Intrinsic: "count()", Orderable: false},
{Alias: "root_span_name", Intrinsic: "anyIf(name, parent_span_id = '')", Orderable: false},
{Alias: "service.name", Intrinsic: "any(resource_string_service$$name)", Orderable: false},
}
}
// genAIProjectionProvider adds AI/LLM per-trace metrics to the common columns.
type genAIProjectionProvider struct{}
var _ ProjectionProvider = (*genAIProjectionProvider)(nil)
func NewGenAIProjectionProvider() ProjectionProvider {
return &genAIProjectionProvider{}
}
func (genAIProjectionProvider) Columns() []TraceColumn {
// Key definitions (name/context/type) live once in GenAIFieldDefinitions, shared
// with the metadata enrichment. Copy to locals so we can take their address.
reqModel := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIRequestModel]
inTok := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIUsageInputTokens]
outTok := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIUsageOutputTokens]
cols := CommonTraceColumns()
return append(cols,
// LLM calls only (request model present), not the full gate (tool/agent too).
TraceColumn{Alias: "llm_call_count", Orderable: true, Attr: &AttrAgg{Func: "count", ExistsKey: &reqModel}},
// tokens live only on LLM spans, so a plain sum over resolved values (NULL
// elsewhere) is correct without scoping to the mask. Aliases match the OTel
// source attributes (gen_ai.usage.input_tokens / output_tokens).
TraceColumn{Alias: "input_tokens", Orderable: true, Attr: &AttrAgg{Func: "sum", ValueKey: &inTok}},
TraceColumn{Alias: "output_tokens", Orderable: true, Attr: &AttrAgg{Func: "sum", ValueKey: &outTok}},
// timestamp of the last in-scope (gen_ai: LLM/tool/agent) span.
TraceColumn{Alias: "last_activity_time", Orderable: true, Attr: &AttrAgg{Func: "max", ValueExpr: "timestamp", Scoped: true}},
)
}
func (genAIProjectionProvider) DefaultOrderAlias() string { return "last_activity_time" }
func (genAIProjectionProvider) AggregateAliases() []string {
// every computed column except service.name (a real resource key, filterable
// at the span level). Of these, only the gen_ai-scoped ones (llm_call_count,
// input_tokens, output_tokens, last_activity_time) are Orderable and thus usable
// in ORDER BY / the aggregate filter; the rest are output-only.
return []string{
"start_time", "end_time", "duration_nano", "span_count", "root_span_name",
"llm_call_count", "input_tokens", "output_tokens", "last_activity_time",
}
}

View File

@@ -0,0 +1,722 @@
package telemetryai
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
var (
ErrUnsupportedRequestType = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type for source=ai")
)
// scopedTraceStatementBuilder builds a trace list scoped to a span-selection
// category. Topology is fixed; selection (BaseConditionProvider) and columns
// (ProjectionProvider) are pluggable, so a new category is a new pair of
// providers, not new topology.
type scopedTraceStatementBuilder struct {
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
baseCond BaseConditionProvider
projection ProjectionProvider
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
}
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*scopedTraceStatementBuilder)(nil)
// NewScopedTraceStatementBuilder wires the generic trace-list builder. The trace
// builder is reused for the span-list (raw) path.
func NewScopedTraceStatementBuilder(
settings factory.ProviderSettings,
metadataStore telemetrytypes.MetadataStore,
fieldMapper qbtypes.FieldMapper,
conditionBuilder qbtypes.ConditionBuilder,
baseCond BaseConditionProvider,
projection ProjectionProvider,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
) *scopedTraceStatementBuilder {
aiSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetryai")
return &scopedTraceStatementBuilder{
logger: aiSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
cb: conditionBuilder,
baseCond: baseCond,
projection: projection,
traceStmtBuilder: traceStmtBuilder,
}
}
// NewAITraceStatementBuilder is the scoped builder with the gen_ai gate + AI projection.
func NewAITraceStatementBuilder(
settings factory.ProviderSettings,
metadataStore telemetrytypes.MetadataStore,
fieldMapper qbtypes.FieldMapper,
conditionBuilder qbtypes.ConditionBuilder,
baseCond BaseConditionProvider,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
) *scopedTraceStatementBuilder {
return NewScopedTraceStatementBuilder(settings, metadataStore, fieldMapper, conditionBuilder, baseCond, NewGenAIProjectionProvider(), traceStmtBuilder)
}
func (b *scopedTraceStatementBuilder) Build(
ctx context.Context,
start uint64,
end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
switch requestType {
case qbtypes.RequestTypeTrace:
return b.buildTraceListQuery(ctx, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
case qbtypes.RequestTypeRaw:
return b.buildDelegated(ctx, start, end, requestType, query, variables)
default:
return nil, ErrUnsupportedRequestType
}
}
// buildDelegated ANDs the base gate into the user filter and delegates to the
// standard trace builder (the span-list / raw path).
func (b *scopedTraceStatementBuilder) buildDelegated(
ctx context.Context,
start, end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
gate := b.baseCond.FilterExpression()
expr := gate
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
expr = fmt.Sprintf("(%s) AND (%s)", gate, query.Filter.Expression)
}
// shallow copy; only Filter is replaced, caller's query untouched
gated := query
gated.Filter = &qbtypes.Filter{Expression: expr}
return b.traceStmtBuilder.Build(ctx, start, end, requestType, gated, variables)
}
// buildTraceListQuery is the map for the whole file. It resolves the columns, then
// wires the CTE pipeline that was benchmarked (see ai-qb-handoff.md): a single
// windowed pass picks the top-N traces, then a bucket-pruned pass enriches only those.
// The helpers appear in this file in the order they run here.
//
// RESOLVE (turn keys/columns into SQL, field-mapper aware)
// fetchKeys → metadata for the keys we reference
// resolveMask → the "is a gen_ai span" predicate (OR of EXISTS) [existsExpr]
// resolveColumns → per-trace column SQL: intrinsics + resolved aggregates
// resolveListOrders→ which resolved columns to ORDER BY
// splitFilter → span-level predicate + trace-level HAVING expression
//
// BUILD (compose the CTE pipeline)
// matched [buildMatchedCTE] ONE windowed, mask-pruned GROUP BY trace_id pass over
// │ the span index that applies the gate (+ span filter as
// │ countIf existence), the trace-level HAVING, ORDER BY and
// │ LIMIT/OFFSET in a single scan → top-N trace_ids + their
// │ gen_ai-scoped ranking metrics. No giant gate id-set.
// ▼
// ranked [buildRankedCTE] per-trace [start,end] bounds for those N traces, read
// │ from the small distributed_trace_summary table.
// ▼
// buckets [buildBucketsCTE] the exact ts_bucket_start values those N traces touch,
// │ so the enrichment scan is primary-key pruned.
// ▼
// enrichment[buildEnrichmentSelect] all per-trace columns for the N traces, scanning
// only their buckets (full trace, not window-clipped).
//
// Only gen_ai-scoped aggregates (tokens, llm activity, llm_call_count) are computable in
// the mask-pruned `matched` pass, so only those are orderable / usable in the aggregate
// filter. All-span columns (span_count, duration_nano, …) are output-only.
//
// start/end are nanoseconds.
func (b *scopedTraceStatementBuilder) buildTraceListQuery(
ctx context.Context,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
endBucket := end / querybuilder.NsToSeconds
limit := query.Limit
if limit <= 0 {
limit = 100
}
// Resolve the gate keys + columns once; every attribute access below goes through
// the field mapper (materialization/evolution aware), never a hardcoded map lookup.
keys, err := b.fetchKeys(ctx)
if err != nil {
return nil, err
}
maskExpr, maskArgs, err := b.resolveMask(ctx, start, end, keys)
if err != nil {
return nil, err
}
resolved, err := b.resolveColumns(ctx, start, end, keys, maskExpr, maskArgs)
if err != nil {
return nil, err
}
orders, err := b.resolveListOrders(query.Order, resolved)
if err != nil {
return nil, err
}
orderableSet := orderableAliasSet(resolved)
// Split the user filter: span-level predicate + trace-level HAVING expression.
fp, err := b.splitFilter(ctx, query, b.aggregateAliasSet(), orderableSet, start, end, variables)
if err != nil {
return nil, err
}
// matched → ranked → buckets → enrichment
matchedFrag, matchedArgs, err := b.buildMatchedCTE(start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, maskArgs, fp, limit, query.Offset)
if err != nil {
return nil, err
}
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
bucketsFrag := buildBucketsCTE()
mainSQL, mainArgs := b.buildEnrichmentSelect(resolved, orders)
cteFragments := []string{matchedFrag, rankedFrag, bucketsFrag}
cteArgs := [][]any{matchedArgs, rankedArgs, nil}
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
return &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: fp.warnings,
WarningsDocURL: fp.warningsURL,
}, nil
}
// ---------------------------------------------------------------------------
// RESOLVE — turn keys/columns into field-mapper-aware SQL
// ---------------------------------------------------------------------------
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
fields := b.resolverFieldKeys()
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
for _, k := range fields {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
Name: k.Name,
Signal: k.Signal,
FieldContext: k.FieldContext,
})
}
keys, _, err := b.metadataStore.GetKeysMulti(ctx, selectors)
return keys, err
}
func (b *scopedTraceStatementBuilder) resolverFieldKeys() []*telemetrytypes.TelemetryFieldKey {
seen := make(map[string]struct{})
var out []*telemetrytypes.TelemetryFieldKey
add := func(k *telemetrytypes.TelemetryFieldKey) {
if k == nil {
return
}
if _, dup := seen[k.Name]; dup {
return
}
seen[k.Name] = struct{}{}
out = append(out, k)
}
for _, k := range b.baseCond.FieldKeys() {
add(k)
}
for _, c := range b.projection.Columns() {
if c.Attr != nil {
add(c.Attr.ValueKey)
add(c.Attr.ExistsKey)
}
}
return out
}
// resolveMask builds the per-span in-scope mask: OR of resolved EXISTS predicates
// over the base condition's field keys.
func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error) {
fieldKeys := b.baseCond.FieldKeys()
parts := make([]string, 0, len(fieldKeys))
var args []any
for _, key := range fieldKeys {
e, a, err := b.existsExpr(ctx, start, end, keys, key)
if err != nil {
return "", nil, err
}
parts = append(parts, e)
args = append(args, a...)
}
return "(" + strings.Join(parts, " OR ") + ")", args, nil
}
// existsExpr resolves a field-mapper-aware EXISTS predicate for key (materialized
// column when present, else the map). Escaped once so it round-trips when embedded
// in an outer builder.
func (b *scopedTraceStatementBuilder) existsExpr(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, key *telemetrytypes.TelemetryFieldKey) (string, []any, error) {
resolvedKey := key
cands := keys[key.Name]
if len(cands) == 0 {
cands = []*telemetrytypes.TelemetryFieldKey{key}
} else {
resolvedKey = cands[0]
}
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := b.cb.ConditionFor(ctx, start, end, resolvedKey, cands, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return "", nil, err
}
sb.Where(conds[0])
expr, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
expr = strings.TrimPrefix(expr, "WHERE ")
return sqlbuilder.Escape(expr), args, nil
}
// resolvedColumn is a projection column whose attribute access has been resolved to
// SQL via the field mapper. expr is escaped once, ready to embed in an outer SELECT.
type resolvedColumn struct {
alias string
expr string
args []any
orderable bool
}
// resolveColumns turns the projection's declarative columns into SQL, resolving all
// attribute access through the field mapper.
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, maskExpr string, maskArgs []any) ([]resolvedColumn, error) {
cols := b.projection.Columns()
out := make([]resolvedColumn, 0, len(cols))
for _, c := range cols {
rc := resolvedColumn{alias: c.Alias, orderable: c.Orderable}
if c.Attr == nil {
// intrinsic: escape once (no-op unless it references a $$ column).
rc.expr = sqlbuilder.Escape(c.Intrinsic)
out = append(out, rc)
continue
}
a := c.Attr
switch {
case a.Func == "count" && a.ExistsKey != nil:
cond, cargs, err := b.existsExpr(ctx, start, end, keys, a.ExistsKey)
if err != nil {
return nil, err
}
rc.expr = fmt.Sprintf("countIf(%s)", cond)
rc.args = cargs
default:
var vexpr string
var vargs []any
if a.ValueKey != nil {
e, ar, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, a.ValueKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeFloat64, nil, false)
if err != nil {
return nil, err
}
vexpr, vargs = e, ar
} else {
vexpr = a.ValueExpr
}
if a.Scoped {
rc.expr = fmt.Sprintf("%sIf(%s, %s)", a.Func, vexpr, maskExpr)
rc.args = append(append([]any{}, vargs...), maskArgs...)
} else {
rc.expr = fmt.Sprintf("%s(%s)", a.Func, vexpr)
rc.args = vargs
}
}
out = append(out, rc)
}
return out, nil
}
// listOrder resolves a sort key to an aggregate-column alias + direction. Both the
// matched CTE and the enrichment select that alias, so both ORDER BY it.
type listOrder struct {
alias string
direction string
}
// resolveListOrders maps order keys to the resolved orderable columns; non-orderable
// columns are rejected. Defaults to the projection's default order.
func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy, resolved []resolvedColumn) ([]listOrder, error) {
byAlias := make(map[string]resolvedColumn, len(resolved))
orderable := make([]string, 0, len(resolved))
for _, rc := range resolved {
byAlias[rc.alias] = rc
if rc.orderable {
orderable = append(orderable, rc.alias)
}
}
if len(order) == 0 {
return []listOrder{{alias: b.projection.DefaultOrderAlias(), direction: "DESC"}}, nil
}
orders := make([]listOrder, 0, len(order))
for _, o := range order {
direction := "DESC"
if o.Direction == qbtypes.OrderDirectionAsc {
direction = "ASC"
}
rc, ok := byAlias[o.Key.Name]
if !ok || !rc.orderable {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
"unsupported order key %q for the trace list; orderable keys: %s", o.Key.Name, strings.Join(orderable, ", "))
}
orders = append(orders, listOrder{alias: rc.alias, direction: direction})
}
return orders, nil
}
// filterParts is the split of the user filter into a resolved span-level predicate
// (used both to widen the matched WHERE prune and as a countIf existence in HAVING)
// and a trace-level HAVING expression.
type filterParts struct {
spanPred string
spanArgs []any
hasSpanFilter bool
havingExpr string
warnings []string
warningsURL string
}
// splitFilter partitions query.Filter into a span-level predicate and a trace-level
// HAVING expression; an explicit query.Having is ANDed onto the latter. The trace-level
// expression is validated against the aggregates computable in the matched pass.
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem) (filterParts, error) {
var fp filterParts
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
if err != nil {
return fp, err
}
fp.havingExpr = traceExpr
if strings.TrimSpace(spanExpr) != "" {
pred, args, warnings, url, err := b.resolveSpanPredicate(ctx, start, end, spanExpr, variables)
if err != nil {
return fp, err
}
fp.spanPred, fp.spanArgs, fp.hasSpanFilter = pred, args, true
fp.warnings, fp.warningsURL = warnings, url
}
}
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
if fp.havingExpr != "" {
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
} else {
fp.havingExpr = query.Having.Expression
}
}
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
return fp, err
}
return fp, nil
}
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean SQL
// predicate (escaped) + args via the field mapper.
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, start, end uint64, expr string, variables map[string]qbtypes.VariableItem) (string, []any, []string, string, error) {
selectors := querybuilder.QueryStringToKeysSelectors(expr)
for i := range selectors {
selectors[i].Signal = telemetrytypes.SignalTraces
}
keys, _, err := b.metadataStore.GetKeysMulti(ctx, selectors)
if err != nil {
return "", nil, nil, "", err
}
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
Variables: variables,
StartNs: start,
EndNs: end,
})
if err != nil {
return "", nil, nil, "", err
}
if prepared.IsEmpty() {
return "", nil, nil, "", nil
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("1")
sb.AddWhereClause(prepared.WhereClause)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
pred := sql[strings.Index(sql, "WHERE ")+len("WHERE "):]
return sqlbuilder.Escape(pred), args, prepared.Warnings, prepared.WarningsDocURL, nil
}
// ---------------------------------------------------------------------------
// BUILD — compose the CTE pipeline (matched → ranked → buckets → enrichment)
// ---------------------------------------------------------------------------
// buildMatchedCTE builds `matched`: a single windowed GROUP BY trace_id pass that
// picks the top-N traces. The WHERE prunes to gen_ai spans (widened with the span-level
// predicate when present); HAVING enforces the gate (countIf(mask) > 0), the span-level
// filter as an existence check, and the trace-level aggregate filter; ORDER BY + LIMIT
// select the winners. Only gen_ai-scoped aggregates are computable here, and only those
// actually referenced by ORDER BY / the aggregate filter are selected (the rest are
// computed once, later, in the enrichment scan) — this keeps the hot ranking pass lean.
func (b *scopedTraceStatementBuilder) buildMatchedCTE(start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, maskArgs []any, fp filterParts, limit, offset int) (string, []any, error) {
sb := sqlbuilder.NewSelectBuilder()
// SELECT trace_id + only the aggregates ORDER BY / HAVING reference (as aliases).
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
selects := []string{"trace_id"}
for _, rc := range resolved {
if _, ok := needed[rc.alias]; !ok {
continue
}
selects = append(selects, embedExpr(sb, rc.expr, rc.args)+" AS "+quoteAlias(rc.alias))
}
sb.Select(selects...)
sb.From(spanTable())
// WHERE: window + coarse prune to gen_ai spans (widened so span-filter spans are
// visible for the countIf existence check below).
win := windowWhere(sb, start, end, startBucket, endBucket)
prune := "(" + embedExpr(sb, maskExpr, maskArgs)
if fp.hasSpanFilter {
prune += " OR " + embedExpr(sb, fp.spanPred, fp.spanArgs)
}
prune += ")"
sb.Where(append(win, prune)...)
sb.GroupBy("trace_id")
// HAVING: the gate + span-existence checks are only needed once the WHERE has been
// widened by a span filter; otherwise WHERE = mask already enforces the gate.
var having []string
if fp.hasSpanFilter {
having = append(having, "countIf("+embedExpr(sb, maskExpr, maskArgs)+") > 0")
having = append(having, "countIf("+embedExpr(sb, fp.spanPred, fp.spanArgs)+") > 0")
}
if strings.TrimSpace(fp.havingExpr) != "" {
hv, err := b.buildHaving(fp.havingExpr, orderableSet)
if err != nil {
return "", nil, err
}
if hv != "" {
having = append(having, hv)
}
}
if len(having) > 0 {
sb.Having(strings.Join(having, " AND "))
}
sb.OrderBy(orderClause(orders)...)
sb.Limit(limit)
if offset > 0 {
sb.Offset(offset)
}
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return fmt.Sprintf("matched AS (%s)", sql), args, nil
}
// buildRankedCTE builds `ranked`: per-trace [start,end] bounds for the matched traces,
// read from the small trace-summary table (used to derive the bucket prune).
func (b *scopedTraceStatementBuilder) buildRankedCTE(start, end uint64) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("trace_id", "min(start) AS t_start", "max(end) AS t_end")
sb.From(summaryTable())
sb.Where(
"trace_id GLOBAL IN (SELECT trace_id FROM matched)",
"end >= fromUnixTimestamp64Nano("+sb.Var(start)+")",
"start < fromUnixTimestamp64Nano("+sb.Var(end)+")",
)
sb.GroupBy("trace_id")
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return fmt.Sprintf("ranked AS (%s)", sql), args
}
// buildBucketsCTE builds `buckets`: the exact ts_bucket_start values the matched traces
// span, so the enrichment scan is pruned to those primary-key buckets. No args.
func buildBucketsCTE() string {
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
return fmt.Sprintf("buckets AS (SELECT DISTINCT b AS ts_bucket FROM ranked "+
"ARRAY JOIN range("+
"toUInt64(intDiv(toUnixTimestamp(t_start), %d) * %d - %d), "+
"toUInt64(intDiv(toUnixTimestamp(t_end), %d) * %d + %d), "+
"%d) AS b)", adj, adj, adj, adj, adj, adj, adj)
}
// buildEnrichmentSelect builds the final SELECT: all per-trace columns for the matched
// traces, scanning only their buckets (full trace, not window-clipped). SELECT-expr
// args lead; the WHERE / ORDER BY carry none.
func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(resolved []resolvedColumn, orders []listOrder) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
selects, selectArgs := selectAllColumns(resolved)
sb.Select(selects...)
sb.From(spanTable())
sb.Where(
"ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)",
"trace_id GLOBAL IN (SELECT trace_id FROM ranked)",
)
sb.GroupBy("trace_id")
sb.OrderBy(orderClause(orders)...)
sql, builtArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return sql, append(append([]any{}, selectArgs...), builtArgs...)
}
// buildHaving rewrites a trace-level HAVING expression against the aggregate column
// aliases computable in the matched pass; bare, trace., and tracefield. forms all map
// to the selected alias.
func (b *scopedTraceStatementBuilder) buildHaving(havingExpr string, orderableSet map[string]struct{}) (string, error) {
columnMap := make(map[string]string, len(orderableSet)*3)
for a := range orderableSet {
columnMap[a] = quoteAlias(a)
columnMap["trace."+a] = quoteAlias(a)
columnMap["tracefield."+a] = quoteAlias(a)
}
return querybuilder.NewHavingExpressionRewriter().Rewrite(havingExpr, columnMap)
}
// ---------------------------------------------------------------------------
// Small shared SQL-builder utilities
// ---------------------------------------------------------------------------
// spanTable is the fully-qualified span index table.
func spanTable() string {
return fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.SpanIndexV3TableName)
}
// summaryTable is the fully-qualified trace-summary table.
func summaryTable() string {
return fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.TraceSummaryTableName)
}
// aggregateAliasSet is the set of all trace-level (computed) column aliases, used to
// classify which filter keys are trace-level vs span-level.
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
set := make(map[string]struct{}, len(b.projection.AggregateAliases()))
for _, a := range b.projection.AggregateAliases() {
set[a] = struct{}{}
}
return set
}
// orderableAliasSet is the subset of aggregate aliases computable in the matched pass
// (gen_ai-scoped): the only ones usable for ORDER BY and the aggregate filter.
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
set := make(map[string]struct{})
for _, rc := range resolved {
if rc.orderable {
set[rc.alias] = struct{}{}
}
}
return set
}
// neededMatchedAliases is the minimal set of aggregate aliases the matched pass must
// select: those referenced by ORDER BY plus those referenced in the aggregate HAVING
// (bare / trace. / tracefield. forms). Anything else is left to the enrichment scan.
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
needed := make(map[string]struct{})
for _, o := range orders {
needed[o.alias] = struct{}{}
}
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
name := strings.TrimPrefix(strings.TrimPrefix(sel.Name, "trace."), "tracefield.")
if _, ok := orderableSet[name]; ok {
needed[name] = struct{}{}
}
}
return needed
}
// validateAggregateFilter rejects a trace-level filter that references an aggregate not
// computable in the matched pass (e.g. span_count, duration_nano), with a clear message.
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
if strings.TrimSpace(havingExpr) == "" {
return nil
}
allowed := make([]string, 0, len(orderableSet))
for a := range orderableSet {
allowed = append(allowed, a)
}
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
name := strings.TrimPrefix(strings.TrimPrefix(sel.Name, "trace."), "tracefield.")
if _, ok := orderableSet[name]; !ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"aggregate %q cannot be used in an AI trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
}
}
return nil
}
// embedExpr inlines a resolved (escaped) expr carrying `?` placeholders into sb by
// replacing each `?` with a builder Var, so go-sqlbuilder tracks the args in appearance
// order and un-escapes the expr at Build time.
func embedExpr(sb *sqlbuilder.SelectBuilder, expr string, args []any) string {
var out strings.Builder
ai := 0
for i := 0; i < len(expr); i++ {
if expr[i] == '?' && ai < len(args) {
out.WriteString(sb.Var(args[ai]))
ai++
continue
}
out.WriteByte(expr[i])
}
return out.String()
}
// windowWhere binds the shared time-window predicates to sb and returns them, so a
// caller can add its own predicate in the same Where call.
func windowWhere(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64) []string {
return []string{
sb.GE("timestamp", fmt.Sprintf("%d", start)),
sb.L("timestamp", fmt.Sprintf("%d", end)),
sb.GE("ts_bucket_start", startBucket),
sb.LE("ts_bucket_start", endBucket),
}
}
// orderClause renders the ORDER BY terms (by column alias) + the trace_id tiebreak.
func orderClause(orders []listOrder) []string {
out := make([]string, 0, len(orders)+1)
for _, o := range orders {
out = append(out, fmt.Sprintf("%s %s", quoteAlias(o.alias), o.direction))
}
return append(out, "trace_id DESC")
}
// selectAllColumns renders `expr AS alias` for every resolved column and returns their
// field-mapper args in select order.
func selectAllColumns(resolved []resolvedColumn) ([]string, []any) {
selects := []string{"trace_id"}
var args []any
for _, rc := range resolved {
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
args = append(args, rc.args...)
}
return selects, args
}
// quoteAlias backticks an alias that carries characters special to the SQL builder.
func quoteAlias(alias string) string {
if strings.ContainsAny(alias, ".$`") {
return "`" + alias + "`"
}
return alias
}

Some files were not shown because too many files have changed in this diff Show More