mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 03:40:43 +01:00
Compare commits
29 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1622868b0d | ||
|
|
25e91f1dcc | ||
|
|
6a879e80bc | ||
|
|
fa8c035d75 | ||
|
|
fa2d0387d2 | ||
|
|
41615397c8 | ||
|
|
d7ea516df5 | ||
|
|
204c713236 | ||
|
|
522c42de53 | ||
|
|
af450bf330 | ||
|
|
9a2b3c7daf | ||
|
|
d8f91a6c7c | ||
|
|
a5694ae179 | ||
|
|
a8fb48fd72 | ||
|
|
c8c424c788 | ||
|
|
833aeda808 | ||
|
|
898209b5e5 | ||
|
|
9a97b3a623 | ||
|
|
672524adcc | ||
|
|
f4c3fedb03 | ||
|
|
caf75b097f | ||
|
|
5f649e0d9e | ||
|
|
c2f347d3f4 | ||
|
|
f3eda0956a | ||
|
|
d31255e717 | ||
|
|
34c90b1289 | ||
|
|
4bb32a69e5 | ||
|
|
8eb299e8fa | ||
|
|
5f39cd0038 |
@@ -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">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
|
||||
&--legend-right {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SquareArrowOutUpRight } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import styles from './ConfigActions.module.scss';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
interface ConfigActionRowProps {
|
||||
/** Leading glyph for the action. */
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the config pane's "Actions" list — a cross-page navigation link
|
||||
* (leading icon, label, trailing external-link affordance). The whole row is the
|
||||
* click target.
|
||||
*/
|
||||
function ConfigActionRow({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
}: ConfigActionRowProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.row}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
prefix={<span className={styles.icon}>{icon}</span>}
|
||||
suffix={<SquareArrowOutUpRight size={14} />}
|
||||
>
|
||||
<Typography.Text className={styles.label}>{label}</Typography.Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigActionRow;
|
||||
@@ -0,0 +1,57 @@
|
||||
/* The "Actions" group: a list of cross-page navigation links, visually separated
|
||||
from the collapsible config sections above by the same hairline divider. */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin: 0 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* A navigation-link row: leading icon, label, trailing external-link affordance. */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 6%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Bell } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
|
||||
|
||||
import ConfigActionRow from './ConfigActionRow';
|
||||
import styles from './ConfigActions.module.scss';
|
||||
|
||||
interface ConfigActionsProps {
|
||||
/** The draft panel — its current query seeds the actions (e.g. Create alert rule). */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Actions" group at the foot of the config pane: cross-page navigation links,
|
||||
* kept distinct from the collapsible config sections above (config you edit in place
|
||||
* vs. links that take you elsewhere). Each link is gated by the panel kind's
|
||||
* capabilities; the whole group hides when none apply. Extend by adding rows here.
|
||||
*/
|
||||
function ConfigActions({
|
||||
panel,
|
||||
panelId,
|
||||
}: ConfigActionsProps): JSX.Element | null {
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
// Only kinds whose query can seed an alert offer this today; mirror the panel
|
||||
// menu's create-alert capability.
|
||||
if (!actions.createAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.container}>
|
||||
<span className={styles.eyebrow}>Actions</span>
|
||||
<div className={styles.list}>
|
||||
<ConfigActionRow
|
||||
testId="panel-editor-v2-create-alert"
|
||||
icon={<Bell size={14} />}
|
||||
label="Create alert"
|
||||
onClick={(): void => createAlert(panel, panelId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigActions;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigActions from '../ConfigActions';
|
||||
|
||||
const mockCreateAlert = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
|
||||
() => ({
|
||||
useCreateAlertFromPanel: jest.fn(() => mockCreateAlert),
|
||||
}),
|
||||
);
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('ConfigActions', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('offers "Create alert rule" for a create-alert-capable kind and seeds from the panel', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
render(<ConfigActions panel={panel} panelId="panel-1" />);
|
||||
|
||||
const row = screen.getByTestId('panel-editor-v2-create-alert');
|
||||
expect(row).toHaveTextContent('Create alert rule');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(panel, 'panel-1');
|
||||
});
|
||||
|
||||
it('renders nothing for a kind that cannot seed an alert', () => {
|
||||
const { container } = render(
|
||||
<ConfigActions panel={makePanel('signoz/TablePanel')} panelId="panel-1" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-create-alert'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,46 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
import { PanelKind } from '../../Panels/types/panelKind';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
|
||||
panelKind: PanelKind;
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Switch the panel to another visualization kind. */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/**
|
||||
* Active query type from the query-builder provider (the selected tab). Drives which
|
||||
* panel types the visualization switcher disables — read from the provider, not the
|
||||
* spec, because a new panel's spec has no query until staged.
|
||||
*/
|
||||
queryType?: EQueryType;
|
||||
/** Panel's resolved series, provided to sections that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
|
||||
stepInterval?: number;
|
||||
/**
|
||||
* The draft panel and its id — supplied by the editor so the "Actions" group can
|
||||
* seed cross-page links (Create alert rule) from the current query. Absent (e.g.
|
||||
* in isolation tests) hides that group.
|
||||
*/
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,18 +50,21 @@ interface ConfigPaneProps {
|
||||
* generically via the section registry — only sections with a built editor appear.
|
||||
*/
|
||||
function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
stepInterval,
|
||||
panel,
|
||||
panelId,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const panelKind = spec.plugin.kind;
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
|
||||
const signal = getBuilderQueries(spec.queries || [])[0]?.signal as
|
||||
| TelemetrytypesSignalDTO
|
||||
| undefined;
|
||||
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
|
||||
|
||||
// Title/description are just a slice of the spec — edit them through the same
|
||||
// onChangeSpec path the sections use, so there's a single editing surface.
|
||||
@@ -95,12 +115,18 @@ function ConfigPane({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfigActions panel={panel} panelId={panelId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
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 ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). Defaults to Query Builder. */
|
||||
queryType?: EQueryType;
|
||||
/** Panel's current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section). A type is
|
||||
* disabled when the active query type or datasource is incompatible with it — resolved
|
||||
* through the capabilities guard. The datasource is unknown for PromQL/ClickHouse, but
|
||||
* those query types still disable kinds that only support Query Builder (e.g. List).
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
queryType,
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value as PanelKind)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSwitcher;
|
||||
@@ -0,0 +1,122 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
|
||||
// Query-type support per kind: List is Query-Builder-only; Table/Pie drop PromQL.
|
||||
const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
|
||||
'signoz/ListPanel': [EQueryType.QUERY_BUILDER],
|
||||
'signoz/TablePanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
};
|
||||
|
||||
function disabledLabels(): (string | null)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current datasource', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('does not disable any type when the datasource is unknown (builder, no signal)', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disables Query-Builder-only kinds under PromQL even without a datasource', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.PROM}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
// List/Table/Pie can't be authored in PromQL; Time Series can.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).toContain('Table');
|
||||
expect(disabledLabels()).toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TablePanel"
|
||||
queryType={EQueryType.CLICKHOUSE}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Table');
|
||||
expect(disabledLabels()).not.toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelTypeDisabledReason } from '../utils';
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
const { logs, metrics } = TelemetrytypesSignalDTO;
|
||||
|
||||
describe('getPanelTypeDisabledReason', () => {
|
||||
it('returns undefined for a supported combination', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
queryType: PROM,
|
||||
label: 'Time Series',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: logs,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('explains an unsupported query type', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: PROM,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List isn't available for PromQL queries");
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: CLICKHOUSE,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List isn't available for ClickHouse queries");
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/TablePanel',
|
||||
queryType: PROM,
|
||||
label: 'Table',
|
||||
}),
|
||||
).toBe("Table isn't available for PromQL queries");
|
||||
});
|
||||
|
||||
it('explains an unsupported datasource', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: metrics,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List doesn't support metrics data");
|
||||
});
|
||||
|
||||
it('prefers the query-type reason when both are incompatible', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: PROM,
|
||||
signal: metrics,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List isn't available for PromQL queries");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
isQueryTypeSupported,
|
||||
isSignalSupported,
|
||||
} from '../../../Panels/capabilities';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
|
||||
[EQueryType.QUERY_BUILDER]: 'Query Builder',
|
||||
[EQueryType.CLICKHOUSE]: 'ClickHouse',
|
||||
[EQueryType.PROM]: 'PromQL',
|
||||
};
|
||||
|
||||
const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
|
||||
[TelemetrytypesSignalDTO.logs]: 'logs',
|
||||
[TelemetrytypesSignalDTO.traces]: 'traces',
|
||||
[TelemetrytypesSignalDTO.metrics]: 'metrics',
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a panel kind can't be selected for the current query type / datasource, or
|
||||
* `undefined` when it can. Drives both the type switcher's disabled state and its
|
||||
* tooltip, so the two never disagree. The query-type reason takes precedence (it's the
|
||||
* outer choice): query types have no datasource, so the signal only matters in builder.
|
||||
*/
|
||||
export function getPanelTypeDisabledReason({
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
}: {
|
||||
kind: PanelKind;
|
||||
queryType: EQueryType;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
label: string;
|
||||
}): string | undefined {
|
||||
if (!isQueryTypeSupported(kind, queryType)) {
|
||||
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
|
||||
}
|
||||
if (signal !== undefined && !isSignalSupported(kind, signal)) {
|
||||
return `${label} doesn't support ${SIGNAL_LABEL[signal]} data`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
SECTION_METADATA,
|
||||
type SectionConfig,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
@@ -23,6 +25,13 @@ interface SectionSlotProps {
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Current panel kind + switch handler, for the visualization section's type switcher. */
|
||||
panelKind: PanelKind;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Active query type, for the type switcher's disabled rule (Query-Builder-only kinds). */
|
||||
queryType?: EQueryType;
|
||||
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
|
||||
stepInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +47,10 @@ function SectionSlot({
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
// A kind can hide a section based on current spec state (e.g. Histogram legend once
|
||||
// queries are merged) — skip it before resolving the editor.
|
||||
@@ -60,7 +73,12 @@ function SectionSlot({
|
||||
.formatting?.unit;
|
||||
|
||||
return (
|
||||
<SettingsSection title={title} icon={<Icon size={15} />}>
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
// Open Visualization by default so the type switcher is visible.
|
||||
defaultOpen={config.kind === 'visualization'}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
@@ -69,6 +87,10 @@ function SectionSlot({
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -26,13 +26,15 @@ function SettingsSection({
|
||||
}: SettingsSectionProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={`config-section-${title}`}
|
||||
data-testid={`config-section-${serializedTitle}`}
|
||||
onClick={(): void => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{icon && (
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigPane from '../ConfigPane';
|
||||
|
||||
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
|
||||
() => ({
|
||||
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
function spec(unit?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'CPU', description: 'usage' },
|
||||
@@ -18,11 +29,13 @@ function renderConfigPane(
|
||||
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
|
||||
): React.ComponentProps<typeof ConfigPane> {
|
||||
const props: React.ComponentProps<typeof ConfigPane> = {
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,
|
||||
panelId: 'panel-1',
|
||||
...overrides,
|
||||
};
|
||||
render(<ConfigPane {...props} />);
|
||||
@@ -56,6 +69,32 @@ describe('ConfigPane', () => {
|
||||
it('renders the Formatting section for a kind that declares it', () => {
|
||||
renderConfigPane();
|
||||
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
|
||||
expect(screen.getByTestId('config-section-Formatting')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('config-section-formatting-&-units'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Actions group for a create-alert-capable panel', () => {
|
||||
// renderConfigPane defaults to a TimeSeries panel, which can seed an alert.
|
||||
renderConfigPane();
|
||||
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-create-alert'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the create-alert action for a kind that cannot seed an alert', () => {
|
||||
// Table panels can't seed alerts → the Actions group hides its row. Only the
|
||||
// panel passed to ConfigActions needs the kind; sections are asserted elsewhere.
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: { ...spec(), plugin: { kind: 'signoz/TablePanel', spec: {} } },
|
||||
} as DashboardtypesPanelDTO;
|
||||
renderConfigPane({ panel });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-create-alert'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.group {
|
||||
width: min(350px, 100%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
|
||||
@@ -8,3 +8,11 @@
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
// Wraps a tooltip-bearing option so the hover target fills the row and still receives
|
||||
// pointer events when the option is disabled (antd dims it but doesn't block events).
|
||||
.tooltipTrigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Select } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
|
||||
@@ -7,7 +8,11 @@ import styles from './ConfigSelect.module.scss';
|
||||
export interface ConfigSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
/** A `SegmentIconName` string (resolved to a glyph), or an arbitrary icon node. */
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Hover hint shown on the option — typically the reason a disabled item is disabled. */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
interface ConfigSelectProps {
|
||||
@@ -38,17 +43,31 @@ function ConfigSelect({
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
virtual={false}
|
||||
options={items.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.icon ? (
|
||||
options={items.map((item) => {
|
||||
const content = item.icon ? (
|
||||
<span className={styles.item}>
|
||||
<SegmentIcon name={item.icon} />
|
||||
{typeof item.icon === 'string' ? (
|
||||
<SegmentIcon name={item.icon as SegmentIconName} />
|
||||
) : (
|
||||
item.icon
|
||||
)}
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
),
|
||||
}))}
|
||||
);
|
||||
return {
|
||||
value: item.value,
|
||||
disabled: item.disabled,
|
||||
label: item.tooltip ? (
|
||||
<Tooltip title={item.tooltip} placement="top">
|
||||
<span className={styles.tooltipTrigger}>{content}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
content
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ export interface ErasedSectionDescriptor {
|
||||
// The panel's telemetry signal; read by editors that fetch field-key
|
||||
// suggestions scoped to it (List column picker).
|
||||
signal?: unknown;
|
||||
// Current panel kind + switch handler; read by the visualization section's
|
||||
// type switcher.
|
||||
panelKind?: unknown;
|
||||
onChangePanelKind?: unknown;
|
||||
// Active query type; read by the visualization section's type switcher to
|
||||
// disable kinds that can't be authored in it (List is Query-Builder-only).
|
||||
queryType?: unknown;
|
||||
// Query step interval (seconds); read by chart appearance to floor the
|
||||
// span-gaps threshold.
|
||||
stepInterval?: unknown;
|
||||
}>;
|
||||
get: (spec: PanelSpec) => unknown;
|
||||
update: (spec: PanelSpec, value: unknown) => PanelSpec;
|
||||
|
||||
@@ -3,3 +3,14 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thresholdField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thresholdPrefix {
|
||||
padding-right: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
@@ -11,6 +9,7 @@ import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContaine
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import DisconnectValuesField from './DisconnectValuesField';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
@@ -77,16 +76,11 @@ function ChartAppearanceSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'chartAppearance'>): JSX.Element {
|
||||
// `spanGaps.fillLessThan` is a stringified seconds threshold: empty means "connect
|
||||
// every gap" (the chart default), a number means "only bridge gaps shorter than this".
|
||||
const handleSpanGaps = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = e.target.value;
|
||||
onChange({
|
||||
...value,
|
||||
spanGaps: raw === '' ? undefined : { ...value?.spanGaps, fillLessThan: raw },
|
||||
});
|
||||
};
|
||||
stepInterval,
|
||||
}: SectionEditorProps<'chartAppearance'> & {
|
||||
/** Query step interval (seconds) for the span-gaps threshold floor. */
|
||||
stepInterval?: number;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.lineStyle && (
|
||||
@@ -146,16 +140,12 @@ function ChartAppearanceSection({
|
||||
)}
|
||||
|
||||
{controls.spanGaps && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Connect gaps shorter than (s)</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-span-gaps"
|
||||
type="number"
|
||||
placeholder="All gaps"
|
||||
value={value?.spanGaps?.fillLessThan ?? ''}
|
||||
onChange={handleSpanGaps}
|
||||
/>
|
||||
</div>
|
||||
<DisconnectValuesField
|
||||
testId="panel-editor-v2-span-gaps"
|
||||
value={value?.spanGaps}
|
||||
stepInterval={stepInterval}
|
||||
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesSpanGapsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
const DEFAULT_THRESHOLD = '1m';
|
||||
const MODE_NEVER = 'never';
|
||||
const MODE_THRESHOLD = 'threshold';
|
||||
const MODE_OPTIONS = [
|
||||
{ value: MODE_NEVER, label: 'Never' },
|
||||
{ value: MODE_THRESHOLD, label: 'Threshold' },
|
||||
];
|
||||
|
||||
interface DisconnectValuesFieldProps {
|
||||
testId: string;
|
||||
value: DashboardtypesSpanGapsDTO | undefined;
|
||||
/** Query step interval (seconds): seeds the default threshold and floors it. */
|
||||
stepInterval?: number;
|
||||
onChange: (next: DashboardtypesSpanGapsDTO | undefined) => void;
|
||||
}
|
||||
|
||||
/** Default threshold duration: the step interval (smallest meaningful), else 1m. */
|
||||
function defaultDuration(stepInterval?: number): string {
|
||||
return stepInterval && stepInterval > 0
|
||||
? rangeUtil.secondsToHms(stepInterval)
|
||||
: DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Disconnect values": Never (span every gap — the chart default) vs Threshold
|
||||
* (only bridge gaps shorter than a duration). The threshold persists as a
|
||||
* duration string in `spanGaps.fillLessThan` ("10m", "5s") — the wire format the
|
||||
* backend expects.
|
||||
*/
|
||||
function DisconnectValuesField({
|
||||
testId,
|
||||
value,
|
||||
stepInterval,
|
||||
onChange,
|
||||
}: DisconnectValuesFieldProps): JSX.Element {
|
||||
const duration = value?.fillLessThan || undefined;
|
||||
const isThreshold = !!duration;
|
||||
// Remember the last threshold so toggling Never → Threshold restores it.
|
||||
const [lastDuration, setLastDuration] = useState(
|
||||
duration ?? defaultDuration(stepInterval),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration) {
|
||||
setLastDuration(duration);
|
||||
}
|
||||
}, [duration]);
|
||||
|
||||
const handleMode = (mode: string): void => {
|
||||
onChange(
|
||||
mode === MODE_THRESHOLD
|
||||
? { ...value, fillLessThan: lastDuration }
|
||||
: undefined,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Disconnect values</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId={testId}
|
||||
value={isThreshold ? MODE_THRESHOLD : MODE_NEVER}
|
||||
items={MODE_OPTIONS}
|
||||
onChange={handleMode}
|
||||
/>
|
||||
</div>
|
||||
{isThreshold && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Threshold value</Typography.Text>
|
||||
<DisconnectValuesThresholdInput
|
||||
testId={`${testId}-value`}
|
||||
value={lastDuration}
|
||||
minValue={stepInterval}
|
||||
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisconnectValuesField;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { type ChangeEvent, useEffect, useState } from 'react';
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
interface DisconnectValuesThresholdInputProps {
|
||||
testId: string;
|
||||
/** Current threshold as a duration string (e.g. "1m") — the stored wire value. */
|
||||
value: string;
|
||||
/** Smallest allowed threshold (the query step interval), in seconds. */
|
||||
minValue?: number;
|
||||
onChange: (duration: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration input for the span-gaps threshold: shows/accepts and reports a human
|
||||
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
|
||||
* `fillLessThan` (a bare number is read as seconds). It is only parsed to seconds
|
||||
* to validate against the query step interval. Invalid entries, or values below
|
||||
* that floor, surface an inline error and are not committed (V1 parity).
|
||||
*/
|
||||
function DisconnectValuesThresholdInput({
|
||||
testId,
|
||||
value,
|
||||
minValue,
|
||||
onChange,
|
||||
}: DisconnectValuesThresholdInputProps): JSX.Element {
|
||||
const [text, setText] = useState(value);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Resync the displayed duration when the committed value changes upstream.
|
||||
useEffect(() => {
|
||||
setText(value);
|
||||
setError(null);
|
||||
}, [value]);
|
||||
|
||||
const commit = (raw: string): void => {
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
let seconds: number;
|
||||
try {
|
||||
seconds = rangeUtil.isValidTimeSpan(raw)
|
||||
? rangeUtil.intervalToSeconds(raw)
|
||||
: NaN;
|
||||
} catch {
|
||||
seconds = NaN;
|
||||
}
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
setError('Enter a valid duration (e.g. 30s, 1m, 1h)');
|
||||
return;
|
||||
}
|
||||
if (minValue !== undefined && seconds < minValue) {
|
||||
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
// Store the user's duration string as-is — the wire format the backend wants.
|
||||
onChange(raw);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.thresholdField}>
|
||||
<Input
|
||||
data-testid={testId}
|
||||
type="text"
|
||||
status={error ? 'error' : undefined}
|
||||
prefix={<span className={styles.thresholdPrefix}>></span>}
|
||||
value={text}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setText(e.target.value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onBlur={(e): void => commit(e.currentTarget.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
commit(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<Callout type="error" size="small" showIcon>
|
||||
{error}
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisconnectValuesThresholdInput;
|
||||
@@ -108,9 +108,24 @@ describe('ChartAppearanceSection', () => {
|
||||
expect(onChange).toHaveBeenCalledWith({ showPoints: true });
|
||||
});
|
||||
|
||||
it('writes a span-gaps threshold and clears it when emptied', () => {
|
||||
it('defaults to "Never" (no threshold) and hides the threshold input', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switching to "Threshold" seeds the default 1m threshold', () => {
|
||||
const onChange = jest.fn();
|
||||
const { rerender } = render(
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ spanGaps: true }}
|
||||
@@ -118,23 +133,103 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
|
||||
target: { value: '60' },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '60' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Threshold'));
|
||||
|
||||
rerender(
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '1m' },
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the threshold as a duration string (not seconds)', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '60' } }}
|
||||
value={{ spanGaps: { fillLessThan: '1m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
|
||||
target: { value: '' },
|
||||
|
||||
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
|
||||
expect(input).toHaveValue('1m');
|
||||
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '5m' },
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the entry verbatim (bare number kept as typed, not converted)', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '1m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
|
||||
fireEvent.change(input, { target: { value: '300' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '300' },
|
||||
});
|
||||
});
|
||||
|
||||
it('switching back to "Never" clears the threshold', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '1m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Never'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
|
||||
});
|
||||
|
||||
it('shows an error and does not commit an invalid duration', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '1m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
|
||||
fireEvent.change(input, { target: { value: 'abc' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a threshold below the query step interval', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '2m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
stepInterval={120}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
|
||||
// 1m (60s) is below the 2m (120s) step interval.
|
||||
fireEvent.change(input, { target: { value: '1m' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,25 @@ describe('ComparisonThresholdsSection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets the value input be cleared instead of snapping back to 0', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ComparisonThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
|
||||
const valueInput = screen.getByTestId('comparison-threshold-value-0');
|
||||
|
||||
// Regression: clearing used to coerce "" → 0 and refill the field, so the
|
||||
// seeded value could never be removed.
|
||||
await user.clear(valueInput);
|
||||
expect(valueInput).toHaveValue(null);
|
||||
|
||||
// And a fresh value can be typed into the now-empty field.
|
||||
await user.type(valueInput, '5');
|
||||
expect(valueInput).toHaveValue(5);
|
||||
});
|
||||
|
||||
it('does not commit edits when Discard is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
|
||||
@@ -16,6 +17,12 @@ function ThresholdValueField({
|
||||
value,
|
||||
onChange,
|
||||
}: ThresholdValueFieldProps): JSX.Element {
|
||||
const [raw, setRaw] = useState(String(value));
|
||||
|
||||
useEffect(() => {
|
||||
setRaw((prev) => (Number(prev) === value ? prev : String(value)));
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
|
||||
@@ -23,8 +30,11 @@ function ThresholdValueField({
|
||||
data-testid={testId}
|
||||
type="number"
|
||||
placeholder="Value"
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
value={raw}
|
||||
onChange={(e): void => {
|
||||
setRaw(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,55 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../../Panels/types/panelKind';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
|
||||
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
|
||||
|
||||
import styles from './VisualizationSection.module.scss';
|
||||
|
||||
type VisualizationSectionProps = SectionEditorProps<'visualization'> & {
|
||||
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
/** Active query type, forwarded by SectionSlot — scopes the switcher's disabled types. */
|
||||
queryType?: EQueryType;
|
||||
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
|
||||
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
|
||||
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
|
||||
* writes — the visualization fields its spec actually supports.
|
||||
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
|
||||
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
|
||||
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
|
||||
* flag, so a kind only renders — and only writes — the fields its spec supports.
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'visualization'>): JSX.Element {
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.switchPanelKind && panelKind && onChangePanelKind && (
|
||||
<PanelTypeSwitcher
|
||||
panelKind={panelKind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.timePreference && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel time preference</Typography.Text>
|
||||
|
||||
@@ -4,6 +4,15 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
|
||||
|
||||
import VisualizationSection from '../VisualizationSection';
|
||||
|
||||
// The type switcher resolves each kind's supported signals + query types; stub it so
|
||||
// the test doesn't pull the whole panel registry (renderers, chart libs).
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(() => ({
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
|
||||
})),
|
||||
}));
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label.
|
||||
async function pickOption(triggerTestId: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
@@ -17,7 +26,12 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true, stacking: true, fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -35,7 +49,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -56,7 +73,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -74,7 +94,10 @@ describe('VisualizationSection', () => {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
stackedBarChart: false,
|
||||
}}
|
||||
controls={{ stacking: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
stacking: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -92,7 +115,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: false }}
|
||||
controls={{ fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -101,4 +127,43 @@ describe('VisualizationSection', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
|
||||
});
|
||||
|
||||
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
|
||||
const onChangePanelKind = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ switchPanelKind: true }}
|
||||
onChange={jest.fn()}
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-type-switcher'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await pickOption('panel-editor-v2-type-switcher', 'Table');
|
||||
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
|
||||
});
|
||||
|
||||
it('hides the type switcher when switchPanelKind is not set', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{
|
||||
switchPanelKind: false,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-switcher'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,23 +8,35 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
|
||||
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
panelType: PANEL_TYPES;
|
||||
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
|
||||
panelKind: PanelKind;
|
||||
/** The panel's current datasource; selects per-signal query-builder field rules. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
@@ -41,12 +53,15 @@ interface PanelEditorQueryBuilderProps {
|
||||
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelType,
|
||||
panelKind,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
footer,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -74,13 +89,16 @@ function PanelEditorQueryBuilder({
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
// Query-builder field visibility for this kind + signal (e.g. List hides step
|
||||
// interval / having). QueryBuilderV2 ignores it for list view (its internal config
|
||||
// wins), but the guard stays the single declared source — see ListPanel definition.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
|
||||
[],
|
||||
() => getHiddenQueryBuilderFields(panelKind, signal),
|
||||
[panelKind, signal],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
|
||||
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
@@ -127,7 +145,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelType, filterConfigs, isDarkMode]);
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
|
||||
|
||||
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
|
||||
const mockQueryBuilderV2 = jest.fn();
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
||||
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
|
||||
QueryBuilderV2: (props: unknown): null => {
|
||||
mockQueryBuilderV2(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
jest.mock(
|
||||
'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse',
|
||||
() => ({ __esModule: true, default: (): null => null }),
|
||||
);
|
||||
jest.mock(
|
||||
'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL',
|
||||
() => ({ __esModule: true, default: (): null => null }),
|
||||
);
|
||||
jest.mock('container/QueryBuilder/components/RunQueryBtn/RunQueryBtn', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
jest.mock('components/TextToolTip', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
jest.mock('assets/Dashboard/PromQl', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
|
||||
function renderBuilder(
|
||||
panelKind: string,
|
||||
signal?: TelemetrytypesSignalDTO,
|
||||
): void {
|
||||
render(
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind as never}
|
||||
signal={signal}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
onCancelQuery={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function lastQueryBuilderProps(): {
|
||||
panelType: string;
|
||||
isListViewPanel: boolean;
|
||||
filterConfigs: unknown;
|
||||
} {
|
||||
const calls = mockQueryBuilderV2.mock.calls;
|
||||
return calls[calls.length - 1][0];
|
||||
}
|
||||
|
||||
describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities guard)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the Query Builder tab for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Query Builder + ClickHouse but not PromQL for the Table kind', () => {
|
||||
renderBuilder('signoz/TablePanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
|
||||
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all three tabs for the Time Series kind', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelEditorQueryBuilder field visibility (driven by the capabilities guard)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('passes empty field config + non-list flag for a non-list kind', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel', TelemetrytypesSignalDTO.metrics);
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.panelType).toBe('graph');
|
||||
expect(props.isListViewPanel).toBe(false);
|
||||
expect(props.filterConfigs).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('hides step interval / having and sets body-contains for List + logs', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.panelType).toBe('list');
|
||||
expect(props.isListViewPanel).toBe(true);
|
||||
expect(props.filterConfigs).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
|
||||
it('additionally hides limit for List + traces', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.traces);
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.filterConfigs).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,10 +71,8 @@ function PreviewPane({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
<PanelHeader
|
||||
name={panel.spec.display.name}
|
||||
description={panel.spec.display.description}
|
||||
panelId={panelId}
|
||||
panelKind={panel.spec.plugin.kind}
|
||||
panel={panel}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
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 });
|
||||
|
||||
fireEvent.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,98 @@
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import { defaultColumnsForSignal } from '../ListColumnsEditor/selectFields';
|
||||
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
jest.mock('../ListColumnsEditor/selectFields', () => ({
|
||||
defaultColumnsForSignal: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
const mockDefaultColumnsForSignal =
|
||||
defaultColumnsForSignal as unknown as jest.Mock;
|
||||
|
||||
function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TablePanel', spec: pluginSpec },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('getSwitchedPluginSpec', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDefaultColumnsForSignal.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
|
||||
});
|
||||
const old = specWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
|
||||
axes: { logScale: true },
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/TimeSeriesPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
|
||||
// Type-specific config from the old kind is dropped.
|
||||
expect((result as { axes?: unknown }).axes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not carry formatting when the new kind has no formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
const old = specWith({ formatting: { unit: 'ms' } });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.formatting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds List columns from the signal when switching into a List', () => {
|
||||
const columns = [{ name: 'body' }];
|
||||
mockDefaultColumnsForSignal.mockReturnValue(columns);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
specWith({}),
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
expect(result.selectFields).toBe(columns);
|
||||
});
|
||||
|
||||
it('includes the kind section defaults (e.g. legend position)', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'legend', controls: { position: true } }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
specWith({}),
|
||||
'signoz/PieChartPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.legend?.position).toBe('bottom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import {
|
||||
buildDefaultPluginSpec,
|
||||
type DefaultPluginSpec,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
|
||||
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
|
||||
|
||||
/**
|
||||
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
|
||||
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
|
||||
* at the boundary.
|
||||
*/
|
||||
export interface SwitchedPluginSpec extends DefaultPluginSpec {
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
|
||||
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
|
||||
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
|
||||
* List seeds the current signal's default columns so the columns control isn't empty.
|
||||
*
|
||||
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
|
||||
*/
|
||||
export function getSwitchedPluginSpec(
|
||||
oldSpec: DashboardtypesPanelSpecDTO,
|
||||
newKind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): SwitchedPluginSpec {
|
||||
const sections = getPanelDefinition(newKind)?.sections ?? [];
|
||||
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
|
||||
|
||||
if (sections.some((section) => section.kind === 'formatting')) {
|
||||
const oldFormatting = (
|
||||
oldSpec.plugin.spec as {
|
||||
formatting?: PanelFormattingSlice;
|
||||
}
|
||||
).formatting;
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
|
||||
...(oldFormatting?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: oldFormatting.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
if (Object.keys(carried).length > 0) {
|
||||
result.formatting = carried;
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.some((section) => section.kind === 'columns')) {
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
if (columns.length > 0) {
|
||||
result.selectFields = columns;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { handleQueryChange } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../../Panels/capabilities';
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
handleQueryChange: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../Panels/capabilities', () => ({
|
||||
resolveQueryType: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../getSwitchedPluginSpec', () => ({
|
||||
getSwitchedPluginSpec: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
getBuilderQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
|
||||
// Opaque sentinels — the leaf utilities are mocked, so only identity matters.
|
||||
const TABLE_PLUGIN_SPEC = { table: true } as unknown;
|
||||
const TABLE_QUERIES = [{ id: 'table-q' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const LIST_PLUGIN_SPEC = { list: true } as unknown;
|
||||
const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const TRANSFORMED = {
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
} as unknown as Query;
|
||||
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SWITCHED_SPEC = { switched: true } as unknown;
|
||||
|
||||
function makeSpec(
|
||||
kind: string,
|
||||
pluginSpec: unknown,
|
||||
queries: NonNullable<DashboardtypesPanelSpecDTO['queries']>,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind, spec: pluginSpec },
|
||||
queries,
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
const tableSpec = makeSpec(
|
||||
'signoz/TablePanel',
|
||||
TABLE_PLUGIN_SPEC,
|
||||
TABLE_QUERIES,
|
||||
);
|
||||
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
|
||||
|
||||
function builderState(currentQuery: Query): {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: jest.Mock;
|
||||
} {
|
||||
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
|
||||
}
|
||||
|
||||
describe('usePanelTypeSwitch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
|
||||
mockToPerses.mockReturnValue(CONVERTED);
|
||||
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
|
||||
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
|
||||
// The guard owns coercion (tested in capabilities.test.ts); here it always
|
||||
// resolves to Query Builder so the coerced type flows into handleQueryChange.
|
||||
mockResolveQueryType.mockReturnValue('builder');
|
||||
});
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(state.redirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('on first visit: transforms the query and resets the spec to the new kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
expect(setSpec).toHaveBeenCalledTimes(1);
|
||||
const next = setSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(next.plugin.kind).toBe('signoz/ListPanel');
|
||||
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
|
||||
expect(next.queries).toBe(CONVERTED);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
|
||||
});
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// The hook asks the guard to resolve the active query type against the new kind…
|
||||
expect(mockResolveQueryType).toHaveBeenCalledWith(
|
||||
'signoz/ListPanel',
|
||||
'promql',
|
||||
);
|
||||
// …and the resolved type ('builder') flows into the query rebuild.
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
|
||||
let state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelTypeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
|
||||
// Leave Table for List (stashes Table in its pristine state).
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// Parent re-renders as a List panel; the builder now holds the List query.
|
||||
state = builderState(listQuery);
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
|
||||
// Switch back to Table → restored from the stash, not re-transformed.
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const restored = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(restored.plugin.kind).toBe('signoz/TablePanel');
|
||||
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
|
||||
expect(restored.queries).toBe(TABLE_QUERIES);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(tableQuery);
|
||||
// The restore path must not run the query transform again.
|
||||
expect(mockHandleQueryChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ export function useLegendSeries(
|
||||
getTimeSeriesResults(data?.response),
|
||||
data.legendMap,
|
||||
);
|
||||
const builderQueries = getBuilderQueries(panel?.spec?.queries || []);
|
||||
const builderQueries = getBuilderQueries(panel.spec.queries || []);
|
||||
|
||||
const byLabel = new Map<string, string>();
|
||||
series.forEach((s) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
handleQueryChange,
|
||||
type PartialPanelTypes,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import {
|
||||
getSwitchedPluginSpec,
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
|
||||
queries: DashboardtypesQueryDTO[] | null;
|
||||
builderQuery: Query;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchArgs {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchApi {
|
||||
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
|
||||
onChangePanelKind: (newKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
|
||||
* renderer, config sections, query-builder tabs and request type for free; this hook adds
|
||||
* the two things that don't: a per-kind session cache that makes switching reversible
|
||||
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
|
||||
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
|
||||
*/
|
||||
export function usePanelTypeSwitch({
|
||||
spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
|
||||
|
||||
// Latest spec/query/type, read inside the stable callback without re-subscribing.
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
const panelTypeRef = useRef(panelType);
|
||||
panelTypeRef.current = panelType;
|
||||
|
||||
const onChangePanelKind = useCallback(
|
||||
(newKind: PanelKind): void => {
|
||||
const currentSpec = specRef.current;
|
||||
const oldKind = currentSpec.plugin.kind as PanelKind;
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
pluginSpec: currentSpec.plugin.spec,
|
||||
queries: currentSpec.queries ?? null,
|
||||
builderQuery: query,
|
||||
});
|
||||
|
||||
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
|
||||
|
||||
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
|
||||
// dynamically-chosen kind can't be correlated with its spec statically (as in
|
||||
// `createDefaultPanel`). The surrounding spec stays fully typed.
|
||||
const buildSpec = (
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
|
||||
queries: DashboardtypesQueryDTO[] | null,
|
||||
): DashboardtypesPanelSpecDTO => ({
|
||||
...currentSpec,
|
||||
plugin: {
|
||||
...currentSpec.plugin,
|
||||
kind: newKind,
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
});
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path).
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → coerce the query type if the new kind disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const queryType = resolveQueryType(newKind, query.queryType);
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
const signal = getBuilderQueries(currentSpec.queries || [])[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(transformed, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(transformed);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return { onChangePanelKind };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -10,14 +10,10 @@ import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
@@ -25,9 +21,7 @@ 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 { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
@@ -64,7 +58,32 @@ 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.
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
@@ -84,34 +103,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,
|
||||
});
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties. A new panel is always savable (you're creating it).
|
||||
@@ -146,6 +138,14 @@ function PanelEditorContainer({
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
|
||||
// Smallest query step interval (seconds) — the floor for the span-gaps
|
||||
// threshold. Undefined until results carry step metadata.
|
||||
const stepInterval = useMemo((): number | undefined => {
|
||||
const intervals = getExecStats(data.response)?.stepIntervals;
|
||||
const values = intervals ? Object.values(intervals) : [];
|
||||
return values.length ? Math.min(...values) : undefined;
|
||||
}, [data.response]);
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
@@ -197,7 +197,8 @@ function PanelEditorContainer({
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelType={panelType}
|
||||
panelKind={fullKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
@@ -223,11 +224,16 @@ function PanelEditorContainer({
|
||||
className={styles.right}
|
||||
>
|
||||
<ConfigPane
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
// panelKind={draft.spec.plugin.kind}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
getSupportedSignals,
|
||||
isPanelCombinationValid,
|
||||
isQueryTypeSupported,
|
||||
isSignalSupported,
|
||||
resolveQueryType,
|
||||
} from '../capabilities';
|
||||
import type { PanelKind } from '../types/panelKind';
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
|
||||
|
||||
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/BarChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/ListPanel': [QUERY_BUILDER],
|
||||
};
|
||||
|
||||
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/TimeSeriesPanel': [metrics, logs, traces],
|
||||
'signoz/BarChartPanel': [metrics, logs, traces],
|
||||
'signoz/NumberPanel': [metrics, logs, traces],
|
||||
'signoz/HistogramPanel': [metrics, logs, traces],
|
||||
'signoz/PieChartPanel': [metrics, logs, traces],
|
||||
'signoz/TablePanel': [metrics, logs, traces],
|
||||
// List renders raw rows; metrics produce no row data.
|
||||
'signoz/ListPanel': [logs, traces],
|
||||
};
|
||||
|
||||
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
|
||||
|
||||
describe('panel capabilities guard', () => {
|
||||
describe('query type support', () => {
|
||||
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
|
||||
expect(getSupportedQueryTypes(kind)).toStrictEqual(
|
||||
EXPECTED_QUERY_TYPES[kind],
|
||||
);
|
||||
});
|
||||
|
||||
it('Table and Pie do not support PromQL', () => {
|
||||
expect(isQueryTypeSupported('signoz/TablePanel', PROM)).toBe(false);
|
||||
expect(isQueryTypeSupported('signoz/PieChartPanel', PROM)).toBe(false);
|
||||
});
|
||||
|
||||
it('List only supports Query Builder', () => {
|
||||
expect(isQueryTypeSupported('signoz/ListPanel', QUERY_BUILDER)).toBe(true);
|
||||
expect(isQueryTypeSupported('signoz/ListPanel', CLICKHOUSE)).toBe(false);
|
||||
expect(isQueryTypeSupported('signoz/ListPanel', PROM)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signal support', () => {
|
||||
it.each(ALL_KINDS)('declares the expected signals for %s', (kind) => {
|
||||
expect(getSupportedSignals(kind)).toStrictEqual(EXPECTED_SIGNALS[kind]);
|
||||
});
|
||||
|
||||
it('List excludes metrics', () => {
|
||||
expect(isSignalSupported('signoz/ListPanel', metrics)).toBe(false);
|
||||
expect(isSignalSupported('signoz/ListPanel', logs)).toBe(true);
|
||||
expect(isSignalSupported('signoz/ListPanel', traces)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPanelCombinationValid', () => {
|
||||
it('accepts a supported triad', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
queryType: PROM,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: logs,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unsupported query type', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: 'signoz/ListPanel', queryType: PROM }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: 'signoz/TablePanel', queryType: PROM }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unsupported signal when one is given', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores signal when none is given (ClickHouse/PromQL have no datasource)', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQueryType', () => {
|
||||
it('keeps a supported query type', () => {
|
||||
expect(resolveQueryType('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
|
||||
expect(resolveQueryType('signoz/ListPanel', QUERY_BUILDER)).toBe(
|
||||
QUERY_BUILDER,
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces an unsupported query type to the first supported one', () => {
|
||||
// PromQL → List has no PromQL, falls back to its first (and only) type.
|
||||
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
|
||||
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHiddenQueryBuilderFields', () => {
|
||||
it('returns {} for kinds that declare no field rules', () => {
|
||||
expect(getHiddenQueryBuilderFields('signoz/TimeSeriesPanel')).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(getHiddenQueryBuilderFields('signoz/TablePanel', logs)).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
// Mirrors QueryBuilderV2's internal listViewLogFilterConfigs — the guard is the
|
||||
// single source of truth for these values.
|
||||
it('hides step interval / having and sets body-contains for List + logs', () => {
|
||||
expect(getHiddenQueryBuilderFields('signoz/ListPanel', logs)).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors listViewTracesFilterConfigs — traces additionally hide `limit`.
|
||||
it('additionally hides limit for List + traces', () => {
|
||||
expect(
|
||||
getHiddenQueryBuilderFields('signoz/ListPanel', traces),
|
||||
).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default rule when no signal is given', () => {
|
||||
expect(getHiddenQueryBuilderFields('signoz/ListPanel')).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelDefinition } from './registry';
|
||||
import type { FilterConfigsPartial } from './types/panelCapabilities';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
/**
|
||||
* The single deterministic guard for V2 dashboards. Every "what works with what"
|
||||
* question — panel kind × query type × signal, and which query-builder fields a kind
|
||||
* hides — is answered here by reading each kind's declared capabilities from the panel
|
||||
* registry. Adding a new kind means declaring its capabilities once in its definition;
|
||||
* these functions then cover it automatically. Pure and side-effect free.
|
||||
*/
|
||||
|
||||
/** Signals (datasources) a kind can visualize. */
|
||||
export function getSupportedSignals(
|
||||
kind: PanelKind,
|
||||
): TelemetrytypesSignalDTO[] {
|
||||
return getPanelDefinition(kind).supportedSignals;
|
||||
}
|
||||
|
||||
export function isSignalSupported(
|
||||
kind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): boolean {
|
||||
return getSupportedSignals(kind).includes(signal);
|
||||
}
|
||||
|
||||
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
|
||||
return getPanelDefinition(kind).supportedQueryTypes;
|
||||
}
|
||||
|
||||
export function isQueryTypeSupported(
|
||||
kind: PanelKind,
|
||||
queryType: EQueryType,
|
||||
): boolean {
|
||||
return getSupportedQueryTypes(kind).includes(queryType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master guard: is this panel kind renderable with this query type (and, when a
|
||||
* datasource is known, this signal)? Signal is only meaningful in builder mode —
|
||||
* ClickHouse/PromQL queries have no datasource — so it's validated only when given.
|
||||
*/
|
||||
export function isPanelCombinationValid({
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
}: {
|
||||
kind: PanelKind;
|
||||
queryType: EQueryType;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}): boolean {
|
||||
if (!isQueryTypeSupported(kind, queryType)) {
|
||||
return false;
|
||||
}
|
||||
if (signal !== undefined && !isSignalSupported(kind, signal)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query type to use for a kind given a `preferred` one: keep it if the kind
|
||||
* supports it, otherwise fall back to the kind's first supported type. Used when
|
||||
* switching panel kinds to coerce an unsupported active query type (e.g. PromQL → a
|
||||
* List panel coerces to Query Builder).
|
||||
*/
|
||||
export function resolveQueryType(
|
||||
kind: PanelKind,
|
||||
preferred: EQueryType,
|
||||
): EQueryType {
|
||||
const supported = getSupportedQueryTypes(kind);
|
||||
return supported.includes(preferred) ? preferred : supported[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-builder field visibility for a kind + signal: the kind's `default` rule with
|
||||
* its per-signal overrides merged over it (signal wins). `{}` when the kind declares
|
||||
* nothing, i.e. the builder shows every field.
|
||||
*/
|
||||
export function getHiddenQueryBuilderFields(
|
||||
kind: PanelKind,
|
||||
signal?: TelemetrytypesSignalDTO,
|
||||
): FilterConfigsPartial {
|
||||
const rule = getPanelDefinition(kind).queryBuilderFields;
|
||||
if (!rule) {
|
||||
return {};
|
||||
}
|
||||
const perSignal = signal ? rule[signal] : undefined;
|
||||
return { ...rule.default, ...perSignal };
|
||||
}
|
||||
@@ -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,30 @@ 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 ? (
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -126,10 +153,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 +191,7 @@ function BarPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
@@ -158,7 +203,7 @@ function BarPanelRenderer({
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
@@ -13,6 +14,11 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
@@ -20,5 +26,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
|
||||
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, stacking: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: 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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
@@ -13,6 +14,11 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
@@ -20,5 +26,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
{
|
||||
kind: 'legend',
|
||||
controls: { position: true },
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
kind: 'signoz/ListPanel',
|
||||
@@ -12,6 +14,21 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
// Raw rows have no aggregation, so step interval / having never apply, and the
|
||||
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
|
||||
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
|
||||
// list configs — the capabilities guard is the single source for both.
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
|
||||
queryBuilderFields: {
|
||||
default: {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
},
|
||||
[TelemetrytypesSignalDTO.traces]: {
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
},
|
||||
},
|
||||
sections,
|
||||
actions: {
|
||||
view: true,
|
||||
@@ -20,5 +37,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [];
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
@@ -13,6 +14,11 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
@@ -20,5 +26,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'comparison' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
@@ -13,6 +14,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
@@ -20,5 +22,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { themeColors } from 'constants/theme';
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { coerceToString } from 'utils/stringUtils';
|
||||
|
||||
export interface PreparePieDataArgs {
|
||||
/** Scalar tables from the V5 response (see `prepareScalarTables`). */
|
||||
@@ -36,17 +37,24 @@ export function preparePieData({
|
||||
|
||||
table.rows.forEach((row) => {
|
||||
const value = Number(row.data[valueKey]);
|
||||
// Group-by key→value of this row; carried on the slice so drill-down can build filters.
|
||||
const labels: Record<string, string> = {};
|
||||
labelColumns.forEach((column) => {
|
||||
const cell = row.data[column.id || column.name];
|
||||
if (cell != null) {
|
||||
labels[column.name] = coerceToString(cell);
|
||||
}
|
||||
});
|
||||
const label =
|
||||
labelColumns
|
||||
.map((column) => row.data[column.id || column.name])
|
||||
.filter((part) => part != null)
|
||||
.map(String)
|
||||
.join(', ') ||
|
||||
table.legend ||
|
||||
table.queryName ||
|
||||
'';
|
||||
Object.values(labels).join(', ') || table.legend || table.queryName || '';
|
||||
const color = customColors?.[label] ?? generateColor(label, colorMap);
|
||||
slices.push({ label, value, color });
|
||||
slices.push({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
queryName: valueColumn.queryName,
|
||||
labels,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
@@ -13,6 +14,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
|
||||
actions: {
|
||||
view: true,
|
||||
@@ -22,5 +24,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
createAlert: false,
|
||||
// V1 parity: only tables (and lists) expose the header search box.
|
||||
search: true,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// single column set). It exposes the per-panel time scope, formatting (decimals +
|
||||
// per-column units), per-column thresholds, and context links.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'table' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -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,30 @@ 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 ? (
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -127,10 +154,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 +192,7 @@ function TimeSeriesPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
@@ -158,7 +203,7 @@ function TimeSeriesPanelRenderer({
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
@@ -13,6 +14,11 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
@@ -20,5 +26,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, colors: true } },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
|
||||
/**
|
||||
* Query-builder field-visibility config a panel kind can declare, mirroring the
|
||||
* shape `QueryBuilderV2` consumes via its `filterConfigs` prop. Derived from that
|
||||
* prop type (the underlying `FilterConfigs` isn't exported) so the two never drift.
|
||||
*/
|
||||
export type FilterConfigsPartial = NonNullable<
|
||||
QueryBuilderProps['filterConfigs']
|
||||
>;
|
||||
|
||||
/**
|
||||
* Per-signal query-builder field rules for a panel kind. `default` applies to every
|
||||
* signal; a per-signal entry is merged over it (signal wins). The capabilities guard
|
||||
* resolves this into a single `FilterConfigsPartial` via `getHiddenQueryBuilderFields`.
|
||||
*/
|
||||
export type QueryBuilderFieldRule = {
|
||||
default?: FilterConfigsPartial;
|
||||
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { QueryBuilderFieldRule } from './panelCapabilities';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
/**
|
||||
@@ -28,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> {
|
||||
@@ -35,7 +42,12 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
/** Signals (datasources) this kind can visualize. */
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
supportedQueryTypes: EQueryType[];
|
||||
/** Query-builder fields this kind hides/disables, optionally per signal. */
|
||||
queryBuilderFields?: QueryBuilderFieldRule;
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -89,8 +89,11 @@ export interface SectionControls {
|
||||
spanGaps?: boolean;
|
||||
};
|
||||
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
|
||||
// stacking → stackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
|
||||
// switchPanelKind → the visualization-type switcher (every kind, so you can switch
|
||||
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
|
||||
// 0 (TimeSeries).
|
||||
visualization: {
|
||||
switchPanelKind: boolean;
|
||||
timePreference?: boolean;
|
||||
stacking?: boolean;
|
||||
fillSpans?: boolean;
|
||||
@@ -128,7 +131,7 @@ export type SectionConfig =
|
||||
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
|
||||
// live in the ConfigPane section registry.
|
||||
export const SECTION_METADATA = {
|
||||
formatting: { title: 'Formatting', icon: Hash },
|
||||
formatting: { title: 'Formatting & Units', icon: Hash },
|
||||
axes: { title: 'Axes', icon: Ruler },
|
||||
legend: { title: 'Legend', icon: Layers },
|
||||
chartAppearance: { title: 'Chart appearance', icon: Palette },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { resolveSignal } from '../getBuilderQueries';
|
||||
|
||||
function builderQuery(signal: string): DashboardtypesQueryDTO {
|
||||
return {
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal } } },
|
||||
} as unknown as DashboardtypesQueryDTO;
|
||||
}
|
||||
|
||||
const promqlQuery = {
|
||||
spec: { plugin: { kind: 'signoz/PromQuery', spec: { query: 'up' } } },
|
||||
} as unknown as DashboardtypesQueryDTO;
|
||||
|
||||
describe('resolveSignal', () => {
|
||||
const DEFAULT = TelemetrytypesSignalDTO.metrics;
|
||||
|
||||
it("uses the first builder query's signal when present", () => {
|
||||
expect(
|
||||
resolveSignal([builderQuery('logs')], DEFAULT),
|
||||
).toBe('logs');
|
||||
});
|
||||
|
||||
it("prefers the builder signal over the default", () => {
|
||||
expect(resolveSignal([builderQuery('traces')], DEFAULT)).toBe(
|
||||
'traces',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the default signal when there are no queries (new panel)', () => {
|
||||
expect(resolveSignal([], DEFAULT)).toBe('metrics');
|
||||
expect(resolveSignal(null, DEFAULT)).toBe('metrics');
|
||||
});
|
||||
|
||||
it('stays undefined when queries exist but none are builder queries (PromQL/ClickHouse)', () => {
|
||||
expect(resolveSignal([promqlQuery], DEFAULT)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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,22 @@
|
||||
import { resolveSpanGaps } from '../resolvers';
|
||||
|
||||
describe('resolveSpanGaps', () => {
|
||||
it('spans all gaps (true) when unset', () => {
|
||||
expect(resolveSpanGaps(undefined)).toBe(true);
|
||||
expect(resolveSpanGaps('')).toBe(true);
|
||||
});
|
||||
|
||||
it('parses a duration string into seconds', () => {
|
||||
expect(resolveSpanGaps('5s')).toBe(5);
|
||||
expect(resolveSpanGaps('10m')).toBe(600);
|
||||
expect(resolveSpanGaps('1h')).toBe(3600);
|
||||
});
|
||||
|
||||
it('tolerates a bare seconds number (back-compat)', () => {
|
||||
expect(resolveSpanGaps('600')).toBe(600);
|
||||
});
|
||||
|
||||
it('falls back to true for unparseable input', () => {
|
||||
expect(resolveSpanGaps('abc')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import {
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesPrecisionOptionDTO,
|
||||
@@ -38,9 +39,10 @@ export function resolveDecimalPrecision(
|
||||
}
|
||||
|
||||
/**
|
||||
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
|
||||
* wire. Empty/missing → span all gaps (default); numeric → forward the threshold
|
||||
* so uPlot only bridges short runs of nulls.
|
||||
* `spec.chartAppearance.spanGaps.fillLessThan` is a duration string on the wire
|
||||
* ("10m", "5s"). Empty/missing → span all gaps (default); otherwise forward the
|
||||
* threshold in seconds so uPlot only bridges short runs of nulls. Tolerates a
|
||||
* bare seconds number for back-compat.
|
||||
*/
|
||||
export function resolveSpanGaps(
|
||||
fillLessThan: string | undefined,
|
||||
@@ -48,8 +50,10 @@ export function resolveSpanGaps(
|
||||
if (!fillLessThan) {
|
||||
return true;
|
||||
}
|
||||
const parsed = Number(fillLessThan);
|
||||
return Number.isFinite(parsed) ? parsed : true;
|
||||
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)
|
||||
? rangeUtil.intervalToSeconds(fillLessThan)
|
||||
: Number(fillLessThan);
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : true;
|
||||
}
|
||||
|
||||
/** Legend position; missing/unknown falls back to `BOTTOM` (chart default, V1 parity). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
@@ -33,3 +36,21 @@ export function getBuilderQueries(
|
||||
});
|
||||
return flattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datasource signal scoping panel-type compatibility (List needs logs/traces, not
|
||||
* metrics): the builder query's signal if present; else `defaultSignal` for a new
|
||||
* panel (queries empty until edited); else undefined for PromQL/ClickHouse.
|
||||
*/
|
||||
export function resolveSignal(
|
||||
queries: DashboardtypesQueryDTO[] | null,
|
||||
defaultSignal: TelemetrytypesSignalDTO,
|
||||
): TelemetrytypesSignalDTO | undefined {
|
||||
const builderSignal = getBuilderQueries(queries ?? [])[0]?.signal as
|
||||
| TelemetrytypesSignalDTO
|
||||
| undefined;
|
||||
if (builderSignal) {
|
||||
return builderSignal;
|
||||
}
|
||||
return queries?.length ? undefined : defaultSignal;
|
||||
}
|
||||
|
||||
@@ -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,71 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DraftingCompass, 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;
|
||||
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,
|
||||
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={
|
||||
<span style={{ color: context.seriesColor }}>
|
||||
<ScrollText size={16} />
|
||||
</span>
|
||||
}
|
||||
onClick={onViewLogs}
|
||||
>
|
||||
<span data-testid="drilldown-view-logs">View in Logs</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
icon={
|
||||
<span style={{ color: context.seriesColor }}>
|
||||
<DraftingCompass size={16} />
|
||||
</span>
|
||||
}
|
||||
onClick={onViewTraces}
|
||||
>
|
||||
<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';
|
||||
@@ -41,10 +43,6 @@ function Panel({
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
const name = panel.spec.display.name;
|
||||
const description = panel.spec.display?.description;
|
||||
const fullKind = panel.spec.plugin.kind;
|
||||
|
||||
// A per-panel time preference is surfaced as a header pill. `visualization` is
|
||||
// common to every plugin-spec variant — localized cast reads it without
|
||||
// narrowing on kind.
|
||||
@@ -55,7 +53,8 @@ function Panel({
|
||||
)?.visualization?.timePreference;
|
||||
const timeLabel = panelTimePreferenceLabel(timePreference);
|
||||
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
// Header search: only kinds that declare it render the box. The term is owned
|
||||
// here and threaded to both the header (input) and renderer (filter).
|
||||
@@ -70,6 +69,7 @@ function Panel({
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
const drilldown = useDrilldown(panel, panelId);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -77,10 +77,8 @@ function Panel({
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<PanelHeader
|
||||
name={name}
|
||||
description={description}
|
||||
panelId={panelId}
|
||||
panelKind={fullKind}
|
||||
panel={panel}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
@@ -103,8 +101,11 @@ function Panel({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { usePanelActionItems } from './usePanelActionItems';
|
||||
import styles from './PanelActionsMenu.module.scss';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
interface PanelActionsMenuProps {
|
||||
panelId: string;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
|
||||
panelKind: PanelKind;
|
||||
/** The panel itself — its query seeds "Create Alerts". */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -23,12 +23,12 @@ interface PanelActionsMenuProps {
|
||||
*/
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
panelKind,
|
||||
panel,
|
||||
panelActions,
|
||||
}: PanelActionsMenuProps): JSX.Element | null {
|
||||
const { items, deleteConfirm } = usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panel,
|
||||
panelActions,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
@@ -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,
|
||||
@@ -29,6 +42,11 @@ jest.mock('../../hooks/useClonePanel', () => ({
|
||||
useClonePanel: (): jest.Mock => mockClonePanel,
|
||||
}));
|
||||
|
||||
const mockCreateAlert = jest.fn();
|
||||
jest.mock('../../hooks/useCreateAlertFromPanel', () => ({
|
||||
useCreateAlertFromPanel: (): jest.Mock => mockCreateAlert,
|
||||
}));
|
||||
|
||||
// Role is the only thing read off the app context; useComponentPermission runs
|
||||
// for real so the tests exercise the actual role → permission mapping.
|
||||
let mockRole: ROLES = 'ADMIN';
|
||||
@@ -52,10 +70,23 @@ function section(
|
||||
}
|
||||
|
||||
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
|
||||
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
|
||||
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
|
||||
|
||||
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
|
||||
// translation it drives is covered by buildCreateAlertUrl's own tests.
|
||||
const mockPanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
|
||||
panel: mockPanel,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
};
|
||||
|
||||
@@ -113,29 +144,18 @@ describe('usePanelActionItems', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('unknown panel kind hides all kind-gated actions (incl. clone), keeping only move/delete', () => {
|
||||
const { result } = renderHook(() =>
|
||||
// A kind with no registered definition — exercises the "unsupported kind"
|
||||
// branch. Clone is kind-gated (needs the kind to declare actions.clone),
|
||||
// so it drops too; only the kind-agnostic layout actions remain.
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelKind: 'signoz/UnsupportedPanel' as PanelKind,
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('read-only dashboard keeps only View (V1 parity)', () => {
|
||||
it('read-only dashboard keeps View and Create Alerts (V1 parity: both survive a lock)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so it
|
||||
// isn't gated on edit access — matching V1's locked-dashboard menu.
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
@@ -177,6 +197,49 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('"Move out of section" moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
const moveOut = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move-to-root',
|
||||
);
|
||||
(moveOut as { onClick: () => void }).onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when the panel already sits in the root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when every section is titled (no root)', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const del = result.current.items.find(
|
||||
@@ -214,18 +277,21 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('not-yet-implemented actions (view/create-alert) 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(mockOpenView).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
['view-panel', 'create-alert'].forEach((key) => {
|
||||
const item = result.current.items.find((i) => 'key' in i && i.key === key);
|
||||
(item as { onClick: () => void }).onClick();
|
||||
});
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledTimes(2);
|
||||
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
|
||||
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
|
||||
alertSpy.mockRestore();
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const createAlert = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'create-alert',
|
||||
);
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
CloudDownload,
|
||||
Copy,
|
||||
FolderInput,
|
||||
FolderOutput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import {
|
||||
type ConfirmableAction,
|
||||
@@ -22,10 +24,14 @@ import { useAppContext } from 'providers/App/App';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { useClonePanel } from '../hooks/useClonePanel';
|
||||
import { useCreateAlertFromPanel } from '../hooks/useCreateAlertFromPanel';
|
||||
import { useDeletePanel } from '../hooks/useDeletePanel';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import {
|
||||
type MovePanelArgs,
|
||||
useMovePanelToSection,
|
||||
} from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
@@ -37,10 +43,73 @@ function notImplementedYet(feature: string): void {
|
||||
alert(`${feature} option clicked`);
|
||||
}
|
||||
|
||||
interface MoveItemsArgs {
|
||||
sections: DashboardSection[];
|
||||
currentLayoutIndex: number;
|
||||
panelId: string;
|
||||
movePanel: (args: MovePanelArgs) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Move to section" submenu (other titled sections) plus a direct "Move out
|
||||
* of section" to the untitled root, shown only when the panel sits in a titled
|
||||
* section and a root section exists to receive it.
|
||||
*/
|
||||
function buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
}: MoveItemsArgs): MenuItem[] {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const rootSection = sections.find((s) => !s.title);
|
||||
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
|
||||
items.push({
|
||||
key: 'move-to-root',
|
||||
label: 'Move out of section',
|
||||
icon: <FolderOutput size={14} />,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: rootSection.layoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
interface UsePanelActionItemsArgs {
|
||||
panelId: string;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
|
||||
panelKind: PanelKind;
|
||||
/**
|
||||
* The panel itself — its query seeds the "Create Alerts" action. Absent where
|
||||
* the panel data isn't threaded (so that action simply doesn't appear).
|
||||
*/
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Layout context for move/delete — absent outside editable mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -64,9 +133,10 @@ export interface PanelActionItems {
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panel,
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const { user } = useAppContext();
|
||||
const [canEditWidget, canMove, canDelete] = useComponentPermission(
|
||||
[
|
||||
@@ -79,6 +149,8 @@ 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.
|
||||
@@ -87,7 +159,7 @@ export function usePanelActionItems({
|
||||
const deletePanel = useDeletePanel({ sections });
|
||||
const clonePanel = useClonePanel({ sections });
|
||||
|
||||
const kindActions = getPanelDefinition(panelKind)?.actions;
|
||||
const kindActions = getPanelDefinition(panelKind).actions;
|
||||
|
||||
// Delete runs on confirm, not on click — the menu item opens a prompt.
|
||||
const deleteConfirm = useConfirmableAction(
|
||||
@@ -106,15 +178,15 @@ export function usePanelActionItems({
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const panelGroup: MenuItem[] = [];
|
||||
if (kindActions?.view) {
|
||||
if (kindActions.view) {
|
||||
panelGroup.push({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => notImplementedYet('View'),
|
||||
onClick: (): void => openView(panelId),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && kindActions?.edit) {
|
||||
if (isEditable && canEditWidget && kindActions.edit) {
|
||||
panelGroup.push({
|
||||
key: 'edit-panel',
|
||||
label: 'Edit panel',
|
||||
@@ -124,7 +196,7 @@ export function usePanelActionItems({
|
||||
}
|
||||
// Clone needs the section context (source spec + dimensions) to place the
|
||||
// copy, so — unlike Edit — it requires panelActions.
|
||||
if (isEditable && canEditWidget && panelActions && kindActions?.clone) {
|
||||
if (isEditable && canEditWidget && panelActions && kindActions.clone) {
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
label: 'Clone',
|
||||
@@ -138,7 +210,7 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
const dataGroup: MenuItem[] = [];
|
||||
if (kindActions?.download) {
|
||||
if (kindActions.download) {
|
||||
dataGroup.push({
|
||||
key: 'download-panel',
|
||||
label: 'Download as CSV',
|
||||
@@ -146,40 +218,27 @@ export function usePanelActionItems({
|
||||
onClick: (): void => notImplementedYet('Download'),
|
||||
});
|
||||
}
|
||||
if (isEditable && kindActions?.createAlert) {
|
||||
// Seeding an alert opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity: available
|
||||
// on locked dashboards too). It needs the panel to read its query.
|
||||
if (kindActions.createAlert && panel) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
label: 'Create Alerts',
|
||||
icon: <Bell size={14} />,
|
||||
onClick: (): void => notImplementedYet('Create Alerts'),
|
||||
onClick: (): void => createAlert(panel, panelId),
|
||||
});
|
||||
}
|
||||
|
||||
const moveGroup: MenuItem[] = [];
|
||||
if (canMove && panelActions) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
|
||||
);
|
||||
moveGroup.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: panelActions.currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
}
|
||||
const moveGroup: MenuItem[] =
|
||||
canMove && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
})
|
||||
: [];
|
||||
|
||||
const deleteGroup: MenuItem[] =
|
||||
canDelete && panelActions
|
||||
@@ -205,10 +264,13 @@ export function usePanelActionItems({
|
||||
canMove,
|
||||
canDelete,
|
||||
kindActions,
|
||||
panel,
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
movePanel,
|
||||
clonePanel,
|
||||
requestDelete,
|
||||
|
||||
@@ -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,7 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
|
||||
@@ -14,15 +17,12 @@ import {
|
||||
panelStatusFromWarning,
|
||||
} from '../PanelStatus/utils';
|
||||
import styles from './PanelHeader.module.scss';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
name: string;
|
||||
description?: string;
|
||||
panelId: string;
|
||||
/** Full plugin kind — drives kind-gated menu actions. */
|
||||
panelKind: PanelKind;
|
||||
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Background refresh in flight — shows a spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error — surfaced as a header error indicator. */
|
||||
@@ -49,10 +49,8 @@ interface PanelHeaderProps {
|
||||
|
||||
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
|
||||
function PanelHeader({
|
||||
name,
|
||||
description,
|
||||
panelId,
|
||||
panelKind,
|
||||
panel,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
@@ -63,6 +61,8 @@ function PanelHeader({
|
||||
onSearchChange,
|
||||
hideActions,
|
||||
}: PanelHeaderProps): JSX.Element {
|
||||
const name = panel.spec.display.name;
|
||||
const description = panel.spec.display.description;
|
||||
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
|
||||
|
||||
const warningDetail = useMemo(
|
||||
@@ -116,7 +116,7 @@ function PanelHeader({
|
||||
{!hideActions && (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
panelKind={panelKind}
|
||||
panel={panel}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import styles from './PanelTypeSelectionModal.module.scss';
|
||||
interface PanelTypeSelectionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (pluginKind: PanelKind) => void;
|
||||
onSelect: (panelKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
function PanelTypeSelectionModal({
|
||||
@@ -25,17 +25,17 @@ function PanelTypeSelectionModal({
|
||||
destroyOnClose
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map((type) => (
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<Button
|
||||
key={type.pluginKind}
|
||||
key={panelKind}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${type.pluginKind}`}
|
||||
onClick={(): void => onSelect(type.pluginKind)}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
onClick={(): void => onSelect(panelKind)}
|
||||
>
|
||||
{type.icon}
|
||||
{type.label}
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import type { PanelType } from './types';
|
||||
|
||||
export const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
Icon: ChartLine,
|
||||
},
|
||||
{ panelKind: 'signoz/NumberPanel', label: 'Number', Icon: Hash },
|
||||
{ panelKind: 'signoz/TablePanel', label: 'Table', Icon: Table },
|
||||
{ panelKind: 'signoz/BarChartPanel', label: 'Bar Chart', Icon: BarChart },
|
||||
{ panelKind: 'signoz/PieChartPanel', label: 'Pie Chart', Icon: ChartPie },
|
||||
{ panelKind: 'signoz/HistogramPanel', label: 'Histogram', Icon: BarChart },
|
||||
{ panelKind: 'signoz/ListPanel', label: 'List', Icon: List },
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import type { PanelType } from './types';
|
||||
|
||||
export const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
pluginKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
icon: <ChartLine size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
|
||||
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
|
||||
{
|
||||
pluginKind: 'signoz/BarChartPanel',
|
||||
label: 'Bar Chart',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/PieChartPanel',
|
||||
label: 'Pie Chart',
|
||||
icon: <ChartPie size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/HistogramPanel',
|
||||
label: 'Histogram',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user