mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-03 13:20:34 +01:00
Compare commits
13 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed0c5b7f17 | ||
|
|
c7c9dc9d32 | ||
|
|
35c9300aea | ||
|
|
128f6118a0 | ||
|
|
a3ceb4c4fb | ||
|
|
58c196f9bc | ||
|
|
74f61c746c | ||
|
|
05a33ea912 | ||
|
|
cda75cc37d | ||
|
|
c494afdc1c | ||
|
|
86671d43dd | ||
|
|
ec3ada3a70 | ||
|
|
dc6ce4051b |
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
@@ -27,7 +28,7 @@ interface PieArcProps {
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ export default function PieArc({
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
onClick={(event): void => onClick?.(slice, event)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('PieArc', () => {
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
// onClick now also receives the DOM event (for drill-down popover positioning).
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
@@ -79,6 +80,10 @@ export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
|
||||
queryName?: string;
|
||||
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +104,7 @@ export interface PieChartProps {
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
|
||||
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
selectIsDashboardLocked,
|
||||
useDashboardStore,
|
||||
} from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { getChartManagerColumns } from './getChartMangerColumns';
|
||||
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
|
||||
@@ -44,7 +44,6 @@ export default function ChartManager({
|
||||
decimalPrecision = PrecisionOptionsEnum.TWO,
|
||||
onCancel,
|
||||
}: ChartManagerProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const { legendItemsMap } = useLegendsSync({
|
||||
config,
|
||||
subscribeToFocusChange: false,
|
||||
@@ -136,11 +135,9 @@ export default function ChartManager({
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
notifications.success({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
toast.success('The updated graphs & legends are saved');
|
||||
onCancel?.();
|
||||
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
|
||||
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
|
||||
|
||||
return (
|
||||
<div className="chart-manager-container">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
|
||||
import ChartManager from '../ChartManager';
|
||||
|
||||
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
const mockNotificationsSuccess = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
|
||||
usePlotContext: (): {
|
||||
@@ -46,12 +46,11 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}): boolean => s.dashboardData?.locked ?? false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: { success: jest.Mock } } => ({
|
||||
notifications: {
|
||||
success: mockNotificationsSuccess,
|
||||
},
|
||||
}),
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('components/ResizeTable', () => {
|
||||
@@ -160,7 +159,7 @@ describe('ChartManager', () => {
|
||||
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
|
||||
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
|
||||
render(
|
||||
<ChartManager
|
||||
config={createMockConfig() as UPlotConfigBuilder}
|
||||
@@ -172,9 +171,9 @@ describe('ChartManager', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'The updated graphs & legends are saved',
|
||||
);
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
// in the same container; size the chart region to its content so they aren't
|
||||
// pushed out. Only this case opts out of filling the height — the dashboard grid,
|
||||
// alert preview, and other charts keep 100% so they fill their container.
|
||||
&--with-layout-children {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function ChartLayout({
|
||||
className={cx('chart-layout', {
|
||||
'chart-layout--legend-right':
|
||||
legendConfig.position === LegendPosition.RIGHT,
|
||||
'chart-layout--with-layout-children': !!layoutChildren,
|
||||
})}
|
||||
>
|
||||
<div className="chart-layout__content">
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
@@ -31,22 +30,7 @@ function PanelTypeSwitcher({
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
});
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface UsePanelTypeSelectItemsArgs {
|
||||
/** Active query type — a kind that can't be authored in it is disabled (defaults to Query Builder). */
|
||||
queryType?: EQueryType;
|
||||
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
signal,
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
}),
|
||||
[queryType, signal],
|
||||
);
|
||||
}
|
||||
@@ -164,7 +164,7 @@ function PanelEditorQueryBuilder({
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
<RunQueryBtn
|
||||
className="run-query-dashboard-btn"
|
||||
label="Stage & Run Query"
|
||||
label="Run Query"
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={onCancelQuery}
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
// Standalone View stacks the graph-manager below the chart inside the surface (it
|
||||
// must stay within the chart's PlotContext). Let it flow out of the surface so the
|
||||
// modal body scrolls as a whole, instead of clipping it or scrolling the panel.
|
||||
.surfaceStacked {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
PanelPagination,
|
||||
@@ -30,6 +32,14 @@ interface PreviewPaneProps {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
|
||||
hideHeader?: boolean;
|
||||
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +57,10 @@ function PreviewPane({
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
panelMode = PanelMode.DASHBOARD_EDIT,
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
@@ -58,18 +72,24 @@ function PreviewPane({
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
{!hideHeader && (
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
<div
|
||||
className={cx(styles.surface, {
|
||||
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
|
||||
})}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
@@ -90,9 +110,11 @@ function PreviewPane({
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
|
||||
/**
|
||||
* Characterization test for the editor's composition: which derived values and
|
||||
* options it forwards to the draft/query/query-sync/type-switch hooks and to its
|
||||
* children. The leaf hooks are mocked as arg-capturing spies so this pins the
|
||||
* wiring; it stays valid (and guards behavior) after that wiring is pulled into a
|
||||
* shared edit-session hook, since the mocks intercept the leaf hooks either way.
|
||||
*/
|
||||
|
||||
const mockSetSpec = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
const mockCancelQuery = jest.fn();
|
||||
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
|
||||
const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
|
||||
}));
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('../../hooks/usePanelQuery', () => ({
|
||||
usePanelQuery: (args: unknown): unknown => mockUseQuery(args),
|
||||
}));
|
||||
|
||||
const mockUseQuerySync = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorQuerySync', () => ({
|
||||
usePanelEditorQuerySync: (args: unknown): unknown => mockUseQuerySync(args),
|
||||
}));
|
||||
|
||||
const mockUseTypeSwitch = jest.fn();
|
||||
jest.mock('../hooks/usePanelTypeSwitch', () => ({
|
||||
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
usePanelEditorSave: (): unknown => ({ save: mockSave, isSaving: false }),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
|
||||
useSwitchColumnsOnSignalChange: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useSeedNewListColumns', () => ({
|
||||
useSeedNewListColumns: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useLegendSeries', () => ({
|
||||
useLegendSeries: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
|
||||
}));
|
||||
jest.mock(
|
||||
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',
|
||||
() => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: {},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@signozhq/ui/resizable', () => ({
|
||||
__esModule: true,
|
||||
ResizablePanelGroup: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <div>{children}</div>,
|
||||
ResizablePanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ResizableHandle: (): null => null,
|
||||
useDefaultLayout: (): unknown => ({
|
||||
defaultLayout: undefined,
|
||||
onLayoutChanged: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// Children mocked to capture props (and expose a Save trigger / footer slot).
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
const mockPreviewProps = jest.fn();
|
||||
jest.mock('../PreviewPane/PreviewPane', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockPreviewProps(props);
|
||||
return <div data-testid="preview" />;
|
||||
},
|
||||
}));
|
||||
const mockQbProps = jest.fn();
|
||||
jest.mock('../PanelEditorQueryBuilder/PanelEditorQueryBuilder', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { footer?: React.ReactNode }): JSX.Element => {
|
||||
mockQbProps(props);
|
||||
return <div data-testid="qb">{props.footer}</div>;
|
||||
},
|
||||
}));
|
||||
const mockConfigProps = jest.fn();
|
||||
jest.mock('../ConfigPane/ConfigPane', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockConfigProps(props);
|
||||
return <div data-testid="config" />;
|
||||
},
|
||||
}));
|
||||
jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="list-columns" />,
|
||||
}));
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
|
||||
function setup(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
|
||||
): void {
|
||||
mockUseDraft.mockReturnValue({
|
||||
draft: panel,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
isSpecDirty: false,
|
||||
});
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { response: undefined },
|
||||
isFetching: false,
|
||||
error: null,
|
||||
cancelQuery: mockCancelQuery,
|
||||
refetch: mockRefetch,
|
||||
pagination: undefined,
|
||||
});
|
||||
mockUseQuerySync.mockReturnValue({
|
||||
runQuery: jest.fn(),
|
||||
isQueryDirty: false,
|
||||
buildSaveSpec: mockBuildSaveSpec,
|
||||
});
|
||||
mockUseTypeSwitch.mockReturnValue({
|
||||
onChangePanelKind: mockOnChangePanelKind,
|
||||
});
|
||||
render(<PanelEditorContainer {...baseProps} panel={panel} {...overrides} />);
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('qb')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('config')).toBeInTheDocument();
|
||||
|
||||
expect(mockPreviewProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panel,
|
||||
panelDefinition: getPanelDefinition('signoz/TimeSeriesPanel'),
|
||||
}),
|
||||
);
|
||||
expect(mockQbProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
|
||||
);
|
||||
expect(mockConfigProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panel,
|
||||
spec: panel.spec,
|
||||
onChangePanelKind: mockOnChangePanelKind,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the derived panel type + query-sync options to the leaf hooks', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel);
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panel, panelId: 'panel-1', enabled: true }),
|
||||
);
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec: mockSetSpec,
|
||||
refetch: mockRefetch,
|
||||
alwaysSerializeQuery: false,
|
||||
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
|
||||
}),
|
||||
);
|
||||
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel dirty and always serializes its query', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alwaysSerializeQuery: true }),
|
||||
);
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('bakes the live query into the spec on save, then notifies', async () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel, { onSaved: baseProps.onSaved });
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
|
||||
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('renders the list-columns editor only for list panels', () => {
|
||||
setup(makePanel('signoz/ListPanel'));
|
||||
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the list-columns editor for non-list panels', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
expect(screen.queryByTestId('list-columns')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
usePanelQuery,
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import { usePanelEditorDraft } from './usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
|
||||
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
|
||||
|
||||
interface UsePanelEditSessionArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
|
||||
time?: PanelQueryTimeOverride;
|
||||
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
|
||||
seedQuerySignal?: boolean;
|
||||
}
|
||||
|
||||
export interface UsePanelEditSessionApi {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
isSpecDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
/** Draft kind → V1 panel type (drives the query builder + preview). */
|
||||
panelType: PANEL_TYPES;
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The kind's first supported signal — seeds new queries/columns. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Shared query result for the draft over the resolved time window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft. */
|
||||
runQuery: () => void;
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live (possibly un-run) query into a spec — for save / editor handoff. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
/** Switch the draft's visualization kind in place (reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel-editing pipeline shared by the full-page editor and the View modal's
|
||||
* drilldown editor: a local draft, its query result over the resolved time window,
|
||||
* the staged-query sync, and the visualization-kind switch. Each consumer layers its
|
||||
* own concerns on top (the editor adds save + list seeding; the modal adds per-view
|
||||
* time isolation + reset). Keeping the wiring here stops the two from drifting.
|
||||
*/
|
||||
export function usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionApi {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } =
|
||||
usePanelEditorDraft(panel);
|
||||
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[fullKind];
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
time,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch: query.refetch,
|
||||
alwaysSerializeQuery,
|
||||
signal: seedQuerySignal ? defaultSignal : undefined,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draft.spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
});
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
reset,
|
||||
panelType,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
};
|
||||
}
|
||||
@@ -12,13 +12,7 @@ import {
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
@@ -29,12 +23,9 @@ import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
@@ -70,7 +61,28 @@ function PanelEditorContainer({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
|
||||
// panel always serializes its seed query and seeds the builder's default signal.
|
||||
const {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
});
|
||||
const { data, isFetching, error, cancelQuery, refetch, pagination } = query;
|
||||
|
||||
// Live query type (the selected tab) — the type switcher disables kinds that can't be
|
||||
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
|
||||
// query until staged, so the spec would lag the tab.
|
||||
@@ -94,37 +106,7 @@ function PanelEditorContainer({
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
// Panel kind → V1 panel type, which drives the query builder and preview.
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const { data, isFetching, error, cancelQuery, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
// New panel's seed query is the builder default, not a real saved query —
|
||||
// always serialize it on save.
|
||||
alwaysSerializeQuery: isNew,
|
||||
signal: defaultSignal,
|
||||
});
|
||||
|
||||
// Switch the panel's visualization kind in place (reversible per session).
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
|
||||
|
||||
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
|
||||
const formattingUnit = (
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Router location state for opening the panel editor pre-loaded with edits instead of
|
||||
* the saved panel. The View modal sets this so "Switch to Edit Mode" carries its
|
||||
* drilldown-edited spec (queries/plugin) into the editor.
|
||||
*/
|
||||
export interface PanelEditorHandoffState {
|
||||
editSpec?: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
@@ -12,8 +14,6 @@ import {
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
|
||||
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import { buildBarChartConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -37,6 +40,8 @@ function BarPanelRenderer({
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -53,12 +58,10 @@ function BarPanelRenderer({
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data. The generated
|
||||
// request DTO is structurally the V5 request; the cast is the boundary.
|
||||
// X-scale clamps come from the request that produced the data, so each panel
|
||||
// pins to the window it fetched.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
@@ -114,6 +117,32 @@ function BarPanelRenderer({
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -126,10 +155,27 @@ function BarPanelRenderer({
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
(args: ChartClickData): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichChartClick({
|
||||
clickData: args,
|
||||
series: flatSeries,
|
||||
builderQueries,
|
||||
});
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const timeRange = stepClickTimeRange({
|
||||
clickedDataTimestamp: args.clickedDataTimestamp,
|
||||
queryName: payload.context.queryName,
|
||||
builderQueries,
|
||||
stepIntervals: getExecStats(data.response)?.stepIntervals,
|
||||
});
|
||||
onClick({ ...payload, context: { ...payload.context, timeRange } });
|
||||
},
|
||||
[onClick],
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -147,6 +193,7 @@ function BarPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
@@ -158,7 +205,7 @@ function BarPanelRenderer({
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildHistogramConfig } from './utils/buildConfig';
|
||||
import { prepareHistogramData } from './prepareData';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function HistogramPanelRenderer({
|
||||
panelId,
|
||||
@@ -28,7 +27,6 @@ function HistogramPanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -100,13 +98,6 @@ function HistogramPanelRenderer({
|
||||
|
||||
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
@@ -127,7 +118,6 @@ function HistogramPanelRenderer({
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,5 +37,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
@@ -8,6 +13,9 @@ import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { formatPanelValue } from '../../utils/formatPanelValue';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { enrichNumberClick } from '../../utils/drilldown/enrichNumberClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import { prepareNumberData } from './prepareData';
|
||||
import { mapNumberThresholds } from './utils';
|
||||
@@ -17,24 +25,31 @@ function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const tables = useMemo(
|
||||
() =>
|
||||
prepareNumberData(
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
),
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
[data.response, data.legendMap, data.requestPayload],
|
||||
);
|
||||
|
||||
const value = useMemo(() => prepareNumberData(tables), [tables]);
|
||||
|
||||
const thresholds = useMemo(
|
||||
() => mapNumberThresholds(spec.thresholds),
|
||||
[spec.thresholds],
|
||||
@@ -54,10 +69,60 @@ function NumberPanelRenderer({
|
||||
[value, unit, decimalPrecision],
|
||||
);
|
||||
|
||||
const openDrilldown = useCallback(
|
||||
(coordinates: { x: number; y: number }): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichNumberClick({
|
||||
tables,
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
onClick(payload);
|
||||
}
|
||||
},
|
||||
[onClick, tables, data.requestPayload, builderQueries],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>): void =>
|
||||
openDrilldown({ x: event.clientX, y: event.clientY }),
|
||||
[openDrilldown],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
openDrilldown({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
});
|
||||
}
|
||||
},
|
||||
[openDrilldown],
|
||||
);
|
||||
|
||||
// The whole panel is the value, so the container itself is the drill-down target.
|
||||
const isClickable = enableDrillDown && !!onClick && value !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="number-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
{...(isClickable
|
||||
? {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onClick: handleClick,
|
||||
onKeyDown: handleKeyDown,
|
||||
style: { cursor: 'pointer' },
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
@@ -13,6 +17,9 @@ import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { enrichPieClick } from '../../utils/drilldown/enrichPieClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import { preparePieData } from './prepareData';
|
||||
|
||||
@@ -22,6 +29,7 @@ function PiePanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -30,6 +38,11 @@ function PiePanelRenderer({
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const slices = useMemo(
|
||||
() =>
|
||||
preparePieData({
|
||||
@@ -61,10 +74,21 @@ function PiePanelRenderer({
|
||||
);
|
||||
|
||||
const handleSliceClick = useCallback(
|
||||
(slice: PieSlice) => {
|
||||
onClick?.({ label: slice.label, value: slice.value });
|
||||
(slice: PieSlice, event: ReactMouseEvent): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichPieClick({
|
||||
slice,
|
||||
builderQueries,
|
||||
coordinates: { x: event.clientX, y: event.clientY },
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
onClick(payload);
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
[onClick, builderQueries, data.requestPayload],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -79,7 +103,7 @@ function PiePanelRenderer({
|
||||
isDarkMode={isDarkMode}
|
||||
position={legendPosition}
|
||||
id={panelId}
|
||||
onSliceClick={handleSliceClick}
|
||||
onSliceClick={enableDrillDown ? handleSliceClick : undefined}
|
||||
data-testid="pie-chart"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,5 +23,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -8,6 +15,9 @@ import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/query
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { enrichTableClick } from '../../utils/drilldown/enrichTableClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
import { useResizableColumns } from '../../hooks/useResizableColumns';
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
|
||||
@@ -27,6 +37,8 @@ function TablePanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,6 +54,11 @@ function TablePanelRenderer({
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// V5 joins every query into a single scalar result, so the first non-empty
|
||||
// table is the whole panel.
|
||||
const table = useMemo(
|
||||
@@ -64,6 +81,34 @@ function TablePanelRenderer({
|
||||
[spec.thresholds],
|
||||
);
|
||||
|
||||
const handleCellClick = useCallback(
|
||||
({
|
||||
columnId,
|
||||
record,
|
||||
event,
|
||||
}: {
|
||||
columnId: string;
|
||||
record: TableRowData;
|
||||
event: ReactMouseEvent<HTMLElement>;
|
||||
}): void => {
|
||||
if (!onClick || !table) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichTableClick({
|
||||
record,
|
||||
columnId,
|
||||
table,
|
||||
builderQueries,
|
||||
coordinates: { x: event.clientX, y: event.clientY },
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
onClick(payload);
|
||||
}
|
||||
},
|
||||
[onClick, table, builderQueries, data.requestPayload],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
table
|
||||
@@ -72,9 +117,17 @@ function TablePanelRenderer({
|
||||
columnUnits: spec.formatting?.columnUnits ?? {},
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
onCellClick: enableDrillDown ? handleCellClick : undefined,
|
||||
})
|
||||
: [],
|
||||
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
|
||||
[
|
||||
table,
|
||||
spec.formatting?.columnUnits,
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
enableDrillDown,
|
||||
handleCellClick,
|
||||
],
|
||||
);
|
||||
|
||||
// User-resizable columns, persisted per panel to localStorage.
|
||||
|
||||
@@ -25,5 +25,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
createAlert: false,
|
||||
// V1 parity: only tables (and lists) expose the header search box.
|
||||
search: true,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface BuildTableColumnsArgs {
|
||||
decimalPrecision?: PrecisionOption;
|
||||
/** Thresholds grouped by column name (see `mapTableThresholds`). */
|
||||
thresholdsByColumn: Record<string, PanelThreshold[]>;
|
||||
/** When set, every body cell becomes a drill-down target (keyed by its column id). */
|
||||
onCellClick?: (args: {
|
||||
columnId: string;
|
||||
record: TableRowData;
|
||||
event: React.MouseEvent<HTMLElement>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +71,7 @@ export function buildTableColumns({
|
||||
columnUnits,
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
onCellClick,
|
||||
}: BuildTableColumnsArgs): TableProps<TableRowData>['columns'] {
|
||||
return table.columns.map((col) => {
|
||||
// Column key = query identifier for value columns, group name otherwise. Units
|
||||
@@ -97,19 +104,26 @@ export function buildTableColumns({
|
||||
}
|
||||
return text;
|
||||
},
|
||||
onCell: (record: TableRowData): { style?: React.CSSProperties } => {
|
||||
if (!col.isValueColumn || colThresholds.length === 0) {
|
||||
return {};
|
||||
onCell: (record: TableRowData): React.HTMLAttributes<HTMLElement> => {
|
||||
const cellProps: React.HTMLAttributes<HTMLElement> = {};
|
||||
|
||||
if (col.isValueColumn && colThresholds.length > 0) {
|
||||
const num = Number(record[key]);
|
||||
if (Number.isFinite(num)) {
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
if (threshold?.format === 'background') {
|
||||
cellProps.style = { backgroundColor: threshold.color };
|
||||
}
|
||||
}
|
||||
}
|
||||
const num = Number(record[key]);
|
||||
if (!Number.isFinite(num)) {
|
||||
return {};
|
||||
|
||||
if (onCellClick) {
|
||||
cellProps.onClick = (event): void =>
|
||||
onCellClick({ columnId: key, record, event });
|
||||
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
if (threshold?.format === 'background') {
|
||||
return { style: { backgroundColor: threshold.color } };
|
||||
}
|
||||
return {};
|
||||
|
||||
return cellProps;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
@@ -12,8 +14,6 @@ import {
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
|
||||
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import { buildTimeSeriesConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -37,6 +40,8 @@ function TimeSeriesPanelRenderer({
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -55,11 +60,9 @@ function TimeSeriesPanelRenderer({
|
||||
|
||||
// X-scale clamps come from the request that produced the data, so each panel
|
||||
// pins to the window it fetched — matters during drag-zoom transitions before
|
||||
// new data arrives. The generated request DTO is structurally the V5 request.
|
||||
// new data arrives.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
@@ -115,6 +118,32 @@ function TimeSeriesPanelRenderer({
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -127,10 +156,27 @@ function TimeSeriesPanelRenderer({
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
(args: ChartClickData): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichChartClick({
|
||||
clickData: args,
|
||||
series: flatSeries,
|
||||
builderQueries,
|
||||
});
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const timeRange = stepClickTimeRange({
|
||||
clickedDataTimestamp: args.clickedDataTimestamp,
|
||||
queryName: payload.context.queryName,
|
||||
builderQueries,
|
||||
stepIntervals: getExecStats(data.response)?.stepIntervals,
|
||||
});
|
||||
onClick({ ...payload, context: { ...payload.context, timeRange } });
|
||||
},
|
||||
[onClick],
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -148,6 +194,7 @@ function TimeSeriesPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
@@ -158,7 +205,7 @@ function TimeSeriesPanelRenderer({
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chartManagerContainer {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
|
||||
// Drilldown is the click-to-context-menu feature ported from V1. Every renderer turns its native
|
||||
// click into one `DrilldownClickPayload`; the kind-agnostic orchestration layer consumes only that.
|
||||
// `FilterData` is imported read-only from the V1 util so the payload feeds `buildDrilldownUrl`
|
||||
// directly, with no intermediate translation.
|
||||
|
||||
/** The clicked point's drilldown context, derived from the flattened series/columns the renderer holds. */
|
||||
export interface DrilldownContext {
|
||||
/** The clicked series'/column's query. Drives query selection in `getViewQuery`. */
|
||||
queryName: string;
|
||||
/** Telemetry signal of the clicked query — picks the explorer the drilldown navigates to. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Key/value/op filters from the clicked point's group-by labels (empty when ungrouped). */
|
||||
filters: FilterData[];
|
||||
/** Explorer time window. Charts use the clicked bucket ±step; scalar panels use the fetched window. */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
/** Series/slice display name, shown as the menu header's second line. */
|
||||
label?: string;
|
||||
/** Series/slice colour; tints the menu header label and item icons (charts/pie only). */
|
||||
seriesColor?: string;
|
||||
/** Tables only: a value column opens the aggregate menu; a group column opens filter-by-value. */
|
||||
columnKind?: 'aggregate' | 'group';
|
||||
/** Group-column click only: the clicked column's key, for the filter-by-value menu. */
|
||||
clickedKey?: string;
|
||||
/** Group-column click only: the clicked cell's value, for the filter-by-value menu. */
|
||||
clickedValue?: string | number;
|
||||
}
|
||||
|
||||
/** What each renderer's `onClick` emits: where to anchor the popover plus the drilldown context. */
|
||||
export interface DrilldownClickPayload {
|
||||
/** Absolute viewport coordinates for the popover anchor. */
|
||||
coordinates: { x: number; y: number };
|
||||
context: DrilldownContext;
|
||||
}
|
||||
@@ -1,46 +1,36 @@
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/** Source-tagged click events; each non-chart kind carries its own drill-down context. */
|
||||
export type ChartClickEvent = ChartClickData;
|
||||
export type TableClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
columnId?: string;
|
||||
};
|
||||
export type ListClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
};
|
||||
export type PieClickEvent = { label: string; value: number };
|
||||
|
||||
/** Union of every panel click event — switched on by `source` at the boundary. */
|
||||
export type PanelClickEvent =
|
||||
| ChartClickEvent
|
||||
| TableClickEvent
|
||||
| ListClickEvent
|
||||
| PieClickEvent;
|
||||
import type { DrilldownClickPayload } from './drilldown';
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
/** Close the standalone View modal — fired by the chart's graph-manager Save/Cancel. */
|
||||
type CloseStandaloneView = () => void;
|
||||
|
||||
/**
|
||||
* Per-kind interaction props — each kind exposes only the gestures it supports.
|
||||
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
|
||||
* is a compile error there.
|
||||
*
|
||||
* Every interactive kind's `onClick` receives the unified `DrilldownClickPayload`
|
||||
* its renderer enriches from the native click. Number/Value drills down on its
|
||||
* single value. Histogram and List are omitted (V1 has no drill-down for either):
|
||||
* they inherit the empty `object` base, so their renderers get only base props
|
||||
* with no click gesture.
|
||||
*/
|
||||
export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
|
||||
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
|
||||
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
|
||||
'signoz/NumberPanel': Record<string, never>;
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -48,6 +38,7 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
* registry render boundary). The supertype the per-kind shapes are cast to once.
|
||||
*/
|
||||
export interface AnyPanelInteractionProps {
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface PanelActionCapabilities {
|
||||
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
|
||||
*/
|
||||
search: boolean;
|
||||
/**
|
||||
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
|
||||
* Pie/Value/Table; Histogram/List opt out. AND-ed with "has a builder query" in `useDrilldown`.
|
||||
*/
|
||||
drilldown: boolean;
|
||||
}
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
|
||||
@@ -17,3 +17,15 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
|
||||
'signoz/ListPanel': PANEL_TYPES.LIST,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverse of {@link PANEL_KIND_TO_PANEL_TYPE} — the mapping is a bijection, so every
|
||||
* panel kind round-trips. Partial because `PANEL_TYPES` also has types with no V2 kind
|
||||
* (e.g. trace/empty); a lookup on those returns `undefined`.
|
||||
*/
|
||||
export const PANEL_TYPE_TO_PANEL_KIND: Partial<Record<PANEL_TYPES, PanelKind>> =
|
||||
Object.fromEntries(
|
||||
(Object.entries(PANEL_KIND_TO_PANEL_TYPE) as [PanelKind, PANEL_TYPES][]).map(
|
||||
([kind, type]) => [type, kind],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelTimeRange } from '../getPanelTimeRange';
|
||||
|
||||
// Fallback path reads the redux global-time selection; stub both so the no-payload branch
|
||||
// is deterministic.
|
||||
jest.mock('store', () => ({
|
||||
__esModule: true,
|
||||
default: { getState: (): unknown => ({ globalTime: { selectedTime: '5m' } }) },
|
||||
}));
|
||||
jest.mock('lib/getStartEndRangeTime', () => ({
|
||||
__esModule: true,
|
||||
default: (): { start: string; end: string } => ({
|
||||
start: '1700',
|
||||
end: '1800',
|
||||
}),
|
||||
}));
|
||||
|
||||
const request = (
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Querybuildertypesv5QueryRangeRequestDTO =>
|
||||
({ start, end }) as Querybuildertypesv5QueryRangeRequestDTO;
|
||||
|
||||
describe('getPanelTimeRange', () => {
|
||||
it('converts the request start/end from ms to seconds', () => {
|
||||
expect(getPanelTimeRange(request(5_000, 9_000))).toStrictEqual({
|
||||
startTime: 5,
|
||||
endTime: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the global-time window when there is no request', () => {
|
||||
expect(getPanelTimeRange(undefined)).toStrictEqual({
|
||||
startTime: 1700,
|
||||
endTime: 1800,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the request is missing an endpoint', () => {
|
||||
expect(getPanelTimeRange(request(5_000, undefined))).toStrictEqual({
|
||||
startTime: 1700,
|
||||
endTime: 1800,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getSwitchedPluginSpec } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec';
|
||||
import { PANEL_TYPE_TO_PANEL_KIND } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { buildViewPanelSpec } from '../buildViewPanelSpec';
|
||||
|
||||
// The query conversion + kind-switch spec builder are tested in their own suites; here we
|
||||
// isolate buildViewPanelSpec's branching (same kind vs. kind switch).
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
|
||||
() => ({ toPerses: jest.fn(() => [{ kind: 'mock-query' }]) }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec',
|
||||
() => ({ getSwitchedPluginSpec: jest.fn(() => ({ switched: true })) }),
|
||||
);
|
||||
|
||||
const query = {} as Query;
|
||||
|
||||
function specOfKind(kind: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: { kind, spec: { formatting: {} } },
|
||||
queries: [],
|
||||
display: { name: 'panel' },
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('PANEL_TYPE_TO_PANEL_KIND', () => {
|
||||
it('is the inverse of the kind→type map', () => {
|
||||
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.VALUE]).toBe(
|
||||
'signoz/NumberPanel',
|
||||
);
|
||||
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.TABLE]).toBe('signoz/TablePanel');
|
||||
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.TIME_SERIES]).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildViewPanelSpec', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('keeps the kind and only swaps the queries when the target type matches', () => {
|
||||
const spec = specOfKind('signoz/TimeSeriesPanel');
|
||||
const result = buildViewPanelSpec({
|
||||
spec,
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
expect(result.plugin.kind).toBe('signoz/TimeSeriesPanel');
|
||||
expect(result.plugin.spec).toBe(spec.plugin.spec);
|
||||
expect(result.queries).toStrictEqual([{ kind: 'mock-query' }]);
|
||||
expect(toPerses).toHaveBeenCalledWith(query, PANEL_TYPES.TIME_SERIES);
|
||||
expect(getSwitchedPluginSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches the kind (Value → Table) and rebuilds the plugin spec', () => {
|
||||
const result = buildViewPanelSpec({
|
||||
spec: specOfKind('signoz/NumberPanel'),
|
||||
query,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
});
|
||||
|
||||
expect(result.plugin.kind).toBe('signoz/TablePanel');
|
||||
expect(result.plugin.spec).toStrictEqual({ switched: true });
|
||||
expect(getSwitchedPluginSpec).toHaveBeenCalled();
|
||||
expect(toPerses).toHaveBeenCalledWith(query, PANEL_TYPES.TABLE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import type {
|
||||
PanelSeries,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownContext } from '../../../types/drilldown';
|
||||
import { buildAggregateData } from '../buildAggregateData';
|
||||
import { stepClickTimeRange } from '../chartClickTimeRange';
|
||||
import { enrichChartClick } from '../enrichChartClick';
|
||||
import { enrichNumberClick } from '../enrichNumberClick';
|
||||
import { enrichPieClick } from '../enrichPieClick';
|
||||
import { enrichTableClick } from '../enrichTableClick';
|
||||
import { resolveDrilldownSignal } from '../signal';
|
||||
|
||||
// The v5 BuilderQuery union is too verbose to construct field-typed inline; cast at the boundary.
|
||||
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
|
||||
return spec as unknown as BuilderQuery;
|
||||
}
|
||||
|
||||
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
labels: { 'service.name': 'frontend' },
|
||||
kind: 'series',
|
||||
values: [],
|
||||
aggregation: { index: 0, alias: '' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function chartClick(
|
||||
focusedSeries: ChartClickData['focusedSeries'],
|
||||
): ChartClickData {
|
||||
return {
|
||||
xValue: 0,
|
||||
yValue: 0,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp: 1_700_000_000,
|
||||
mouseX: 10,
|
||||
mouseY: 20,
|
||||
absoluteMouseX: 110,
|
||||
absoluteMouseY: 220,
|
||||
};
|
||||
}
|
||||
|
||||
function focused(seriesIndex: number): ChartClickData['focusedSeries'] {
|
||||
return { seriesIndex, seriesName: 'frontend', value: 1, color: '#fff' };
|
||||
}
|
||||
|
||||
describe('resolveDrilldownSignal', () => {
|
||||
it('maps logs/traces directly', () => {
|
||||
expect(resolveDrilldownSignal(builderQuery({ signal: 'logs' }))).toBe('logs');
|
||||
expect(resolveDrilldownSignal(builderQuery({ signal: 'traces' }))).toBe(
|
||||
'traces',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to metrics for metrics, meter and unknown/missing signals', () => {
|
||||
expect(resolveDrilldownSignal(builderQuery({ signal: 'metrics' }))).toBe(
|
||||
'metrics',
|
||||
);
|
||||
expect(resolveDrilldownSignal(builderQuery({ signal: 'meter' }))).toBe(
|
||||
'metrics',
|
||||
);
|
||||
expect(resolveDrilldownSignal(undefined)).toBe('metrics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichChartClick', () => {
|
||||
const series = [
|
||||
panelSeries({ queryName: 'A', labels: { 'service.name': 'frontend' } }),
|
||||
panelSeries({ queryName: 'B', labels: { 'service.name': 'cart' } }),
|
||||
];
|
||||
|
||||
it('maps the uPlot series index to the (index - 1) flattened series', () => {
|
||||
// uPlot series[0] is the x-axis, so data series start at 1.
|
||||
const payload = enrichChartClick({
|
||||
clickData: chartClick(focused(2)),
|
||||
series,
|
||||
builderQueries: [
|
||||
builderQuery({ name: 'A', signal: 'metrics' }),
|
||||
builderQuery({ name: 'B', signal: 'logs' }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('B');
|
||||
expect(payload?.context.signal).toBe('logs');
|
||||
expect(payload?.context.filters).toStrictEqual([
|
||||
expect.objectContaining({ filterKey: 'service.name', filterValue: 'cart' }),
|
||||
]);
|
||||
expect(payload?.context.seriesColor).toBe('#fff');
|
||||
expect(payload?.coordinates).toStrictEqual({ x: 110, y: 220 });
|
||||
});
|
||||
|
||||
it('passes through the caller-computed time range and resolves the signal', () => {
|
||||
const payload = enrichChartClick({
|
||||
clickData: chartClick(focused(1)),
|
||||
series,
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'traces' })],
|
||||
timeRange: { startTime: 100, endTime: 200 },
|
||||
});
|
||||
|
||||
expect(payload?.context.signal).toBe('traces');
|
||||
expect(payload?.context.timeRange).toStrictEqual({
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when there is no focused series', () => {
|
||||
expect(
|
||||
enrichChartClick({
|
||||
clickData: chartClick(null),
|
||||
series,
|
||||
builderQueries: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the series index maps to no series', () => {
|
||||
expect(
|
||||
enrichChartClick({
|
||||
clickData: chartClick(focused(99)),
|
||||
series,
|
||||
builderQueries: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for formula queries (queryName starts with F)', () => {
|
||||
expect(
|
||||
enrichChartClick({
|
||||
clickData: chartClick(focused(1)),
|
||||
series: [panelSeries({ queryName: 'F1' })],
|
||||
builderQueries: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('emits empty filters for an ungrouped series', () => {
|
||||
const payload = enrichChartClick({
|
||||
clickData: chartClick(focused(1)),
|
||||
series: [panelSeries({ queryName: 'A', labels: {} })],
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'metrics' })],
|
||||
});
|
||||
|
||||
expect(payload?.context.filters).toStrictEqual([]);
|
||||
expect(payload?.context.queryName).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAggregateData', () => {
|
||||
it('projects the drilldown context onto the V1 AggregateData shape', () => {
|
||||
const context: DrilldownContext = {
|
||||
queryName: 'A',
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
label: 'frontend',
|
||||
seriesColor: '#abc',
|
||||
columnKind: 'aggregate',
|
||||
};
|
||||
|
||||
expect(buildAggregateData(context)).toStrictEqual({
|
||||
queryName: 'A',
|
||||
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
label: 'frontend',
|
||||
seriesColor: '#abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichNumberClick', () => {
|
||||
const numberTable = (queryName: string): PanelTable => ({
|
||||
queryName,
|
||||
legend: '',
|
||||
columns: [{ name: 'value', queryName, isValueColumn: true, id: queryName }],
|
||||
rows: [{ data: { [queryName]: 42 } }],
|
||||
});
|
||||
|
||||
it('drills down on the displayed value column with empty filters and no label', () => {
|
||||
const payload = enrichNumberClick({
|
||||
tables: [numberTable('A')],
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
|
||||
coordinates: { x: 5, y: 6 },
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
});
|
||||
|
||||
// No label: the menu header falls back to the aggregation expression (V1 parity).
|
||||
expect(payload?.context).toStrictEqual({
|
||||
queryName: 'A',
|
||||
signal: 'logs',
|
||||
filters: [],
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
});
|
||||
expect(payload?.coordinates).toStrictEqual({ x: 5, y: 6 });
|
||||
});
|
||||
|
||||
it('drills into the displayed value column, not the first builder query', () => {
|
||||
// Panel shows query B's value column; drilldown must target B, not A.
|
||||
const payload = enrichNumberClick({
|
||||
tables: [numberTable('B')],
|
||||
builderQueries: [
|
||||
builderQuery({ name: 'A', signal: 'logs' }),
|
||||
builderQuery({ name: 'B', signal: 'traces' }),
|
||||
],
|
||||
coordinates: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('B');
|
||||
expect(payload?.context.signal).toBe('traces');
|
||||
});
|
||||
|
||||
it('returns null when there is no drillable query', () => {
|
||||
expect(
|
||||
enrichNumberClick({
|
||||
tables: [],
|
||||
builderQueries: [],
|
||||
coordinates: { x: 0, y: 0 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a formula query', () => {
|
||||
expect(
|
||||
enrichNumberClick({
|
||||
tables: [numberTable('F1')],
|
||||
builderQueries: [],
|
||||
coordinates: { x: 0, y: 0 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichTableClick', () => {
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
rows: [{ data: { 'service.name': 'frontend', A: 42 } }],
|
||||
};
|
||||
const record = { 'service.name': 'frontend', A: 42 };
|
||||
const builderQueries = [builderQuery({ name: 'A', signal: 'traces' })];
|
||||
|
||||
it('builds equality filters from the row group cells for a value-column click', () => {
|
||||
const payload = enrichTableClick({
|
||||
record,
|
||||
columnId: 'A',
|
||||
table,
|
||||
builderQueries,
|
||||
coordinates: { x: 1, y: 2 },
|
||||
timeRange: { startTime: 10, endTime: 20 },
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('A');
|
||||
expect(payload?.context.signal).toBe('traces');
|
||||
expect(payload?.context.columnKind).toBe('aggregate');
|
||||
expect(payload?.context.clickedKey).toBeUndefined();
|
||||
// No label: the aggregate menu header falls back to the aggregation expression,
|
||||
// not the value column name (V1 parity).
|
||||
expect(payload?.context.label).toBeUndefined();
|
||||
expect(payload?.context.filters).toStrictEqual([
|
||||
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the row value column and carries the clicked cell for a group click', () => {
|
||||
const payload = enrichTableClick({
|
||||
record,
|
||||
columnId: 'service.name',
|
||||
table,
|
||||
builderQueries,
|
||||
coordinates: { x: 1, y: 2 },
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('A');
|
||||
expect(payload?.context.columnKind).toBe('group');
|
||||
expect(payload?.context.clickedKey).toBe('service.name');
|
||||
expect(payload?.context.clickedValue).toBe('frontend');
|
||||
});
|
||||
|
||||
it('returns null when the table has no value column', () => {
|
||||
const groupOnly: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
],
|
||||
rows: [{ data: { 'service.name': 'frontend' } }],
|
||||
};
|
||||
expect(
|
||||
enrichTableClick({
|
||||
record,
|
||||
columnId: 'service.name',
|
||||
table: groupOnly,
|
||||
builderQueries,
|
||||
coordinates: { x: 1, y: 2 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichPieClick', () => {
|
||||
it('builds filters from the slice labels and resolves the signal', () => {
|
||||
const payload = enrichPieClick({
|
||||
slice: {
|
||||
label: 'frontend',
|
||||
value: 12,
|
||||
color: '#abc',
|
||||
queryName: 'A',
|
||||
labels: { 'service.name': 'frontend' },
|
||||
},
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'traces' })],
|
||||
coordinates: { x: 7, y: 8 },
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('A');
|
||||
expect(payload?.context.signal).toBe('traces');
|
||||
expect(payload?.context.filters).toStrictEqual([
|
||||
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null for a slice with no source query', () => {
|
||||
expect(
|
||||
enrichPieClick({
|
||||
slice: { label: 'x', value: 1, color: '#000' },
|
||||
builderQueries: [],
|
||||
coordinates: { x: 0, y: 0 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepClickTimeRange', () => {
|
||||
it('returns [clickedTs, clickedTs + step] for a non-APM query', () => {
|
||||
expect(
|
||||
stepClickTimeRange({
|
||||
clickedDataTimestamp: 1000,
|
||||
queryName: 'A',
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
|
||||
stepIntervals: { A: 30 },
|
||||
}),
|
||||
).toStrictEqual({ startTime: 1000, endTime: 1030 });
|
||||
});
|
||||
|
||||
it('falls back to a 60s step when no interval is provided', () => {
|
||||
expect(
|
||||
stepClickTimeRange({
|
||||
clickedDataTimestamp: 1000,
|
||||
queryName: 'A',
|
||||
builderQueries: [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [{ metricName: 'custom_metric' }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toStrictEqual({ startTime: 1000, endTime: 1060 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
|
||||
|
||||
import type { DrilldownContext } from '../../types/drilldown';
|
||||
|
||||
/**
|
||||
* Adapts a V2 `DrilldownContext` to the V1 `AggregateData` that `buildDrilldownUrl`/the drilldown
|
||||
* navigate hook consume. The single boundary between the V2 click payload and the reused V1
|
||||
* navigation machinery.
|
||||
*/
|
||||
export function buildAggregateData(context: DrilldownContext): AggregateData {
|
||||
return {
|
||||
queryName: context.queryName,
|
||||
filters: context.filters,
|
||||
timeRange: context.timeRange,
|
||||
label: context.label,
|
||||
seriesColor: context.seriesColor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getSwitchedPluginSpec } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec';
|
||||
import {
|
||||
PANEL_TYPE_TO_PANEL_KIND,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getBuilderQueries } from '../getBuilderQueries';
|
||||
|
||||
/**
|
||||
* Bakes a V1 query + target panel type into a View-modal spec (drilldown seed + URL re-hydration).
|
||||
* When `panelType` maps to a different kind than the panel's (e.g. a breakout turns Value → Table),
|
||||
* the kind is switched via the editor's `getSwitchedPluginSpec` so it opens with populated config.
|
||||
*/
|
||||
export function buildViewPanelSpec({
|
||||
spec,
|
||||
query,
|
||||
panelType,
|
||||
}: {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
query: Query;
|
||||
panelType: PANEL_TYPES;
|
||||
}): DashboardtypesPanelSpecDTO {
|
||||
const queries = toPerses(query, panelType);
|
||||
const currentKind = spec.plugin.kind as PanelKind;
|
||||
const newKind = PANEL_TYPE_TO_PANEL_KIND[panelType] ?? currentKind;
|
||||
|
||||
if (newKind === currentKind) {
|
||||
return { ...spec, queries };
|
||||
}
|
||||
|
||||
// The plugin cast mirrors the editor's type-switch — a dynamically chosen kind can't be
|
||||
// correlated with its spec statically.
|
||||
const signal = getBuilderQueries(spec.queries ?? [])[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
return {
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
kind: newKind,
|
||||
spec: getSwitchedPluginSpec(spec, newKind, signal),
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
getTimeRangeFromStepInterval,
|
||||
isApmMetric,
|
||||
} from 'container/PanelWrapper/utils';
|
||||
import type { BuilderQuery, MetricAggregation } from 'types/api/v5/queryRange';
|
||||
|
||||
/** Fallback step (seconds) when the response carries no per-query step interval (V1 parity). */
|
||||
const DEFAULT_STEP_INTERVAL = 60;
|
||||
|
||||
interface StepClickTimeRangeArgs {
|
||||
/** Clicked bucket timestamp, in the chart's x-unit (epoch seconds). */
|
||||
clickedDataTimestamp: number;
|
||||
/** The clicked series' query — selects its step and detects APM metrics. */
|
||||
queryName: string;
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Per-query step intervals (seconds) from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time window for a time-axis chart click: the clicked bucket plus one step (V1 parity). APM-metric
|
||||
* panels widen the window one step to the left. Shared by the TimeSeries and Bar renderers; the
|
||||
* matching field remapping happens later inside `getViewQuery`.
|
||||
*/
|
||||
export function stepClickTimeRange({
|
||||
clickedDataTimestamp,
|
||||
queryName,
|
||||
builderQueries,
|
||||
stepIntervals,
|
||||
}: StepClickTimeRangeArgs): { startTime: number; endTime: number } {
|
||||
const builderQuery = builderQueries.find((query) => query.name === queryName);
|
||||
const stepInterval = stepIntervals?.[queryName] ?? DEFAULT_STEP_INTERVAL;
|
||||
const isApm =
|
||||
builderQuery?.signal === 'metrics' &&
|
||||
isApmMetric(
|
||||
(builderQuery?.aggregations?.[0] as MetricAggregation)?.metricName ?? '',
|
||||
);
|
||||
return getTimeRangeFromStepInterval(stepInterval, clickedDataTimestamp, isApm);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
getFiltersFromMetric,
|
||||
isValidQueryName,
|
||||
} from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichChartClickArgs {
|
||||
clickData: ChartClickData;
|
||||
/** Flattened series in the same order they were added to uPlot (see `prepareAlignedData`/`addSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** The panel's builder queries, for resolving the clicked series' signal by `queryName`. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Explorer time window; the caller computes it (clicked bucket ±step for time charts, panel window for histograms). */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a uPlot click (time-series or bar) into a drilldown payload. Resolves the clicked series via
|
||||
* uPlot's series index (index 0 is the x-axis, so data series start at 1 → `series[seriesIndex - 1]`)
|
||||
* and builds equality filters from its group-by labels. Returns `null` when the click can't be
|
||||
* attributed to a drillable series (no focused series, unmapped index, or a formula query).
|
||||
*/
|
||||
export function enrichChartClick({
|
||||
clickData,
|
||||
series,
|
||||
builderQueries,
|
||||
timeRange,
|
||||
}: EnrichChartClickArgs): DrilldownClickPayload | null {
|
||||
const { focusedSeries } = clickData;
|
||||
if (!focusedSeries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelSeries = series[focusedSeries.seriesIndex - 1];
|
||||
if (!panelSeries || !isValidQueryName(panelSeries.queryName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builderQuery = builderQueries.find(
|
||||
(query) => query.name === panelSeries.queryName,
|
||||
);
|
||||
|
||||
return {
|
||||
coordinates: { x: clickData.absoluteMouseX, y: clickData.absoluteMouseY },
|
||||
context: {
|
||||
queryName: panelSeries.queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters: getFiltersFromMetric(panelSeries.labels),
|
||||
timeRange,
|
||||
label: focusedSeries.seriesName,
|
||||
seriesColor: focusedSeries.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichNumberClickArgs {
|
||||
/** The panel's scalar tables — the displayed value's column selects the drilldown query. */
|
||||
tables: PanelTable[];
|
||||
/** The panel's builder queries; resolves the clicked query's signal by name. */
|
||||
builderQueries: BuilderQuery[];
|
||||
coordinates: { x: number; y: number };
|
||||
/** Explorer time window — the panel's fetched window (the value has no clicked bucket). */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a Number/Value click into a drilldown payload. Drills into the query the panel actually
|
||||
* displays — the first table-with-rows' value column (mirrors `prepareNumberData`), not blindly
|
||||
* `builderQueries[0]` (they diverge for multi-query panels). Returns `null` when that query isn't
|
||||
* drillable (promql/formula).
|
||||
*/
|
||||
export function enrichNumberClick({
|
||||
tables,
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange,
|
||||
}: EnrichNumberClickArgs): DrilldownClickPayload | null {
|
||||
const valueColumn = tables
|
||||
.find((table) => table.rows.length > 0)
|
||||
?.columns.find((column) => column.isValueColumn);
|
||||
const queryName = valueColumn?.queryName ?? builderQueries[0]?.name ?? '';
|
||||
if (!isValidQueryName(queryName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builderQuery = builderQueries.find((query) => query.name === queryName);
|
||||
return {
|
||||
coordinates,
|
||||
context: {
|
||||
queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters: [],
|
||||
timeRange,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
import {
|
||||
getFiltersFromMetric,
|
||||
isValidQueryName,
|
||||
} from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichPieClickArgs {
|
||||
slice: PieSlice;
|
||||
builderQueries: BuilderQuery[];
|
||||
coordinates: { x: number; y: number };
|
||||
/** Explorer time window — the panel's fetched window (pie slices have no clicked bucket). */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a pie-slice click into a drilldown payload, using the slice's source-row labels (carried by
|
||||
* `preparePieData`) as equality filters. Returns `null` when the slice has no drillable query.
|
||||
*/
|
||||
export function enrichPieClick({
|
||||
slice,
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange,
|
||||
}: EnrichPieClickArgs): DrilldownClickPayload | null {
|
||||
const queryName = slice.queryName ?? '';
|
||||
if (!isValidQueryName(queryName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builderQuery = builderQueries.find((query) => query.name === queryName);
|
||||
return {
|
||||
coordinates,
|
||||
context: {
|
||||
queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters: getFiltersFromMetric(slice.labels ?? {}),
|
||||
timeRange,
|
||||
label: slice.label,
|
||||
seriesColor: slice.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
type FilterData,
|
||||
isValidQueryName,
|
||||
} from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichTableClickArgs {
|
||||
/** The clicked row's data, keyed by column id (see `prepareScalarTables`). */
|
||||
record: Record<string, unknown>;
|
||||
/** The clicked column's key (`column.id || column.name`). */
|
||||
columnId: string;
|
||||
table: PanelTable;
|
||||
builderQueries: BuilderQuery[];
|
||||
coordinates: { x: number; y: number };
|
||||
/** Explorer time window — the panel's fetched window (scalar tables have no clicked bucket). */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a table cell click into a drilldown payload. The clicked value column (or the row's first
|
||||
* value column) selects the aggregate query, and the row's group-by cells become equality filters
|
||||
* (V1 `getFiltersToAddToView` parity). `columnKind` records whether a value or group column was
|
||||
* clicked, for the future filter-by-value menu. Returns `null` when the row has no drillable
|
||||
* aggregate query.
|
||||
*/
|
||||
export function enrichTableClick({
|
||||
record,
|
||||
columnId,
|
||||
table,
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange,
|
||||
}: EnrichTableClickArgs): DrilldownClickPayload | null {
|
||||
const clickedColumn = table.columns.find(
|
||||
(col) => (col.id || col.name) === columnId,
|
||||
);
|
||||
const valueColumn = clickedColumn?.isValueColumn
|
||||
? clickedColumn
|
||||
: table.columns.find((col) => col.isValueColumn);
|
||||
if (!valueColumn || !isValidQueryName(valueColumn.queryName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filters = table.columns.reduce<FilterData[]>((acc, col) => {
|
||||
if (col.isValueColumn) {
|
||||
return acc;
|
||||
}
|
||||
const value = record[col.id || col.name];
|
||||
if (value != null) {
|
||||
// Group cell value → equality filter. Cast at the boundary: row data is `unknown`,
|
||||
// group cells hold scalar label values.
|
||||
acc.push({
|
||||
filterKey: col.name,
|
||||
filterValue: value as string | number,
|
||||
operator: OPERATORS['='],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const builderQuery = builderQueries.find(
|
||||
(query) => query.name === valueColumn.queryName,
|
||||
);
|
||||
|
||||
// A group-column click filters by that single cell (V1 filter-by-value); a value-column click
|
||||
// opens the aggregate menu scoped by the whole row.
|
||||
const isGroupColumn = clickedColumn != null && !clickedColumn.isValueColumn;
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
context: {
|
||||
queryName: valueColumn.queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters,
|
||||
timeRange,
|
||||
// No `label`: like Number/Value, the aggregate menu header falls back to the
|
||||
// aggregation expression (e.g. `sum(signoz_calls_total)`), not the column name (V1 parity).
|
||||
columnKind: isGroupColumn ? 'group' : 'aggregate',
|
||||
clickedKey: isGroupColumn ? clickedColumn?.name : undefined,
|
||||
clickedValue: isGroupColumn
|
||||
? (record[columnId] as string | number)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Maps a V5 builder query's `signal` to the drilldown signal. Meter and unknown signals fall back to
|
||||
* `metrics` so the drilldown always targets a real explorer.
|
||||
*/
|
||||
export function resolveDrilldownSignal(
|
||||
query: BuilderQuery | undefined,
|
||||
): TelemetrytypesSignalDTO {
|
||||
switch (query?.signal) {
|
||||
case 'logs':
|
||||
return TelemetrytypesSignalDTO.logs;
|
||||
case 'traces':
|
||||
return TelemetrytypesSignalDTO.traces;
|
||||
default:
|
||||
return TelemetrytypesSignalDTO.metrics;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import store from 'store';
|
||||
|
||||
/** Panel time window in epoch SECONDS (uPlot X-scale + drilldown explorer window). */
|
||||
interface PanelTimeRange {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time window a panel's data was fetched over, read off the request's `start`/`end` (ms → s).
|
||||
* Falls back to the dashboard global-time window when the panel hasn't fetched yet.
|
||||
*/
|
||||
export function getPanelTimeRange(
|
||||
request: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): PanelTimeRange {
|
||||
if (request?.start && request?.end) {
|
||||
return { startTime: request.start / 1000, endTime: request.end / 1000 };
|
||||
}
|
||||
|
||||
const { globalTime } = store.getState();
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: 'GLOBAL_TIME',
|
||||
interval: globalTime.selectedTime,
|
||||
});
|
||||
return { startTime: parseInt(start, 10), endTime: parseInt(end, 10) };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DraftingCompass, Loader, ScrollText } from '@signozhq/icons';
|
||||
import { getAggregateColumnHeader } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface DrilldownAggregateMenuProps {
|
||||
context: DrilldownContext;
|
||||
/** Panel's V5→V1 query — supplies the aggregation-expression header fallback. */
|
||||
query: Query;
|
||||
/** While dashboard variables resolve, the actions show a spinner and are disabled. */
|
||||
isResolving?: boolean;
|
||||
onViewLogs: () => void;
|
||||
onViewTraces: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base aggregate drill-down menu: a tinted header + View in Logs/Traces. Metrics is
|
||||
* omitted — V1 surfaces only Logs/Traces.
|
||||
*/
|
||||
function DrilldownAggregateMenu({
|
||||
context,
|
||||
query,
|
||||
isResolving = false,
|
||||
onViewLogs,
|
||||
onViewTraces,
|
||||
}: DrilldownAggregateMenuProps): JSX.Element {
|
||||
const aggregations = useMemo(
|
||||
() => getAggregateColumnHeader(query, context.queryName).aggregations,
|
||||
[query, context.queryName],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Header>
|
||||
<div style={{ textTransform: 'capitalize' }}>{context.signal}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'normal',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: context.seriesColor,
|
||||
}}
|
||||
>
|
||||
{context.label || aggregations}
|
||||
</div>
|
||||
</ContextMenu.Header>
|
||||
<ContextMenu.Item
|
||||
icon={
|
||||
isResolving ? (
|
||||
<Loader className="animate-spin" size={16} color={context.seriesColor} />
|
||||
) : (
|
||||
<span style={{ color: context.seriesColor }}>
|
||||
<ScrollText size={16} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onClick={onViewLogs}
|
||||
disabled={isResolving}
|
||||
>
|
||||
<span data-testid="drilldown-view-logs">View in Logs</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
icon={
|
||||
isResolving ? (
|
||||
<Loader className="animate-spin" color={context.seriesColor} size={16} />
|
||||
) : (
|
||||
<span style={{ color: context.seriesColor }}>
|
||||
<DraftingCompass size={16} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onClick={onViewTraces}
|
||||
disabled={isResolving}
|
||||
>
|
||||
<span data-testid="drilldown-view-traces">View in Traces</span>
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrilldownAggregateMenu;
|
||||
@@ -3,11 +3,13 @@ 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 { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useDrilldown } from './hooks/useDrilldown';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import PanelBody from './PanelBody/PanelBody';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
@@ -67,6 +69,7 @@ function Panel({
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
const drilldown = useDrilldown(panel, panelId);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -98,8 +101,11 @@ function Panel({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,19 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockOpenView = jest.fn();
|
||||
jest.mock('../../hooks/useViewPanel', () => ({
|
||||
useViewPanel: (): {
|
||||
openView: jest.Mock;
|
||||
closeView: jest.Mock;
|
||||
expandedPanelId: string | null;
|
||||
} => ({
|
||||
openView: mockOpenView,
|
||||
closeView: jest.fn(),
|
||||
expandedPanelId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockMovePanel = jest.fn();
|
||||
jest.mock('../../hooks/useMovePanelToSection', () => ({
|
||||
useMovePanelToSection: (): jest.Mock => mockMovePanel,
|
||||
@@ -264,18 +277,13 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('not-yet-implemented actions (view) fire the placeholder alert with the feature name', () => {
|
||||
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
it('view opens the View modal for the panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
const view = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'view-panel',
|
||||
);
|
||||
(view as { onClick: () => void }).onClick();
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledTimes(1);
|
||||
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
|
||||
alertSpy.mockRestore();
|
||||
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { ComponentTypes } from 'utils/permission';
|
||||
|
||||
/**
|
||||
* Every action the panel menu can offer: per-kind gated capabilities (minus
|
||||
* `search`, a header control) plus the chrome actions every kind gets. The
|
||||
* `Record<PanelActionId, …>` below forces a meta entry per id, so adding an
|
||||
* action without declaring its gates is a compile error.
|
||||
* `search` and `drilldown`, which are renderer-wired controls, not menu items)
|
||||
* plus the chrome actions every kind gets. The `Record<PanelActionId, …>` below
|
||||
* forces a meta entry per id, so adding an action without declaring its gates is
|
||||
* a compile error.
|
||||
*/
|
||||
export type PanelActionId =
|
||||
| Exclude<keyof PanelActionCapabilities, 'search'>
|
||||
| Exclude<keyof PanelActionCapabilities, 'search' | 'drilldown'>
|
||||
| 'move'
|
||||
| 'delete';
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type MovePanelArgs,
|
||||
useMovePanelToSection,
|
||||
} from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
@@ -146,6 +147,7 @@ export function usePanelActionItems({
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
@@ -178,7 +180,7 @@ export function usePanelActionItems({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => notImplementedYet('View'),
|
||||
onClick: (): void => openView(panelId),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && panelCapabilities.edit) {
|
||||
@@ -263,6 +265,7 @@ export function usePanelActionItems({
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
movePanel,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
@@ -32,6 +33,12 @@ interface PanelBodyProps {
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles — only consumed by raw/list renderers. */
|
||||
pagination?: PanelPagination;
|
||||
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
/** Opens the drill-down context menu; threaded to interactive renderers. */
|
||||
onClick?: AnyPanelInteractionProps['onClick'];
|
||||
/** Gate for the drill-down menu — kind supported and the panel has a builder query. */
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +58,9 @@ function PanelBody({
|
||||
panelMode = PanelMode.DASHBOARD_VIEW,
|
||||
searchTerm,
|
||||
pagination,
|
||||
onCloseStandaloneView,
|
||||
onClick,
|
||||
enableDrillDown = false,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// react-query keeps the previous response during refetches, so its presence is
|
||||
// the "have something to show" signal — only fail hard when there's nothing.
|
||||
@@ -108,10 +118,12 @@ function PanelBody({
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
enableDrillDown={false}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onClick={onClick}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// Expanded state: a compact input that fits the header row.
|
||||
.input {
|
||||
width: 180px;
|
||||
width: min(100%, 320px);
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
--button-height: 18px;
|
||||
--button-width: 18px;
|
||||
--button-padding: 0;
|
||||
}
|
||||
|
||||
.searchTrigger {
|
||||
--button-width: 24px;
|
||||
--button-height: 24px;
|
||||
--button-padding: 4px;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ function PanelHeaderSearch({
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => setExpanded(true)}
|
||||
className={styles.searchTrigger}
|
||||
data-testid="panel-header-search-trigger"
|
||||
aria-label="Search"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.modal {
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
// Tall, fixed-height column so the renderer's resize observer measures real
|
||||
// dimensions — the chart self-sizes to fill whatever space it's given.
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 78vh;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.queryBuilder {
|
||||
flex: 0 0 auto;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbarTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.panelTypeSelector {
|
||||
width: 240px;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Modal } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface ViewPanelModalProps {
|
||||
/**
|
||||
* The expanded panel and its id. Absent while the modal is closed — a single
|
||||
* host instance lives at the layout level and only carries a panel when open.
|
||||
*/
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
panelId?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ViewPanelModal({
|
||||
panel,
|
||||
panelId,
|
||||
open,
|
||||
onClose,
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered
|
||||
width="85%"
|
||||
destroyOnClose
|
||||
className={styles.modal}
|
||||
title={
|
||||
<TooltipSimple title={name} arrow>
|
||||
<span className={styles.title}>{name} - (View mode)</span>
|
||||
</TooltipSimple>
|
||||
}
|
||||
>
|
||||
{open && panel && panelId && (
|
||||
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModal;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
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';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
|
||||
import { usePanelInteractions } from '../hooks/usePanelInteractions';
|
||||
import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelEditor } from './useViewPanelEditor';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Close the modal — wired to the graph manager's Save/Cancel. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
|
||||
* the panel (preview) over a per-view time window plus the shared query builder, so the
|
||||
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
|
||||
*/
|
||||
function ViewPanelModalContent({
|
||||
panel,
|
||||
panelId,
|
||||
onClose,
|
||||
}: ViewPanelModalContentProps): JSX.Element | null {
|
||||
const {
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
} = useViewPanelTimeWindow();
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
signal,
|
||||
defaultSignal,
|
||||
queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
} = useViewPanelEditor({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
|
||||
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
|
||||
// cursor-sync group so a drag here can't replay onto the grid panels.
|
||||
const { dashboardPreference } = usePanelInteractions();
|
||||
const isolatedPreference = useMemo<DashboardPreference>(
|
||||
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
|
||||
[dashboardPreference],
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// The View action only appears for registered kinds, so this is defensive.
|
||||
if (!panelDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<ViewPanelModalHeader
|
||||
selectedInterval={selectedInterval}
|
||||
startMs={timeOverride.startMs}
|
||||
endMs={timeOverride.endMs}
|
||||
onTimeChange={onTimeChange}
|
||||
isFetching={isFetching}
|
||||
onRefresh={(): void => {
|
||||
// Relative windows re-anchor to now (new key → refetch); a fixed
|
||||
// custom window just re-runs the same query.
|
||||
if (selectedInterval === 'custom') {
|
||||
refetch();
|
||||
} else {
|
||||
refreshWindow();
|
||||
}
|
||||
}}
|
||||
onSwitchToEdit={(): void =>
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
|
||||
}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onResetQuery={resetQuery}
|
||||
/>
|
||||
<div className={styles.queryBuilder}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
signal={signal ?? defaultSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
dashboardPreference={isolatedPreference}
|
||||
onCloseStandaloneView={onClose}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalContent;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PenLine, RotateCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { usePanelTypeSelectItems } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
|
||||
import ConfigSelect from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalHeaderProps {
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
/** Current window bounds (epoch ms) — seed the picker's modal display. */
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
onTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
range?: [number, number],
|
||||
) => void;
|
||||
/** Any query in flight — spins the refresh icon and disables it. */
|
||||
isFetching: boolean;
|
||||
onRefresh: () => void;
|
||||
onSwitchToEdit: () => void;
|
||||
/** Draft's current kind (selected value of the panel-type selector). */
|
||||
panelKind: PanelKind;
|
||||
/** Active query type — disables kinds that can't be authored in it (e.g. List under PromQL). */
|
||||
queryType?: EQueryType;
|
||||
/** Current builder datasource — disables types that don't support it. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the saved query + kind (drilldown reset). */
|
||||
onResetQuery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar for the View modal: reset the drilldown, open the full editor, switch the
|
||||
* visualization kind, pick a per-view time window (isolated from the dashboard), and
|
||||
* refresh. Mirrors V1's FullView header controls.
|
||||
*/
|
||||
function ViewPanelModalHeader({
|
||||
selectedInterval,
|
||||
startMs,
|
||||
endMs,
|
||||
onTimeChange,
|
||||
isFetching,
|
||||
onRefresh,
|
||||
onSwitchToEdit,
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
onChangePanelKind,
|
||||
onResetQuery,
|
||||
}: ViewPanelModalHeaderProps): JSX.Element {
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.panelTypeSelector}>
|
||||
<ConfigSelect<PanelKind>
|
||||
testId="view-panel-type-selector"
|
||||
value={panelKind}
|
||||
items={panelTypeItems}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={onResetQuery}
|
||||
data-testid="view-panel-reset-query"
|
||||
>
|
||||
Reset Query
|
||||
</Button>
|
||||
<div className={styles.toolbarTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection
|
||||
disableUrlSync
|
||||
onTimeChange={onTimeChange}
|
||||
modalSelectedInterval={selectedInterval as Time}
|
||||
modalInitialStartTime={startMs}
|
||||
modalInitialEndTime={endMs}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onRefresh}
|
||||
disabled={isFetching}
|
||||
aria-label="Refresh"
|
||||
data-testid="view-panel-refresh"
|
||||
>
|
||||
<RotateCw className={cx({ 'animate-spin': isFetching })} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalHeader;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { usePanelEditSession } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { buildViewPanelSpec } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface UseViewPanelEditorArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
|
||||
time: PanelQueryTimeOverride;
|
||||
}
|
||||
|
||||
export interface UseViewPanelEditorApi {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Resolved renderer for the draft's current kind. */
|
||||
panelDefinition: RenderablePanelDefinition | undefined;
|
||||
/** Current builder datasource — drives the panel-type selector's disabled rule. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** The kind's first supported signal — the query builder's fallback datasource. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
|
||||
queryType: EQueryType;
|
||||
/** Query result for the draft over the per-view window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
|
||||
runQuery: () => void;
|
||||
/** Switch the draft's visualization kind (temporary; reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the query the view opened with, discarding in-modal edits. */
|
||||
resetQuery: () => void;
|
||||
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The View modal's compact drilldown editor on the shared `usePanelEditSession`. Edits are
|
||||
* temporary — they live in the builder/URL + draft, never the dashboard (V1 parity).
|
||||
*/
|
||||
export function useViewPanelEditor({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
}: UseViewPanelEditorArgs): UseViewPanelEditorApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
// Seed the draft from the URL (`compositeQuery` + `graphType`) when present, else the saved
|
||||
// panel — mount-only, so a refresh re-seeds from the URL and in-modal edits survive (V1 parity).
|
||||
const urlQuery = useGetCompositeQueryParam();
|
||||
const urlGraphType = useUrlQuery().get(
|
||||
QueryParams.graphType,
|
||||
) as PANEL_TYPES | null;
|
||||
const initialPanel = useMemo<DashboardtypesPanelDTO>(
|
||||
() =>
|
||||
urlQuery
|
||||
? {
|
||||
...panel,
|
||||
spec: buildViewPanelSpec({
|
||||
spec: panel.spec,
|
||||
query: urlQuery,
|
||||
panelType:
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
}),
|
||||
}
|
||||
: panel,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
buildSaveSpec,
|
||||
reset,
|
||||
} = usePanelEditSession({ panel: initialPanel, panelId, time });
|
||||
|
||||
// The query the view opened with, captured once — the Reset target.
|
||||
const savedQuery = useMemo(
|
||||
() =>
|
||||
fromPerses(
|
||||
panel.spec.queries,
|
||||
PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
[],
|
||||
);
|
||||
|
||||
const resetQuery = useCallback((): void => {
|
||||
reset();
|
||||
redirectWithQueryBuilderData(savedQuery);
|
||||
}, [reset, redirectWithQueryBuilderData, savedQuery]);
|
||||
|
||||
// Current builder datasource for the panel-type disabled rule — resolved the same
|
||||
// way as the full editor's ConfigPane so the two selectors stay in sync.
|
||||
const signal = resolveSignal(draft.spec.queries, defaultSignal);
|
||||
|
||||
return {
|
||||
draft,
|
||||
panelDefinition,
|
||||
signal,
|
||||
defaultSignal,
|
||||
queryType: currentQuery.queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
export interface ViewPanelTimeWindow {
|
||||
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
|
||||
timeOverride: PanelQueryTimeOverride;
|
||||
/** Interval shown in the picker — a relative `Time` or `'custom'`. */
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
/** Apply a selection from DateTimeSelectionV2 (modal mode). */
|
||||
onTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
range?: [number, number],
|
||||
) => void;
|
||||
/** Re-anchor a relative window to "now" (manual refresh); no-op for custom. */
|
||||
refreshWindow: () => void;
|
||||
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view time window for the panel View modal, isolated from the dashboard's
|
||||
* global time (V1 parity: the modal's time selector doesn't move the grid). Seeded
|
||||
* once from the current global window, then owned locally. Relative intervals
|
||||
* resolve to an absolute ms window via the same `GetMinMax` the app-wide picker uses.
|
||||
*/
|
||||
export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
const { selectedTime, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const [selectedInterval, setSelectedInterval] = useState<
|
||||
Time | CustomTimeType
|
||||
>(selectedTime as Time);
|
||||
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
|
||||
() => ({
|
||||
startMs: Math.floor(minTime / NS_PER_MS),
|
||||
endMs: Math.floor(maxTime / NS_PER_MS),
|
||||
}),
|
||||
);
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, range?: [number, number]): void => {
|
||||
setSelectedInterval(interval);
|
||||
// Absolute range comes through directly (already epoch ms).
|
||||
if (interval === 'custom' && range) {
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(range[0]),
|
||||
endMs: Math.floor(range[1]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// GetMinMax returns nanoseconds — convert to the ms window we work in.
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshWindow = useCallback((): void => {
|
||||
// A custom window is fixed; only relative intervals re-anchor to now.
|
||||
if (selectedInterval === 'custom') {
|
||||
return;
|
||||
}
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
}, [selectedInterval]);
|
||||
|
||||
const onDragSelect = useCallback((start: number, end: number): void => {
|
||||
// Drag values are already epoch ms (same as the global custom range).
|
||||
const startMs = Math.floor(start);
|
||||
const endMs = Math.floor(end);
|
||||
// Ignore a click / zero-width or inverted selection.
|
||||
if (startMs >= endMs) {
|
||||
return;
|
||||
}
|
||||
setSelectedInterval('custom');
|
||||
setTimeOverride({ startMs, endMs });
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
}),
|
||||
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
|
||||
|
||||
// The preview reuses the edit page's PreviewPane (chart + header + heavy render
|
||||
// path); stub it (capturing props) so this suite asserts the modal shell + what it
|
||||
// threads down, not the preview internals (PreviewPane/PanelHeader own those).
|
||||
const mockPreviewPaneRender = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
|
||||
() =>
|
||||
function MockPreviewPane(props: Record<string, unknown>): ReactElement {
|
||||
mockPreviewPaneRender(props);
|
||||
return <div data-testid="preview-pane" />;
|
||||
},
|
||||
);
|
||||
|
||||
// Isolate from the draft/query-builder plumbing (its own suite covers it).
|
||||
jest.mock('../ViewPanelModal/useViewPanelEditor', () => ({
|
||||
useViewPanelEditor: (args: {
|
||||
panel: { spec: { plugin: { kind: string } } };
|
||||
}): unknown => {
|
||||
const { kind } = args.panel.spec.plugin;
|
||||
return {
|
||||
draft: args.panel,
|
||||
panelDefinition: {
|
||||
kind,
|
||||
actions: { search: kind === 'signoz/ListPanel' },
|
||||
Renderer: (): null => null,
|
||||
},
|
||||
query: {
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
},
|
||||
runQuery: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
signal: undefined,
|
||||
defaultSignal: 'logs',
|
||||
buildSaveSpec: (spec: unknown): unknown => spec,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// The View modal reuses the edit page's query builder, which reads the global
|
||||
// QueryBuilder context and pulls in the ClickHouse/PromQL editors; stub it here.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder',
|
||||
() =>
|
||||
function MockPanelEditorQueryBuilder(): ReactElement {
|
||||
return <div data-testid="panel-editor-v2-query-builder" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../hooks/usePanelInteractions', () => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: { syncMode: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
// The header mounts DateTimeSelectionV2 (redux + router + heavy deps); stub it so
|
||||
// this suite asserts the modal body, not the toolbar internals.
|
||||
jest.mock(
|
||||
'../ViewPanelModal/ViewPanelModalHeader',
|
||||
() =>
|
||||
function MockViewPanelModalHeader(): ReactElement {
|
||||
return <div data-testid="view-panel-header" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
|
||||
useViewPanelTimeWindow: (): unknown => ({
|
||||
timeOverride: { startMs: 0, endMs: 0 },
|
||||
selectedInterval: '5m',
|
||||
onTimeChange: jest.fn(),
|
||||
refreshWindow: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
() => ({
|
||||
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
|
||||
}),
|
||||
);
|
||||
|
||||
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
|
||||
render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
function makePanel(kind: string, name = 'My panel'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('ViewPanelModal', () => {
|
||||
it('renders nothing until opened', () => {
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open={false}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-modal-content'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header, query builder, and preview when open', () => {
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel', 'CPU usage')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('view-panel-modal-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-query-builder'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preview-pane')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onClose when the modal is dismissed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText('Close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Charts share one global cursor-sync key and uPlot replays drag across the
|
||||
// group; the modal must opt out so a drag here can't move the dashboard's time.
|
||||
it('opts the chart out of the dashboard cursor-sync group', () => {
|
||||
mockPreviewPaneRender.mockClear();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const props = mockPreviewPaneRender.mock.calls.at(-1)?.[0] as {
|
||||
dashboardPreference?: { syncMode?: unknown };
|
||||
};
|
||||
expect(props.dashboardPreference?.syncMode).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
|
||||
import { useDrilldown } from '../hooks/useDrilldown';
|
||||
|
||||
const mockOpenViewWithQuery = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
const mockGetBuilderQueries = jest.fn();
|
||||
let mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
|
||||
|
||||
// Boundaries tested elsewhere / needing external context — mocked so this suite isolates
|
||||
// useDrilldown's orchestration (gating, which menu shows, the View-modal handoff).
|
||||
jest.mock('../hooks/useViewPanel', () => ({
|
||||
useViewPanel: (): unknown => ({ openViewWithQuery: mockOpenViewWithQuery }),
|
||||
}));
|
||||
// Variable-substitution boundary (redux/store/react-query) — its own logic is out of scope here.
|
||||
jest.mock('../hooks/useResolvedDrilldownQuery', () => ({
|
||||
useResolvedDrilldownQuery: (): unknown => mockResolved,
|
||||
}));
|
||||
jest.mock('container/QueryTable/Drilldown/useBaseDrilldownNavigate', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => mockNavigate,
|
||||
}));
|
||||
jest.mock('container/QueryTable/Drilldown/contextConfig', () => ({
|
||||
getGroupContextMenuConfig: ({
|
||||
onColumnClick,
|
||||
}: {
|
||||
onColumnClick: (op: string) => void;
|
||||
}): unknown => ({
|
||||
items: (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-op"
|
||||
onClick={(): void => onColumnClick('=')}
|
||||
>
|
||||
Is this
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
}));
|
||||
jest.mock('container/QueryTable/Drilldown/drilldownUtils', () => ({
|
||||
addFilterToQuery: jest.fn(() => 'REFINED_QUERY'),
|
||||
getAggregateColumnHeader: (): unknown => ({
|
||||
aggregations: 'sum(x)',
|
||||
dataSource: 'metrics',
|
||||
}),
|
||||
getBaseMeta: (): unknown => undefined,
|
||||
isNumberDataType: (): boolean => false,
|
||||
}));
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
|
||||
() => ({
|
||||
fromPerses: (): string => 'V1_QUERY',
|
||||
toPerses: jest.fn(() => [{ kind: 'REFINED' }]),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries',
|
||||
() => ({
|
||||
getBuilderQueries: (...args: unknown[]): unknown =>
|
||||
mockGetBuilderQueries(...args),
|
||||
}),
|
||||
);
|
||||
// Capability lookup mocked (its per-kind values are data in the definitions); avoids
|
||||
// importing the whole renderer registry into the test.
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: (kind: string): unknown => ({
|
||||
actions: { drilldown: kind !== 'signoz/ListPanel' },
|
||||
}),
|
||||
}));
|
||||
|
||||
function panelOfKind(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
spec: { plugin: { kind, spec: {} }, queries: [{ x: 1 }] },
|
||||
display: { name: 'P' },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const tsPanel = panelOfKind('signoz/TimeSeriesPanel');
|
||||
|
||||
const aggregateContext: DrilldownContext = {
|
||||
queryName: 'A',
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
filters: [],
|
||||
label: 'frontend',
|
||||
seriesColor: '#fff',
|
||||
};
|
||||
|
||||
const groupContext: DrilldownContext = {
|
||||
queryName: 'A',
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
filters: [],
|
||||
columnKind: 'group',
|
||||
clickedKey: 'service.name',
|
||||
clickedValue: 'frontend',
|
||||
};
|
||||
|
||||
describe('useDrilldown', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetBuilderQueries.mockReturnValue([{ name: 'A' }]);
|
||||
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
|
||||
});
|
||||
|
||||
describe('enableDrillDown', () => {
|
||||
it('is true when the kind declares drilldown and has a builder query', () => {
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
expect(result.current.enableDrillDown).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when there is no builder query', () => {
|
||||
mockGetBuilderQueries.mockReturnValue([]);
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
expect(result.current.enableDrillDown).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a kind that opts out of drilldown', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDrilldown(panelOfKind('signoz/ListPanel'), 'p1'),
|
||||
);
|
||||
expect(result.current.enableDrillDown).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate menu', () => {
|
||||
it('shows View in Logs/Traces on an aggregate click', () => {
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
expect(screen.getByTestId('drilldown-view-logs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drilldown-view-traces')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to logs when View in Logs is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
await user.click(screen.getByTestId('drilldown-view-logs'));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('view_logs');
|
||||
});
|
||||
|
||||
it('disables navigation while dashboard variables resolve', async () => {
|
||||
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: true };
|
||||
const user = userEvent.setup();
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
await user.click(screen.getByTestId('drilldown-view-logs'));
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter-by-value', () => {
|
||||
it('opens the View modal with the refined query on a group-column filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: groupContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
await user.click(screen.getByTestId('filter-op'));
|
||||
// Opens the View modal on the refined query at the panel's kind — persisted in the URL.
|
||||
expect(mockOpenViewWithQuery).toHaveBeenCalledWith(
|
||||
'p1',
|
||||
'REFINED_QUERY',
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import { useViewPanelTimeWindow } from '../ViewPanelModal/useViewPanelTimeWindow';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
// Global time is stored in nanoseconds; the hook must surface milliseconds.
|
||||
const mockState = {
|
||||
globalTime: {
|
||||
selectedTime: '6h',
|
||||
minTime: 6_000_000 * NS_PER_MS,
|
||||
maxTime: 7_000_000 * NS_PER_MS,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector(mockState),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetMinMax = GetMinMax as unknown as jest.Mock;
|
||||
|
||||
describe('useViewPanelTimeWindow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('seeds the window from global time, converting ns → ms', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: mockState.globalTime.minTime / NS_PER_MS,
|
||||
endMs: mockState.globalTime.maxTime / NS_PER_MS,
|
||||
});
|
||||
expect(result.current.selectedInterval).toBe('6h');
|
||||
});
|
||||
|
||||
it('converts GetMinMax (ns) to ms on a relative selection', () => {
|
||||
mockGetMinMax.mockReturnValue({
|
||||
minTime: 1_700_000_000_000 * NS_PER_MS,
|
||||
maxTime: 1_700_000_300_000 * NS_PER_MS,
|
||||
});
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onTimeChange('5m'));
|
||||
|
||||
expect(result.current.selectedInterval).toBe('5m');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 1_700_000_000_000,
|
||||
endMs: 1_700_000_300_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an absolute custom range as-is (already ms)', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onTimeChange('custom', [111, 222]));
|
||||
|
||||
expect(mockGetMinMax).not.toHaveBeenCalled();
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 111,
|
||||
endMs: 222,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets a custom window from a drag selection (modal-local, ms)', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onDragSelect(1000, 5000));
|
||||
|
||||
expect(result.current.selectedInterval).toBe('custom');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 1000,
|
||||
endMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a zero-width or inverted drag selection', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
const initial = result.current.timeOverride;
|
||||
|
||||
act(() => result.current.onDragSelect(5000, 5000));
|
||||
act(() => result.current.onDragSelect(9000, 1000));
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual(initial);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useBaseDrilldownNavigate from 'container/QueryTable/Drilldown/useBaseDrilldownNavigate';
|
||||
import { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import type {
|
||||
Coordinates,
|
||||
PopoverPosition,
|
||||
} from 'periscope/components/ContextMenu';
|
||||
import type {
|
||||
DrilldownClickPayload,
|
||||
DrilldownContext,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { buildAggregateData } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/buildAggregateData';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
|
||||
import DrilldownAggregateMenu from '../DrilldownMenu/DrilldownAggregateMenu';
|
||||
import { useDrilldownFilter } from './useDrilldownFilter';
|
||||
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
|
||||
import { useViewPanel } from './useViewPanel';
|
||||
|
||||
/** Props the panel shell spreads onto `<ContextMenu>`. */
|
||||
export interface DrilldownContextMenuProps {
|
||||
coordinates: Coordinates | null;
|
||||
popoverPosition: PopoverPosition | null;
|
||||
items: ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface UseDrilldownResult {
|
||||
/** Whether interactive renderers should arm the drill-down click. */
|
||||
enableDrillDown: boolean;
|
||||
/** Renderer `onClick` handler — opens the menu at the clicked point. */
|
||||
onPanelClick: (payload: DrilldownClickPayload) => void;
|
||||
contextMenuProps: DrilldownContextMenuProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates panel drill-down: owns the popover and routes the clicked point to the base
|
||||
* aggregate menu (View in Logs/Traces) or the group filter-by-value menu.
|
||||
*/
|
||||
export function useDrilldown(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
panelId: string,
|
||||
): UseDrilldownResult {
|
||||
const kind = panel.spec.plugin.kind as PanelKind;
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[kind];
|
||||
// Stable ref so the conversions below don't re-run every render (the `?? []` fallback would
|
||||
// otherwise be a fresh array each time).
|
||||
const queries = useMemo(() => panel.spec.queries ?? [], [panel.spec.queries]);
|
||||
|
||||
// Kind must opt in via its capability AND have a builder query to drill into.
|
||||
const enableDrillDown = useMemo(
|
||||
() =>
|
||||
getPanelDefinition(kind).actions.drilldown &&
|
||||
getBuilderQueries(queries).length > 0,
|
||||
[kind, queries],
|
||||
);
|
||||
|
||||
const v1Query = useMemo(
|
||||
() => fromPerses(queries, panelType),
|
||||
[queries, panelType],
|
||||
);
|
||||
|
||||
const { coordinates, popoverPosition, clickedData, onClick, onClose } =
|
||||
useCoordinates();
|
||||
const context = clickedData as DrilldownContext | null;
|
||||
|
||||
const aggregateData = useMemo(
|
||||
() => (context ? buildAggregateData(context) : null),
|
||||
[context],
|
||||
);
|
||||
|
||||
const { openViewWithQuery } = useViewPanel();
|
||||
|
||||
const filter = useDrilldownFilter({
|
||||
context,
|
||||
v1Query,
|
||||
panelId,
|
||||
panelType,
|
||||
openViewWithQuery,
|
||||
onClose,
|
||||
});
|
||||
|
||||
// The aggregate menu (View in Logs/Traces) shows for a non-group click; the group click
|
||||
// routes to filter-by-value instead. Only that menu resolves variables — filter/breakout
|
||||
// open the View modal, which resolves at query-run time.
|
||||
const showAggregateMenu = !!context && !filter.items;
|
||||
|
||||
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
|
||||
queries,
|
||||
panelType,
|
||||
v1Query,
|
||||
enabled: showAggregateMenu,
|
||||
});
|
||||
|
||||
const navigate = useBaseDrilldownNavigate({
|
||||
resolvedQuery,
|
||||
aggregateData,
|
||||
callback: onClose,
|
||||
});
|
||||
|
||||
const items = useMemo<ReactNode>(() => {
|
||||
if (filter.items) {
|
||||
return filter.items;
|
||||
}
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DrilldownAggregateMenu
|
||||
context={context}
|
||||
query={v1Query}
|
||||
isResolving={isResolving}
|
||||
onViewLogs={(): void => navigate('view_logs')}
|
||||
onViewTraces={(): void => navigate('view_traces')}
|
||||
/>
|
||||
);
|
||||
}, [filter.items, context, v1Query, isResolving, navigate]);
|
||||
|
||||
const onPanelClick = useCallback(
|
||||
(payload: DrilldownClickPayload): void =>
|
||||
onClick(payload.coordinates, payload.context),
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return {
|
||||
enableDrillDown,
|
||||
onPanelClick,
|
||||
contextMenuProps: { coordinates, popoverPosition, items, onClose },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getGroupContextMenuConfig } from 'container/QueryTable/Drilldown/contextConfig';
|
||||
import {
|
||||
addFilterToQuery,
|
||||
getBaseMeta,
|
||||
isNumberDataType,
|
||||
} from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface UseDrilldownFilterArgs {
|
||||
/** The clicked point's context; the filter menu only appears for a group-column click. */
|
||||
context: DrilldownContext | null;
|
||||
/** The panel's V5→V1 query the filter is added to. */
|
||||
v1Query: Query;
|
||||
panelId: string;
|
||||
/** Panel's V1 type — the kind the refined query opens the View modal as. */
|
||||
panelType: PANEL_TYPES;
|
||||
/** Opens the View modal on the refined query, persisting it in the URL. */
|
||||
openViewWithQuery: (
|
||||
panelId: string,
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
) => void;
|
||||
/** Close the popover after opening the View modal. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface UseDrilldownFilterApi {
|
||||
/** The filter-by-value operator menu for a group-column click; `null` otherwise. */
|
||||
items: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The group-column "filter by value" drill-down (V1 parity): adds `key <op> value` to the
|
||||
* panel's query and opens the refined result in the View modal. Reuses V1's read-only
|
||||
* `getGroupContextMenuConfig` for the operator menu.
|
||||
*/
|
||||
export function useDrilldownFilter({
|
||||
context,
|
||||
v1Query,
|
||||
panelId,
|
||||
panelType,
|
||||
openViewWithQuery,
|
||||
onClose,
|
||||
}: UseDrilldownFilterArgs): UseDrilldownFilterApi {
|
||||
const handleFilterDrilldown = useCallback(
|
||||
(operator: string): void => {
|
||||
if (!context?.clickedKey) {
|
||||
return;
|
||||
}
|
||||
let filterValue: string | number = context.clickedValue ?? '';
|
||||
const baseMeta = getBaseMeta(v1Query, context.clickedKey);
|
||||
if (baseMeta && isNumberDataType(baseMeta.dataType) && filterValue !== '') {
|
||||
filterValue = Number(filterValue);
|
||||
}
|
||||
const refinedQuery = addFilterToQuery(v1Query, [
|
||||
{ filterKey: context.clickedKey, filterValue, operator },
|
||||
]);
|
||||
openViewWithQuery(panelId, refinedQuery, panelType);
|
||||
onClose();
|
||||
},
|
||||
[context, v1Query, panelType, panelId, openViewWithQuery, onClose],
|
||||
);
|
||||
|
||||
const items = useMemo<ReactNode>(() => {
|
||||
if (!context || context.columnKind !== 'group' || !context.clickedKey) {
|
||||
return null;
|
||||
}
|
||||
const clickedData = {
|
||||
column: { dataIndex: context.clickedKey, title: context.clickedKey },
|
||||
record: {},
|
||||
} as unknown as ClickedData;
|
||||
return (
|
||||
getGroupContextMenuConfig({
|
||||
query: v1Query,
|
||||
clickedData,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
onColumnClick: handleFilterDrilldown,
|
||||
}).items ?? null
|
||||
);
|
||||
}, [context, v1Query, handleFilterDrilldown]);
|
||||
|
||||
return { items };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useReplaceVariables } from 'api/generated/services/querier';
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
interface UseResolvedDrilldownQueryArgs {
|
||||
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
panelType: PANEL_TYPES;
|
||||
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
|
||||
v1Query: Query;
|
||||
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseResolvedDrilldownQueryResult {
|
||||
/** The variable-substituted query for View-in-X navigation. */
|
||||
resolvedQuery: Query;
|
||||
/** True while the round-trip is in flight — View-in-X shows a spinner and is disabled. */
|
||||
isResolving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel's dashboard-variable references (`$var`) into concrete values before
|
||||
* View in Logs/Traces builds the explorer URL — V1 parity (`useBaseAggregateOptions` runs the
|
||||
* same `/substitute_vars` round-trip). Skipped when the dashboard has no selections: the raw
|
||||
* query already carries the refs verbatim and the round-trip would be a no-op. Mirrors the
|
||||
* V2-native path in {@link useCreateAlertFromPanel}.
|
||||
*/
|
||||
export function useResolvedDrilldownQuery({
|
||||
queries,
|
||||
panelType,
|
||||
v1Query,
|
||||
enabled,
|
||||
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { mutate: substituteVars, data, isLoading } = useReplaceVariables();
|
||||
|
||||
const hasVariables = Object.keys(variables).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !hasVariables) {
|
||||
return;
|
||||
}
|
||||
// Redux global time is nanoseconds; the request DTO takes integer epoch ms — the
|
||||
// backend rejects fractional bounds, so floor after the ns→ms divide.
|
||||
substituteVars({
|
||||
data: buildQueryRangeRequest({
|
||||
queries,
|
||||
panelType,
|
||||
startMs: Math.floor(minTime / 1e6),
|
||||
endMs: Math.floor(maxTime / 1e6),
|
||||
variables,
|
||||
}),
|
||||
});
|
||||
}, [
|
||||
enabled,
|
||||
hasVariables,
|
||||
queries,
|
||||
panelType,
|
||||
minTime,
|
||||
maxTime,
|
||||
variables,
|
||||
substituteVars,
|
||||
]);
|
||||
|
||||
const resolvedQuery = useMemo(() => {
|
||||
if (!hasVariables || !data) {
|
||||
return v1Query;
|
||||
}
|
||||
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
|
||||
}, [hasVariables, data, v1Query, panelType]);
|
||||
|
||||
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
|
||||
openView: (panelId: string) => void;
|
||||
/**
|
||||
* Open the View modal pre-seeded with a drilldown query + kind, persisted in the URL so it
|
||||
* survives refresh (V1 parity); the modal hydrates its draft from these on mount.
|
||||
*/
|
||||
openViewWithQuery: (
|
||||
panelId: string,
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
) => void;
|
||||
/** Close the View modal by clearing its URL params. */
|
||||
closeView: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the panel View modal off the URL (V1 parity): `expandedWidgetId` holds the open
|
||||
* panel, and a drilldown additionally seeds `compositeQuery` + `graphType`. URL-backed state
|
||||
* is shareable, survives refresh, and the browser back-button closes it.
|
||||
*/
|
||||
export function useViewPanel(): UseViewPanelApi {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
|
||||
|
||||
const openView = useCallback(
|
||||
(panelId: string): void => {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
// Drop any leftover in-modal query/kind so a plain View opens on the saved
|
||||
// panel, not a stale URL query the modal would otherwise hydrate from.
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const openViewWithQuery = useCallback(
|
||||
(panelId: string, query: Query, panelType: PANEL_TYPES): void => {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
next.set(QueryParams.graphType, panelType);
|
||||
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
|
||||
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
|
||||
next.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const closeView = useCallback((): void => {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.delete(QueryParams.expandedWidgetId);
|
||||
// Drop the drilldown editor's URL state so it doesn't leak to the dashboard
|
||||
// (the in-modal query builder writes compositeQuery, V1 parity).
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
const search = next.toString();
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
return { expandedPanelId, openView, openViewWithQuery, closeView };
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { layoutsToSections } from '../utils';
|
||||
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
|
||||
import { useViewPanel } from './Panel/hooks/useViewPanel';
|
||||
import ViewPanelModal from './Panel/ViewPanelModal/ViewPanelModal';
|
||||
import Section from './Section/Section/Section';
|
||||
import SectionList from './Section/SectionList';
|
||||
import styles from './PanelsAndSectionsLayout.module.scss';
|
||||
@@ -26,6 +28,14 @@ function PanelsAndSectionsLayout({
|
||||
}: PanelsAndSectionsLayoutProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
|
||||
// Single View-modal host for the whole dashboard, driven by the URL
|
||||
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
|
||||
// subscriptions, and the expanded panel is looked up by id from the map. A
|
||||
// drilldown refinement rides in the URL (`compositeQuery`/`graphType`) and is
|
||||
// hydrated inside the modal, so the host just hands it the saved panel.
|
||||
const { expandedPanelId, closeView } = useViewPanel();
|
||||
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(layouts, panels),
|
||||
[layouts, panels],
|
||||
@@ -56,7 +66,17 @@ function PanelsAndSectionsLayout({
|
||||
));
|
||||
};
|
||||
|
||||
return <div className={styles.body}>{renderContent()}</div>;
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
{renderContent()}
|
||||
<ViewPanelModal
|
||||
open={!!expandedPanel}
|
||||
panel={expandedPanel}
|
||||
panelId={expandedPanelId ?? undefined}
|
||||
onClose={closeView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelsAndSectionsLayout;
|
||||
|
||||
@@ -3,21 +3,28 @@ import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id.
|
||||
* caller can open the editor with just the panel id. The optional `state` is passed as router
|
||||
* location state — the View modal uses it to hand off its drilldown-edited spec so the editor
|
||||
* opens on those edits rather than the saved panel.
|
||||
*/
|
||||
export function useOpenPanelEditor(): (panelId: string) => void {
|
||||
export function useOpenPanelEditor(): (
|
||||
panelId: string,
|
||||
state?: PanelEditorHandoffState,
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string): void => {
|
||||
(panelId: string, state?: PanelEditorHandoffState): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
state ? { state } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
|
||||
import {
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
@@ -32,9 +33,13 @@ function PanelEditorPage(): JSX.Element {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}>();
|
||||
const { search } = useLocation();
|
||||
const { search, state } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
// Edits handed off from the View modal's drilldown — open the editor on these
|
||||
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
|
||||
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
|
||||
|
||||
const { data, isLoading, isError, error } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
@@ -44,17 +49,20 @@ function PanelEditorPage(): JSX.Element {
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
const existingPanel = dashboard?.spec.panels[panelId];
|
||||
const panel = useMemo(
|
||||
() =>
|
||||
newKind
|
||||
? createDefaultPanel(
|
||||
newKind,
|
||||
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
|
||||
buildDefaultQueries(newKind),
|
||||
)
|
||||
: existingPanel,
|
||||
[newKind, existingPanel],
|
||||
);
|
||||
const panel = useMemo(() => {
|
||||
if (newKind) {
|
||||
return createDefaultPanel(
|
||||
newKind,
|
||||
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
|
||||
buildDefaultQueries(newKind),
|
||||
);
|
||||
}
|
||||
if (!existingPanel) {
|
||||
return undefined;
|
||||
}
|
||||
// Open on the modal's drilldown edits when handed off; else the saved panel.
|
||||
return handoffSpec ? { ...existingPanel, spec: handoffSpec } : existingPanel;
|
||||
}, [newKind, existingPanel, handoffSpec]);
|
||||
|
||||
// Target section for a newly-created panel (set by the "Add panel" trigger).
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
Reference in New Issue
Block a user