mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 17:40:32 +01:00
Compare commits
29 Commits
metric-red
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49232846c8 | ||
|
|
82bf12892a | ||
|
|
935b079ce0 | ||
|
|
ebc4923b10 | ||
|
|
8e7338553d | ||
|
|
aa0e9dac6e | ||
|
|
4f0e9ff8e6 | ||
|
|
b72bc75ec2 | ||
|
|
985d9dcb79 | ||
|
|
5ea71de666 | ||
|
|
1d264f6996 | ||
|
|
61a44b500a | ||
|
|
fffff33919 | ||
|
|
26a137405d | ||
|
|
0887dc6b2b | ||
|
|
87a5aec0e2 | ||
|
|
16ff68fe95 | ||
|
|
52f0fda21f | ||
|
|
4bc33087b9 | ||
|
|
c59cb53de2 | ||
|
|
59a0a3fcb1 | ||
|
|
86fd8be57c | ||
|
|
56dddba711 | ||
|
|
87118163f1 | ||
|
|
7f615b3514 | ||
|
|
1dd01f9b29 | ||
|
|
89a94dfb77 | ||
|
|
f227a25310 | ||
|
|
a609a4044c |
@@ -5,6 +5,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
|
||||
@@ -36,17 +37,16 @@ export default function Legend({
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
|
||||
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
const containerWidth = legendContainerRef.current.clientWidth;
|
||||
|
||||
const totalLegendWidth = items.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, items.length, position]);
|
||||
}, [averageLegendWidth, items.length, position, containerWidth]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
|
||||
@@ -14,10 +14,11 @@ import type {
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
@@ -50,9 +51,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
@@ -108,8 +108,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
void logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
}, [id, setIsPanelTypeSelectionModalOpen]);
|
||||
openPicker();
|
||||
}, [id, openPicker]);
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
@@ -149,6 +149,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
</div>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface ConfigPaneProps {
|
||||
/** 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;
|
||||
/** 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. */
|
||||
@@ -36,6 +38,7 @@ function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
@@ -95,6 +98,8 @@ function ConfigPane({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
))}
|
||||
</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,54 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
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';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Panel's current datasource — drives the disabled rule. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section). Types whose
|
||||
* supported signals exclude the panel's current datasource are disabled (V1 parity —
|
||||
* e.g. List needs logs/traces, not metrics). The datasource is unknown for
|
||||
* PromQL/ClickHouse queries, in which case no type is disabled.
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = PANEL_TYPES.map((type) => {
|
||||
const definition = getPanelDefinition(type.pluginKind as PanelKind);
|
||||
return {
|
||||
value: type.pluginKind,
|
||||
label: type.label,
|
||||
icon: type.icon,
|
||||
disabled:
|
||||
!!signal && !!definition && !definition.supportedSignals.includes(signal),
|
||||
};
|
||||
});
|
||||
|
||||
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,73 @@
|
||||
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';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
}));
|
||||
});
|
||||
|
||||
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();
|
||||
const disabled = Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabled).toContain('List');
|
||||
expect(disabled).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('does not disable any type when the datasource is unknown', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SectionConfig,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
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 +24,9 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +42,8 @@ function SectionSlot({
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
}: 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 +66,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 +80,8 @@ function SectionSlot({
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ function renderConfigPane(
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
...overrides,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.group {
|
||||
width: min(350px, 100%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
@@ -7,7 +8,9 @@ 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;
|
||||
}
|
||||
|
||||
interface ConfigSelectProps {
|
||||
@@ -40,9 +43,14 @@ function ConfigSelect({
|
||||
virtual={false}
|
||||
options={items.map((item) => ({
|
||||
value: item.value,
|
||||
disabled: item.disabled,
|
||||
label: 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>
|
||||
) : (
|
||||
|
||||
@@ -157,6 +157,10 @@ 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;
|
||||
}>;
|
||||
get: (spec: PanelSpec) => unknown;
|
||||
update: (spec: PanelSpec, value: unknown) => PanelSpec;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
@@ -9,8 +10,8 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
|
||||
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
|
||||
];
|
||||
|
||||
// Stateful harness for flows that depend on the value updating (add/discard). No
|
||||
// `controls` is passed, exercising the default `label` variant.
|
||||
// Stateful harness for flows that depend on the value updating (add/discard);
|
||||
// omits `controls` to exercise the default `label` variant.
|
||||
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
|
||||
const [value, setValue] = useState<AnyThreshold[]>([]);
|
||||
return (
|
||||
@@ -33,7 +34,6 @@ describe('ThresholdsSection', () => {
|
||||
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
// The editable fields are hidden until the row is edited.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -54,6 +54,22 @@ describe('ThresholdsSection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists an empty-string label when none is provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
// Label absent (e.g. a pre-existing spec); spec requires a string, so save
|
||||
// must send '' not undefined.
|
||||
const noLabel = [{ value: 50, color: '#F1575F' }] as AnyThreshold[];
|
||||
render(<ThresholdsSection value={noLabel} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByTestId('threshold-edit-0'));
|
||||
await user.click(screen.getByTestId('threshold-save-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ value: 50, color: '#F1575F', label: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not commit edits when Discard is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
@@ -65,7 +81,6 @@ describe('ThresholdsSection', () => {
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
// Back to view mode.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
});
|
||||
@@ -83,11 +98,10 @@ describe('ThresholdsSection', () => {
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
// New row opens in edit mode.
|
||||
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
// Discarding a never-saved row removes it entirely.
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -23,10 +24,7 @@ interface LabelThresholdRowProps {
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. Edit
|
||||
* form is color, value, unit, label.
|
||||
*/
|
||||
/** Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. */
|
||||
function LabelThresholdRow({
|
||||
index,
|
||||
threshold,
|
||||
@@ -39,6 +37,11 @@ function LabelThresholdRow({
|
||||
}: LabelThresholdRowProps): JSX.Element {
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
|
||||
|
||||
// Persist an empty-string label when none was entered — the spec requires a string.
|
||||
const handleSave = useCallback((): void => {
|
||||
onSave({ ...draft, label: draft.label ?? '' });
|
||||
}, [onSave, draft]);
|
||||
|
||||
const summary = (
|
||||
<>
|
||||
<span className={styles.viewValue}>
|
||||
@@ -58,7 +61,7 @@ function LabelThresholdRow({
|
||||
isEditing={isEditing}
|
||||
summary={summary}
|
||||
onEdit={onEdit}
|
||||
onSave={(): void => onSave(draft)}
|
||||
onSave={handleSave}
|
||||
onDiscard={onDiscard}
|
||||
onRemove={onRemove}
|
||||
>
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
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 { 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;
|
||||
/** 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,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.switchPanelKind && panelKind && onChangePanelKind && (
|
||||
<PanelTypeSwitcher
|
||||
panelKind={panelKind}
|
||||
signal={signal}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.timePreference && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel time preference</Typography.Text>
|
||||
|
||||
@@ -4,6 +4,14 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
|
||||
|
||||
import VisualizationSection from '../VisualizationSection';
|
||||
|
||||
// The type switcher resolves each kind's supported signals; 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'],
|
||||
})),
|
||||
}));
|
||||
|
||||
// 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 +25,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 +48,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -56,7 +72,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -74,7 +93,10 @@ describe('VisualizationSection', () => {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
stackedBarChart: false,
|
||||
}}
|
||||
controls={{ stacking: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
stacking: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -92,7 +114,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: false }}
|
||||
controls={{ fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -101,4 +126,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,7 @@ import styles from './ListColumnsEditor.module.scss';
|
||||
interface ListColumnsEditorProps {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Committed query's signal — scopes the add-dropdown's field suggestions. */
|
||||
signal: TelemetrytypesSignalDTO | undefined;
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useListColumnSuggestions } from '../../hooks/useListColumnSuggestions';
|
||||
import styles from './AddColumnDropdown.module.scss';
|
||||
|
||||
interface AddColumnDropdownProps {
|
||||
signal: TelemetrytypesSignalDTO | undefined;
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Names already chosen — drives the checked state + toggle behavior. */
|
||||
selectedNames: Set<string>;
|
||||
onToggle: (field: TelemetrytypesTelemetryFieldKeyDTO) => void;
|
||||
|
||||
@@ -21,7 +21,7 @@ interface UseListColumnSuggestions {
|
||||
* flatten them and index by name so picks can carry their context/data-type.
|
||||
*/
|
||||
export function useListColumnSuggestions(
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): UseListColumnSuggestions {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const debouncedSearch = useDebounce(searchText, 300);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type {
|
||||
DashboardtypesListPanelSpecDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
type DashboardtypesListPanelSpecDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
|
||||
/**
|
||||
* The field-key suggestions API and the default-column constants carry extra
|
||||
* runtime fields (e.g. `isIndexed`) that the save contract rejects. Reduce each
|
||||
* column to the `TelemetrytypesTelemetryFieldKeyDTO` shape so persisted
|
||||
* `selectFields` only contain backend-known keys.
|
||||
* Reduce each column to the field-key DTO shape: the suggestions API and default
|
||||
* constants carry extra runtime fields (e.g. `isIndexed`) the save contract rejects.
|
||||
*/
|
||||
function toFieldKeyDTO(
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO,
|
||||
@@ -31,10 +34,26 @@ export function sanitizeSelectFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* `spec.plugin.spec` is a discriminated union over every panel kind; these helpers
|
||||
* run only for the List panel, so it's narrowed to the List variant with a single
|
||||
* localized cast at the boundary.
|
||||
* logs/traces List-column defaults (V1 parity), sanitized to the field-key DTO.
|
||||
*/
|
||||
export function defaultColumnsForSignal(
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) {
|
||||
return sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
if (signal === TelemetrytypesSignalDTO.traces) {
|
||||
return sanitizeSelectFields(
|
||||
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// `spec.plugin.spec` is a discriminated union over panel kinds; these List-only
|
||||
// helpers narrow to the List variant via a single localized cast at the boundary.
|
||||
export function readSelectFields(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface PlotTagProps {
|
||||
/** Authoring mode of the panel's query; undefined when no query exists yet. */
|
||||
queryType: EQueryType | undefined;
|
||||
panelType: PANEL_TYPES;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
|
||||
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
|
||||
* query exists, where the mode is irrelevant.
|
||||
*/
|
||||
function PlotTag({
|
||||
queryType,
|
||||
panelType,
|
||||
className,
|
||||
}: PlotTagProps): JSX.Element | null {
|
||||
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} data-testid="panel-editor-plot-tag">
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={queryType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlotTag;
|
||||
@@ -43,8 +43,10 @@
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
// Header stacks above the body, flush to the border — mirrors the dashboard
|
||||
// grid's `.panel` so the preview reads as the real panel chrome.
|
||||
flex-direction: column;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
@@ -57,3 +59,7 @@
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dateTimeSelector {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PlotTag from './PlotTag';
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
|
||||
panelDef: RenderablePanelDefinition | undefined;
|
||||
/** Resolved definition for the panel kind; */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
/** Any fetch in flight — drives the header spinner and the body's loading state. */
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (drives PanelBody's error-state retry). */
|
||||
refetch: () => void;
|
||||
@@ -30,50 +33,69 @@ interface PreviewPaneProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode={DASHBOARD_EDIT}` differs), so the preview
|
||||
* is the production render path. The query result is owned by the editor root.
|
||||
* Live preview for the panel editor: renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode` differs), so the preview is the
|
||||
* production render path. The query result is owned by the editor root.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDef,
|
||||
panelDefinition,
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
|
||||
// Search term is ephemeral preview state, threaded to header + renderer but
|
||||
// not persisted to the draft spec. Only kinds that declare it render the box.
|
||||
const searchable = !!panelDefinition.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{panelDef ? (
|
||||
<PanelBody
|
||||
panelDefinition={panelDef}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
)}
|
||||
<PanelHeader
|
||||
name={panel.spec.display.name}
|
||||
description={panel.spec.display.description}
|
||||
panelId={panelId}
|
||||
panelKind={panel.spec.plugin.kind}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PlotTag from '../PlotTag';
|
||||
|
||||
describe('PlotTag', () => {
|
||||
it('renders the resolved query mode', () => {
|
||||
render(
|
||||
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
|
||||
);
|
||||
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when there is no query yet', () => {
|
||||
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for list panels (query mode is irrelevant)', () => {
|
||||
render(
|
||||
<PlotTag
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
panelType={PANEL_TYPES.LIST}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
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');
|
||||
|
||||
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');
|
||||
|
||||
expect(result.legend?.position).toBe('bottom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../newPanelRoute';
|
||||
|
||||
describe('newPanelRoute', () => {
|
||||
it('round-trips kind + layoutIndex through the new-panel search', () => {
|
||||
const search = newPanelSearch('signoz/ListPanel', 2);
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
|
||||
expect(parseNewPanelLayoutIndex(search)).toBe(2);
|
||||
});
|
||||
|
||||
it('omits layoutIndex when not provided', () => {
|
||||
const search = newPanelSearch('signoz/TimeSeriesPanel');
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null for an existing panel id (not the new sentinel)', () => {
|
||||
const search = newPanelSearch('signoz/ListPanel');
|
||||
expect(parseNewPanelKind('a1b2c3d4-uuid', search)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the kind param is missing or unknown', () => {
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, '')).toBeNull();
|
||||
expect(
|
||||
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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') && signal) {
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
if (columns.length > 0) {
|
||||
result.selectFields = columns;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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 { 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(),
|
||||
PANEL_TYPE_TO_QUERY_TYPES: {
|
||||
graph: ['builder', 'clickhouse', 'promql'],
|
||||
table: ['builder', 'clickhouse'],
|
||||
list: ['builder'],
|
||||
value: ['builder', 'clickhouse', 'promql'],
|
||||
bar: ['builder', 'clickhouse', 'promql'],
|
||||
pie: ['builder', 'clickhouse'],
|
||||
histogram: ['builder', 'clickhouse', 'promql'],
|
||||
},
|
||||
}));
|
||||
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 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' }]);
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
// List allows only Query Builder, so the promql query is coerced to 'builder'.
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
} from 'container/OptionsMenu/constants';
|
||||
|
||||
import { sanitizeSelectFields } from '../../ListColumnsEditor/selectFields';
|
||||
import { useSwitchColumnsOnSignalChange } from '../useSwitchColumnsOnSignalChange';
|
||||
import {
|
||||
useSwitchColumnsOnSignalChange,
|
||||
type UseSwitchColumnsOnSignalChangeArgs,
|
||||
} from '../useSwitchColumnsOnSignalChange';
|
||||
|
||||
// The hook applies the datasource defaults reduced to the field-key DTO (the V1
|
||||
// constants carry extra keys like `isIndexed`); assertions mirror that.
|
||||
// V1 constants carry extra keys (e.g. `isIndexed`); the hook reduces them to the
|
||||
// field-key DTO, so assertions sanitize the same way.
|
||||
const expectedLogs = sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
@@ -30,16 +33,12 @@ function makeSpec(
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
signal: TelemetrytypesSignalDTO | undefined;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
};
|
||||
|
||||
function renderWith(initial: Props): { rerender: (next: Props) => void } {
|
||||
function renderWith(initial: UseSwitchColumnsOnSignalChangeArgs): {
|
||||
rerender: (next: UseSwitchColumnsOnSignalChangeArgs) => void;
|
||||
} {
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useSwitchColumnsOnSignalChange(props),
|
||||
(props: UseSwitchColumnsOnSignalChangeArgs) =>
|
||||
useSwitchColumnsOnSignalChange(props),
|
||||
{ initialProps: initial },
|
||||
);
|
||||
return { rerender };
|
||||
@@ -73,6 +72,45 @@ describe('useSwitchColumnsOnSignalChange', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the original columns on logs → traces → logs', () => {
|
||||
// Customized logs selection, not the timestamp/body defaults.
|
||||
const original = [
|
||||
{ name: 'timestamp' },
|
||||
{ name: 'body' },
|
||||
{ name: 'response_status_code' },
|
||||
{ name: 'trace_id' },
|
||||
];
|
||||
// Mirror the real parent: persist the spec so the next switch stashes the
|
||||
// columns the previous one applied.
|
||||
let spec = makeSpec(original);
|
||||
const onChangeSpec = jest.fn((next: DashboardtypesPanelSpecDTO) => {
|
||||
spec = next;
|
||||
});
|
||||
const { rerender } = renderWith({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
|
||||
rerender({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
expect(selectFieldsOf(spec)).toStrictEqual(expectedTraces);
|
||||
|
||||
// Switching back restores the original columns, not the log defaults.
|
||||
rerender({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
expect(selectFieldsOf(spec)).toStrictEqual(original);
|
||||
});
|
||||
|
||||
it('switches to the log defaults when going traces → logs', () => {
|
||||
const onChangeSpec = jest.fn();
|
||||
const spec = makeSpec([{ name: 'service.name' }]);
|
||||
@@ -114,20 +152,6 @@ describe('useSwitchColumnsOnSignalChange', () => {
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not switch on a transient undefined signal', () => {
|
||||
const onChangeSpec = jest.fn();
|
||||
const spec = makeSpec([{ name: 'body' }]);
|
||||
const { rerender } = renderWith({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
|
||||
rerender({ enabled: true, signal: undefined, spec, onChangeSpec });
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when disabled (non-List kinds)', () => {
|
||||
const onChangeSpec = jest.fn();
|
||||
const spec = makeSpec([{ name: 'body' }]);
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -19,14 +20,21 @@ interface UsePanelEditorQuerySyncArgs {
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
|
||||
refetch: () => void;
|
||||
/**
|
||||
* Serialize the live query on save even when unchanged. Set for a new panel,
|
||||
* whose seed query is the builder default (not a real saved query).
|
||||
*/
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
/** Run the current query (Stage & Run / ⌘↵). */
|
||||
runQuery: () => void;
|
||||
/** True when the live builder query differs from the saved query (compared builder-normalized to avoid re-serialization noise). */
|
||||
/** True when the live builder query differs from the saved query. */
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live query into a spec for saving so unstaged edits persist; returns the spec untouched when unchanged. */
|
||||
/** Bake the live query into a spec so unstaged edits persist; unchanged → spec untouched. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
@@ -34,27 +42,36 @@ interface UsePanelEditorQuerySyncApi {
|
||||
|
||||
/**
|
||||
* Bridges the shared (URL-synced) query builder and the V2 editor draft: seeds the
|
||||
* builder from the saved panel, then commits the active query into `draft.spec.queries`
|
||||
* (what the preview fetches) on a query-type/datasource switch and on Stage & Run.
|
||||
* builder from the saved panel, then commits the active query into
|
||||
* `draft.spec.queries` (what the preview fetches) on a query-type/datasource switch
|
||||
* and on Stage & Run.
|
||||
*/
|
||||
export function usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
alwaysSerializeQuery = false,
|
||||
signal,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
|
||||
// A new panel has no saved query: seed from the kind's first supported signal
|
||||
// instead of letting `fromPerses` fall back to the metrics default (which List
|
||||
// doesn't support).
|
||||
const seedQuery = useMemo(
|
||||
() => fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType],
|
||||
() =>
|
||||
savedQueries.length === 0 && signal
|
||||
? initialQueriesMap[signal]
|
||||
: fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType, signal],
|
||||
);
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding any
|
||||
// stale URL query from a prior edit — otherwise the QB and preview diverge and the
|
||||
// dirty baseline gets captured from the URL. After mount the URL syncs normally.
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding a
|
||||
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
|
||||
// baseline is captured from the URL). After mount the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
@@ -64,11 +81,10 @@ export function usePanelEditorQuerySync({
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty check
|
||||
// compares against the SAVED query (`seedQuery`), not the URL-synced staged query,
|
||||
// which can carry stale state across a refresh and make a real switch read as
|
||||
// "unchanged". Unchanged → restore saved queries; changed → commit. Returns whether
|
||||
// the draft changed.
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty
|
||||
// check compares against the SAVED query (`seedQuery`), not the URL-synced
|
||||
// staged query, which can carry stale state across a refresh and read a real
|
||||
// switch as "unchanged". Returns whether the draft changed.
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
@@ -76,7 +92,7 @@ export function usePanelEditorQuerySync({
|
||||
: savedQueries;
|
||||
// No-op guard at the V5 envelope level: equivalent wrappers (bare
|
||||
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
|
||||
// envelopes, so comparing them structurally would falsely dirty the draft.
|
||||
// envelopes, so a structural compare would falsely dirty the draft.
|
||||
const current = draft.spec?.queries ?? [];
|
||||
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
|
||||
return false;
|
||||
@@ -93,8 +109,8 @@ export function usePanelEditorQuerySync({
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type or datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is force-reset to.
|
||||
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is reset to.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
@@ -119,10 +135,10 @@ export function usePanelEditorQuerySync({
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline: the builder's OWN normalized saved query (first non-null
|
||||
// `stagedQuery` after the mount reset). Comparing builder-normalized to
|
||||
// `stagedQuery` after the mount reset) — comparing builder-normalized to
|
||||
// builder-normalized avoids serialization drift reading an untouched query as
|
||||
// modified. Held in state (not a ref) so capture re-triggers `isQueryDirty`;
|
||||
// captured once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
|
||||
// once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
@@ -135,10 +151,10 @@ export function usePanelEditorQuerySync({
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
isQueryDirty
|
||||
isQueryDirty || alwaysSerializeQuery
|
||||
? { ...spec, queries: toPerses(currentQuery, panelType) }
|
||||
: spec,
|
||||
[isQueryDirty, currentQuery, panelType],
|
||||
[isQueryDirty, alwaysSerializeQuery, currentQuery, panelType],
|
||||
);
|
||||
|
||||
return { runQuery, isQueryDirty, buildSaveSpec };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
@@ -7,12 +8,20 @@ import {
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesPanelKindDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
type GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { createPanelOps } from '../../patchOps';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
@@ -22,34 +31,49 @@ interface UsePanelEditorSaveApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel edits via a single RFC-6902 `add` op that replaces the whole panel
|
||||
* spec at `/spec/panels/{panelId}/spec`, so every config-pane edit is saved (not just
|
||||
* title/description). `add` doubles as create-or-replace, avoiding a separate
|
||||
* existence check.
|
||||
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch. Editing: one
|
||||
* `add` op replaces the whole spec. Creating (`isNew`): mints a fresh id and adds
|
||||
* a grid item in the target section. Persists only on save — cancelling never
|
||||
* touches the dashboard.
|
||||
*/
|
||||
export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
|
||||
let ops: DashboardtypesJSONPatchOperationDTO[];
|
||||
if (isNew) {
|
||||
// Resolve the target section against the freshest dashboard we have.
|
||||
const cached =
|
||||
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
layoutIndex,
|
||||
panelId: uuid(),
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
ops = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
);
|
||||
await queryClient.invalidateQueries(dashboardQueryKey);
|
||||
},
|
||||
[dashboardId, panelId, mutateAsync, queryClient],
|
||||
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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,
|
||||
PANEL_TYPE_TO_QUERY_TYPES,
|
||||
type PartialPanelTypes,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
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 panel disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const supported = PANEL_TYPE_TO_QUERY_TYPES[newPanelType] ?? [];
|
||||
const queryType = supported.includes(query.queryType)
|
||||
? query.queryType
|
||||
: supported[0];
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
defaultColumnsForSignal,
|
||||
readSelectFields,
|
||||
writeSelectFields,
|
||||
} from '../ListColumnsEditor/selectFields';
|
||||
|
||||
interface UseSeedNewListColumnsArgs {
|
||||
/** Gate: a brand-new List panel (the only case that should auto-fill columns). */
|
||||
enabled: boolean;
|
||||
/** Default signal for the new panel — its kind's first supported signal. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a brand-new List panel's columns with its default signal's columns so the
|
||||
* Columns control isn't empty on first open. Runs once and only when empty: an
|
||||
* empty selection is a valid "show all fields" state, so existing panels and
|
||||
* user-cleared selections are never touched.
|
||||
*/
|
||||
export function useSeedNewListColumns({
|
||||
enabled,
|
||||
signal,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
}: UseSeedNewListColumnsArgs): void {
|
||||
const seededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || seededRef.current || !signal) {
|
||||
return;
|
||||
}
|
||||
// Only seed when empty — don't clobber a selection that's already present.
|
||||
if (readSelectFields(spec).length > 0) {
|
||||
return;
|
||||
}
|
||||
seededRef.current = true;
|
||||
onChangeSpec(writeSelectFields(spec, defaultColumnsForSignal(signal)));
|
||||
}, [enabled, signal, spec, onChangeSpec]);
|
||||
}
|
||||
@@ -1,52 +1,31 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
defaultColumnsForSignal,
|
||||
readSelectFields,
|
||||
writeSelectFields,
|
||||
} from '../ListColumnsEditor/selectFields';
|
||||
|
||||
import { sanitizeSelectFields } from '../ListColumnsEditor/selectFields';
|
||||
|
||||
/**
|
||||
* The datasource's default List columns (V1 parity), sanitized to the field-key
|
||||
* DTO — the V1 constants carry extra keys (isIndexed) the save contract rejects.
|
||||
* Other signals (metrics) don't produce a list, so they clear the selection.
|
||||
*/
|
||||
function defaultColumnsForSignal(
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) {
|
||||
return sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
if (signal === TelemetrytypesSignalDTO.traces) {
|
||||
return sanitizeSelectFields(
|
||||
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
interface UseSwitchColumnsOnSignalChangeArgs {
|
||||
export interface UseSwitchColumnsOnSignalChangeArgs {
|
||||
/** Gate so the switch only runs for the List kind (the only one with columns). */
|
||||
enabled: boolean;
|
||||
/** The panel's current telemetry signal (logs / traces / metrics). */
|
||||
signal: TelemetrytypesSignalDTO | undefined;
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the List panel's chosen columns to the new datasource's defaults when
|
||||
* the panel's telemetry signal changes (e.g. logs → traces). V1 kept a separate
|
||||
* field list per datasource; V2 stores a single `selectFields`, so columns picked
|
||||
* for one signal are meaningless after switching — replace them with the new
|
||||
* source's sensible defaults (matching V1's logs/traces list defaults).
|
||||
* Swaps the List panel's columns when the telemetry signal changes. V2 stores a
|
||||
* single `selectFields`, so each signal's columns are stashed and restored on
|
||||
* switch-back; a signal seen for the first time gets the datasource defaults (V1
|
||||
* parity).
|
||||
*/
|
||||
export function useSwitchColumnsOnSignalChange({
|
||||
enabled,
|
||||
@@ -55,28 +34,28 @@ export function useSwitchColumnsOnSignalChange({
|
||||
onChangeSpec,
|
||||
}: UseSwitchColumnsOnSignalChangeArgs): void {
|
||||
const prevSignalRef = useRef(signal);
|
||||
const columnsBySignalRef = useRef<
|
||||
Map<string, TelemetrytypesTelemetryFieldKeyDTO[]>
|
||||
>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevSignalRef.current;
|
||||
prevSignalRef.current = signal;
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
// Only an actual switch between two known signals swaps the columns;
|
||||
// transient `undefined` states (mid query-edit) leave the selection intact.
|
||||
if (!prev || !signal || prev === signal) {
|
||||
const prev = prevSignalRef.current;
|
||||
// Track only real signals: a transient `undefined` (mid query-edit) must
|
||||
// not become `prev`, or stash/restore would lose a step.
|
||||
prevSignalRef.current = signal;
|
||||
|
||||
if (!prev || prev === signal) {
|
||||
return;
|
||||
}
|
||||
onChangeSpec({
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: {
|
||||
...spec.plugin.spec,
|
||||
selectFields: defaultColumnsForSignal(signal),
|
||||
},
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO);
|
||||
|
||||
// Stash the leaving signal's columns; restore the entering one's, or its
|
||||
// datasource defaults the first time it's seen.
|
||||
columnsBySignalRef.current.set(prev, readSelectFields(spec));
|
||||
const restored =
|
||||
columnsBySignalRef.current.get(signal) ?? defaultColumnsForSignal(signal);
|
||||
onChangeSpec(writeSelectFields(spec, restored));
|
||||
}, [enabled, signal, spec, onChangeSpec]);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -29,6 +29,8 @@ import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
|
||||
@@ -39,6 +41,10 @@ interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -46,19 +52,26 @@ interface PanelEditorContainerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor page body (rendered full-page by `PanelEditorPage`): a resizable
|
||||
* split with the live preview + query builder on the left and the config pane on the
|
||||
* right. Owns the draft state and the save round-trip.
|
||||
* V2 panel editor page body: a resizable split with the live preview + query
|
||||
* builder on the left and the config pane on the right. Owns the draft state and
|
||||
* the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
});
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
@@ -79,47 +92,44 @@ function PanelEditorContainer({
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDef = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
cancelQuery,
|
||||
refetch,
|
||||
pagination,
|
||||
} = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
});
|
||||
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];
|
||||
|
||||
// Seed the shared query builder from the draft and expose the Stage-&-Run action.
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
// New panel's seed query is the builder default, not a real saved query —
|
||||
// always serialize it on save.
|
||||
alwaysSerializeQuery: isNew,
|
||||
signal: defaultSignal,
|
||||
});
|
||||
|
||||
// Switch the panel's visualization kind in place (reversible per session).
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties.
|
||||
const isDirty = isSpecDirty || isQueryDirty;
|
||||
// The List panel edits its columns below the query builder (V1 parity), so the
|
||||
// editor container resolves the committed query's signal once and shares it
|
||||
// with both the columns control and the datasource-switch effect below.
|
||||
// never false-dirties. A new panel is always savable (you're creating it).
|
||||
const isDirty = isNew || isSpecDirty || isQueryDirty;
|
||||
const isListPanel = fullKind === 'signoz/ListPanel';
|
||||
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
|
||||
// values; cast at this boundary (as ConfigPane does) so the columns editor's
|
||||
// field-key lookup is typed.
|
||||
const listSignal = getBuilderQueries(spec.queries)[0]?.signal as
|
||||
| TelemetrytypesSignalDTO
|
||||
| undefined;
|
||||
const listSignal =
|
||||
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
|
||||
TelemetrytypesSignalDTO.logs;
|
||||
|
||||
// When the List panel's datasource changes, swap its columns to the new
|
||||
// source's defaults (V1 kept a per-datasource field list; V2 has one
|
||||
// `selectFields`). Driven by the committed query's signal, so it lives in the
|
||||
// editor container alongside the query sync — ConfigPane stays presentational.
|
||||
// Swap the List panel's columns to the new signal's defaults on signal change
|
||||
// (V1 had a per-signal field list; V2 has one `selectFields`).
|
||||
useSwitchColumnsOnSignalChange({
|
||||
enabled: isListPanel,
|
||||
signal: listSignal,
|
||||
@@ -127,6 +137,14 @@ function PanelEditorContainer({
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Seed a new List panel's default columns so the Columns control isn't empty.
|
||||
useSeedNewListColumns({
|
||||
enabled: isNew && isListPanel,
|
||||
signal: defaultSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
@@ -166,17 +184,19 @@ function PanelEditorContainer({
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDef={panelDef}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
{panelDefinition && (
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
@@ -210,6 +230,7 @@ function PanelEditorContainer({
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
|
||||
// New (unsaved) panels share a fixed id segment, carrying kind + target section
|
||||
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
|
||||
// id is generated on save.
|
||||
export const NEW_PANEL_ID = 'new';
|
||||
const PANEL_KIND_PARAM = 'panelKind';
|
||||
const LAYOUT_INDEX_PARAM = 'layoutIndex';
|
||||
|
||||
/** Query string (incl. leading `?`) for the new-panel editor route. */
|
||||
export function newPanelSearch(
|
||||
panelKind: PanelKind,
|
||||
layoutIndex?: number,
|
||||
): string {
|
||||
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: panelKind });
|
||||
if (layoutIndex !== undefined) {
|
||||
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
|
||||
}
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PanelKind a `panel/new` route is creating, or null when the id isn't the
|
||||
* new-panel sentinel or the `panelKind` param is missing/unknown (stale link).
|
||||
*/
|
||||
export function parseNewPanelKind(
|
||||
panelId: string,
|
||||
search: string,
|
||||
): PanelKind | null {
|
||||
if (panelId !== NEW_PANEL_ID) {
|
||||
return null;
|
||||
}
|
||||
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
|
||||
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
|
||||
}
|
||||
|
||||
/** Target section index for a new panel, or undefined when unset/invalid. */
|
||||
export function parseNewPanelLayoutIndex(search: string): number | undefined {
|
||||
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
|
||||
if (raw === null || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1,27 +1,39 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Clock, RotateCw } from '@signozhq/icons';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
import PanelMessage from '../PanelMessage/PanelMessage';
|
||||
|
||||
interface NoDataProps {
|
||||
/** Message to display. Defaults to "No data". */
|
||||
label?: string;
|
||||
/** Title override. Defaults to the time-range empty-state copy. */
|
||||
title?: string;
|
||||
/** Description override. Defaults to the "widen the range" hint. */
|
||||
description?: string;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared empty-state for panel renderers, shown when a query resolves but
|
||||
* returns nothing to plot. Centred in the panel body so every panel kind
|
||||
* surfaces the same "No data" affordance instead of each renderer (or its
|
||||
* underlying chart) inventing its own copy and casing.
|
||||
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
|
||||
* kind surfaces the same "no data" affordance when a query returns nothing.
|
||||
*/
|
||||
function NoData({
|
||||
label = 'No data',
|
||||
title = 'No data in this time range',
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
onRetry,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.noData} data-testid={testId}>
|
||||
<Typography.Text className={styles.noDataText}>{label}</Typography.Text>
|
||||
</div>
|
||||
<PanelMessage
|
||||
icon={<Clock size={18} />}
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined
|
||||
}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Centred, vertically-stacked panel state (no query / no data / error). Fills
|
||||
// the panel body below the header and centres its content both axes.
|
||||
.message {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Muted glyph in a soft tinted disc so the icon reads as decorative chrome
|
||||
// rather than an actionable control.
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
color: var(--l2-foreground);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.iconDanger {
|
||||
color: var(--bg-cherry-500);
|
||||
background: var(--bg-cherry-500-transparent, rgba(231, 64, 64, 0.12));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
max-width: 280px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './PanelMessage.module.scss';
|
||||
|
||||
export interface PanelMessageAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
/** Optional leading icon for the action button. */
|
||||
icon?: ReactElement;
|
||||
}
|
||||
|
||||
interface PanelMessageProps {
|
||||
/** Glyph shown above the title — sets the state's visual identity. */
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
/** Secondary line explaining the state / suggesting a next step. */
|
||||
description?: string;
|
||||
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
|
||||
action?: PanelMessageAction;
|
||||
/** `danger` tints the icon for failure states; `neutral` for empty states. */
|
||||
tone?: 'neutral' | 'danger';
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared centred panel state (icon + title + optional description/action) so the
|
||||
* no-query / no-data / error states stay visually consistent across call sites.
|
||||
*/
|
||||
function PanelMessage({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
tone = 'neutral',
|
||||
'data-testid': testId,
|
||||
}: PanelMessageProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.message} data-testid={testId}>
|
||||
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
|
||||
{icon}
|
||||
</div>
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{description && (
|
||||
<Typography.Text className={styles.description}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={action.icon}
|
||||
onClick={action.onClick}
|
||||
className={styles.action}
|
||||
data-testid={testId ? `${testId}-action` : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelMessage;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import PanelMessage from '../PanelMessage';
|
||||
|
||||
describe('PanelMessage', () => {
|
||||
it('renders the icon, title and description', () => {
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={<svg data-testid="icon" />}
|
||||
title="Nothing to visualize yet"
|
||||
description="This panel has no query."
|
||||
data-testid="panel-state"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing to visualize yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('This panel has no query.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no action button when no action is provided', () => {
|
||||
render(
|
||||
<PanelMessage icon={null} title="No data" data-testid="panel-state" />,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('panel-state-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the action button and fires onClick when pressed', () => {
|
||||
const onClick = jest.fn();
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={null}
|
||||
title="Couldn’t load panel data"
|
||||
action={{ label: 'Retry', onClick }}
|
||||
data-testid="panel-error"
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('panel-error-action');
|
||||
expect(button).toHaveTextContent('Retry');
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
@@ -42,9 +43,8 @@ function BarPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -138,7 +138,7 @@ function BarPanelRenderer({
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: 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 } },
|
||||
|
||||
@@ -26,6 +26,7 @@ function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
@@ -34,9 +35,8 @@ function HistogramPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -113,7 +113,7 @@ function HistogramPanelRenderer({
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -26,14 +26,14 @@ import { useListRowInteraction } from './useListRowInteraction';
|
||||
|
||||
import styles from './ListPanel.module.scss';
|
||||
|
||||
// `body` flexes to fill the remaining table width (module-level so the resize
|
||||
// hook's memo dependency stays referentially stable across renders).
|
||||
// `body` flexes to fill remaining width; module-level to stay referentially stable for the resize hook's memo.
|
||||
const BODY_FLEX_COLUMNS = ['body'];
|
||||
|
||||
function ListPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
pagination,
|
||||
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
|
||||
@@ -42,16 +42,14 @@ function ListPanelRenderer({
|
||||
const { height } = useResizeObserver(containerRef);
|
||||
const { scrollY } = useMemo(() => computeTableLayout(height), [height]);
|
||||
|
||||
// The registry guarantees this Renderer only runs for `signoz/ListPanel`, so
|
||||
// the cast is a documented boundary narrowing.
|
||||
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
|
||||
const spec = useMemo<DashboardtypesListPanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesListPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
// Telemetry signal of the panel's first builder query — drives data flattening,
|
||||
// per-signal cell rendering, and the row-click behavior (log drawer vs trace
|
||||
// navigation). Cast at this boundary (the query carries the same string values).
|
||||
// Telemetry signal of the first builder query; drives flattening, cell rendering,
|
||||
// and row-click behavior. Cast is safe — the query carries the same string values.
|
||||
const signal = useMemo(
|
||||
() =>
|
||||
(getBuilderQueries(panel.spec.queries)[0]
|
||||
@@ -83,7 +81,7 @@ function ListPanelRenderer({
|
||||
[table, signal, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
// User-resizable columns, persisted per panel; `body` flexes to fill width.
|
||||
// User-resizable columns, persisted per panel.
|
||||
const { columns: resizableColumns, components } = useResizableColumns({
|
||||
panelId,
|
||||
columns,
|
||||
@@ -92,8 +90,7 @@ function ListPanelRenderer({
|
||||
|
||||
const dataSource = useMemo(() => table?.rows ?? [], [table]);
|
||||
|
||||
// Header search filters the current page client-side (V1 parity); paging
|
||||
// across pages is server-side via `pagination`.
|
||||
// Header search filters the current page client-side (V1 parity); cross-page paging is server-side via `pagination`.
|
||||
const filteredDataSource = useMemo(
|
||||
() => filterTableRows(dataSource, searchTerm),
|
||||
[dataSource, searchTerm],
|
||||
@@ -115,8 +112,7 @@ function ListPanelRenderer({
|
||||
[spec.selectFields],
|
||||
);
|
||||
|
||||
// Show the footer whenever the panel pages server-side (no explicit query
|
||||
// limit), so the page-size picker is always reachable — V1 parity.
|
||||
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
|
||||
const showPager = !!pagination;
|
||||
|
||||
return (
|
||||
@@ -126,7 +122,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -142,9 +138,7 @@ function ListPanelRenderer({
|
||||
components={components}
|
||||
dataSource={filteredDataSource}
|
||||
pagination={false}
|
||||
// Scroll the body vertically only — no `x: 'max-content'`, which
|
||||
// forced a content-width min and pushed columns off-screen;
|
||||
// `tableLayout="fixed"` fits them to the available width.
|
||||
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
|
||||
scroll={{ y: scrollY }}
|
||||
onRow={onRow}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type DashboardtypesListPanelSpecDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
@@ -10,16 +9,19 @@ import type {
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { fireEvent, render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import ListPanelRenderer from '../Renderer';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesListPanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
): PanelOfKind<'signoz/ListPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/ListPanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
} as unknown as PanelOfKind<'signoz/ListPanel'>;
|
||||
}
|
||||
|
||||
// V5 raw response: one result carrying flattened log rows.
|
||||
@@ -49,9 +51,9 @@ const emptyData: PanelQueryData = {
|
||||
};
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
props: Partial<PanelRendererProps<'signoz/ListPanel'>>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
const baseProps: PanelRendererProps<'signoz/ListPanel'> = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
kind: 'signoz/ListPanel',
|
||||
displayName: 'List',
|
||||
Renderer,
|
||||
// Raw records come from logs and traces; metrics don't produce row data.
|
||||
supportedSignals: [DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
sections,
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// List columns are edited below the query builder, not in the config pane, so
|
||||
// only Context Links shows here.
|
||||
export const sections: SectionConfig[] = [{ kind: 'contextLinks' }];
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,10 +16,10 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ function NumberPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" />
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
displayName: 'Number',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: 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' },
|
||||
|
||||
@@ -20,13 +20,13 @@ function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -25,10 +25,10 @@ function TablePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) and the
|
||||
// header stays pinned while the body scrolls.
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { height } = useResizeObserver(containerRef);
|
||||
const { pageSize, scrollY } = useMemo(
|
||||
@@ -36,12 +36,9 @@ function TablePanelRenderer({
|
||||
[height],
|
||||
);
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/TablePanel'`, so the cast is a
|
||||
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
|
||||
// produce a fresh object on each render.
|
||||
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
|
||||
const spec = useMemo<DashboardtypesTablePanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTablePanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -92,15 +89,13 @@ function TablePanelRenderer({
|
||||
[table],
|
||||
);
|
||||
|
||||
// Header search filters rows client-side (V1 parity). Falls back to the full
|
||||
// set when the term is empty, so non-searching tables pay nothing.
|
||||
// Header search filters rows client-side (V1 parity); empty term returns the full set, so non-searching tables pay nothing.
|
||||
const filteredDataSource = useMemo(
|
||||
() => filterTableRows(dataSource, searchTerm),
|
||||
[dataSource, searchTerm],
|
||||
);
|
||||
|
||||
// Keep pagination in range as the filtered set shrinks: a new term snaps back
|
||||
// to the first page so the user never lands on a now-empty page.
|
||||
// Snap back to page 1 on a new search term so the filtered set never lands on a now-empty page.
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => setPage(1), [searchTerm]);
|
||||
|
||||
@@ -111,7 +106,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
type DashboardtypesTablePanelSpecDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -7,16 +6,19 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import TablePanelRenderer from '../Renderer';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesTablePanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
): PanelOfKind<'signoz/TablePanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
}
|
||||
|
||||
// V5 scalar response: one joined result with a group column + an aggregation column.
|
||||
@@ -60,9 +62,9 @@ const emptyData: PanelQueryData = {
|
||||
};
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
props: Partial<PanelRendererProps<'signoz/TablePanel'>>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
const baseProps: PanelRendererProps<'signoz/TablePanel'> = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
displayName: 'Table',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
|
||||
actions: {
|
||||
view: 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' },
|
||||
|
||||
@@ -32,6 +32,7 @@ function TimeSeriesPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
@@ -42,10 +43,8 @@ function TimeSeriesPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
// Memoized so the `?? {}` fallback doesn't produce a fresh object each render.
|
||||
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -140,7 +139,7 @@ function TimeSeriesPanelRenderer({
|
||||
data-testid="time-series-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: 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 } },
|
||||
|
||||
@@ -11,9 +11,7 @@ import type {
|
||||
} from './types/panelDefinition';
|
||||
import { PanelKind } from './types/panelKind';
|
||||
|
||||
// Pure assembly: each kind owns its own PanelDefinition (see
|
||||
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
|
||||
// single entry below — no other central file needs editing.
|
||||
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
|
||||
export const PANELS: PanelRegistry = {
|
||||
[TimeSeries.kind]: TimeSeries,
|
||||
[BarChart.kind]: BarChart,
|
||||
@@ -24,15 +22,8 @@ export const PANELS: PanelRegistry = {
|
||||
[List.kind]: List,
|
||||
};
|
||||
|
||||
export function getPanelDefinition(
|
||||
kind: PanelKind,
|
||||
): RenderablePanelDefinition | undefined {
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
// The registry is correlated by kind, so a string lookup yields a union over
|
||||
// every kind's exactly-typed definition. The renderer cannot be validated
|
||||
// against that union at the JSX boundary, so widen to the kind-agnostic
|
||||
// surface here — the single, intentional cast for the whole panel system.
|
||||
return PANELS[kind] as unknown as RenderablePanelDefinition | undefined;
|
||||
export function getPanelDefinition(kind: PanelKind): RenderablePanelDefinition {
|
||||
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
|
||||
// prop surface (a per-kind renderer can't be statically validated against the union).
|
||||
return PANELS[kind] as RenderablePanelDefinition;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
@@ -35,12 +35,13 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
supportedSignals: DataSource[];
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
// Indexing with a literal kind yields that kind's exactly-typed PanelDefinition.
|
||||
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
|
||||
// Total over PanelKind: every kind must be registered (missing → compile error),
|
||||
// so getPanelDefinition never returns undefined.
|
||||
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
|
||||
|
||||
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
|
||||
// getPanelDefinition resolves to this, concentrating the unavoidable cast in one
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -22,37 +26,59 @@ export interface DashboardPreference {
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
// Kind-agnostic props every renderer receives. Kind-specific interaction props
|
||||
// are layered on per-kind by PanelRendererProps<K>.
|
||||
/** Kind-agnostic props every renderer receives; kind-specific interactions are layered on by PanelRendererProps<K>. */
|
||||
export interface BaseRendererProps {
|
||||
panelId: string;
|
||||
/**
|
||||
* The whole perses panel — renderers derive `spec` and `queries` from this.
|
||||
* Required: the render boundary only mounts a renderer once the panel and its
|
||||
* kind are resolved, so a renderer never sees an absent panel.
|
||||
*/
|
||||
/** The whole panel — renderers derive `spec` and `queries` from it. Required: the render boundary only mounts once panel + kind resolve. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the panel query; wired to the no-data Retry affordance. Optional so standalone call sites (e.g. the editor preview) can omit it. */
|
||||
refetch?: () => void;
|
||||
/** Gate for the drill-down right-click menu. Off by default in V2. */
|
||||
enableDrillDown?: boolean;
|
||||
/** Render context (dashboard widget vs. standalone vs. editor); see PanelMode. */
|
||||
panelMode: PanelMode;
|
||||
/** Dashboard-level preferences propagated to every panel; shell resolves, renderer consumes. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/**
|
||||
* Free-text filter from the header search box, applied client-side. Only
|
||||
* meaningful for kinds that declare `actions.search`; others ignore it.
|
||||
*/
|
||||
/** Free-text header filter, applied client-side. Only meaningful for kinds that declare `actions.search`. */
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles. Present only for raw/list panels; others ignore it. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
// Renderer props for a specific kind: shared base plus that kind's interaction
|
||||
// surface. Indexing PanelInteractionMap forces it to cover every PanelKind; the
|
||||
// default K = PanelKind yields the widest surface (a union over all kinds).
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> =
|
||||
BaseRendererProps & PanelInteractionMap[K];
|
||||
// The single plugin variant for kind K, picked from the generated plugin union.
|
||||
// Distributes over the union, coercing each member's nominal kind-enum to its
|
||||
// string value (`${VK & string}`) to match K. K = PanelKind recovers the full union.
|
||||
type PluginOfKind<K extends PanelKind> =
|
||||
DashboardtypesPanelPluginDTO extends infer V
|
||||
? V extends { kind: infer VK }
|
||||
? `${VK & string}` extends K
|
||||
? V
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
// The panel narrowed to kind K: the wire DTO with `plugin` (and `plugin.spec`)
|
||||
// fixed to K's single variant, so a renderer reads `panel.spec.plugin.spec` as
|
||||
// its own spec type with no cast.
|
||||
export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
|
||||
DashboardtypesPanelDTO,
|
||||
'spec'
|
||||
> & {
|
||||
spec: Omit<DashboardtypesPanelSpecDTO, 'plugin'> & {
|
||||
plugin: PluginOfKind<K>;
|
||||
};
|
||||
};
|
||||
|
||||
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
|
||||
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
|
||||
// and only the gestures it supports. The default K = PanelKind is the widest surface.
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> = Omit<
|
||||
BaseRendererProps,
|
||||
'panel'
|
||||
> & {
|
||||
panel: PanelOfKind<K>;
|
||||
} & PanelInteractionMap[K];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { sections as barSections } from '../../kinds/BarChartPanel/sections';
|
||||
import { sections as histogramSections } from '../../kinds/HistogramPanel/sections';
|
||||
import { sections as listSections } from '../../kinds/ListPanel/sections';
|
||||
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
import { buildDefaultPluginSpec } from '../buildDefaultPluginSpec';
|
||||
|
||||
describe('buildDefaultPluginSpec', () => {
|
||||
it('seeds the TimeSeries dropdowns/segmented controls with their renderer defaults', () => {
|
||||
expect(buildDefaultPluginSpec(timeSeriesSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode: DashboardtypesFillModeDTO.none,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits chartAppearance for a kind that does not declare it (Bar)', () => {
|
||||
expect(buildDefaultPluginSpec(barSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds only the legend for Histogram (no visualization section)', () => {
|
||||
expect(buildDefaultPluginSpec(histogramSections)).toStrictEqual({
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty spec for a kind with no seeded controls (List)', () => {
|
||||
expect(buildDefaultPluginSpec(listSections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('does not seed controls that already show a clear default', () => {
|
||||
// `axes` and `formatting` stay unset — their empty state is the chart default.
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('only seeds the legend position when the kind exposes that control', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: 'legend', controls: { colors: true } },
|
||||
];
|
||||
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { buildDefaultQueries } from '../buildDefaultQueries';
|
||||
|
||||
describe('buildDefaultQueries', () => {
|
||||
it('seeds a List panel with a runnable logs query ordered by timestamp desc', () => {
|
||||
const queries = buildDefaultQueries('signoz/ListPanel');
|
||||
|
||||
expect(queries).toHaveLength(1);
|
||||
// orderBy timestamp desc must survive serialization so the preview opens
|
||||
// pre-sorted (V1 parity).
|
||||
const serialized = JSON.stringify(queries);
|
||||
expect(serialized).toContain('timestamp');
|
||||
expect(serialized).toContain('desc');
|
||||
expect(serialized.toLowerCase()).toContain('logs');
|
||||
});
|
||||
|
||||
it('seeds no query for non-List kinds (they seed from the builder)', () => {
|
||||
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
|
||||
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelQueryType } from '../getPanelQueryType';
|
||||
|
||||
function panelWithEnvelopes(envelopes: unknown[]): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'P' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: envelopes.length
|
||||
? [
|
||||
{
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('getPanelQueryType', () => {
|
||||
it('returns undefined when the panel has no query', () => {
|
||||
expect(getPanelQueryType(panelWithEnvelopes([]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports the builder mode for builder queries', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'builder_query', spec: { signal: 'traces', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.QUERY_BUILDER);
|
||||
});
|
||||
|
||||
it('reports PromQL when a promql envelope is present', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'promql', spec: { query: 'up', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.PROM);
|
||||
});
|
||||
|
||||
it('reports ClickHouse when a clickhouse_sql envelope is present', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'clickhouse_sql', spec: { query: 'SELECT 1', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.CLICKHOUSE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SectionConfig, SectionSpecMap } from '../types/sections';
|
||||
|
||||
/**
|
||||
* Seeded plugin-spec slices, typed as canonical section slices so each value is
|
||||
* checked against its DTO. A partial cross-section, not any single kind's spec,
|
||||
* so the union cast stays localized to `createDefaultPanel`.
|
||||
*/
|
||||
export interface DefaultPluginSpec {
|
||||
visualization?: SectionSpecMap['visualization'];
|
||||
legend?: SectionSpecMap['legend'];
|
||||
chartAppearance?: SectionSpecMap['chartAppearance'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds per-kind config defaults derived from the kind's declared `sections` so the
|
||||
* config pane opens populated. Values equal the renderer fallbacks (display only).
|
||||
* Controls whose empty state already IS the default are left unset.
|
||||
*/
|
||||
export function buildDefaultPluginSpec(
|
||||
sections: SectionConfig[],
|
||||
): DefaultPluginSpec {
|
||||
const spec: DefaultPluginSpec = {};
|
||||
|
||||
sections.forEach((section) => {
|
||||
switch (section.kind) {
|
||||
case 'visualization':
|
||||
if (section.controls.timePreference) {
|
||||
spec.visualization = {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'legend':
|
||||
if (section.controls.position) {
|
||||
spec.legend = { position: DashboardtypesLegendPositionDTO.bottom };
|
||||
}
|
||||
break;
|
||||
case 'chartAppearance': {
|
||||
const chartAppearance: SectionSpecMap['chartAppearance'] = {};
|
||||
if (section.controls.lineStyle) {
|
||||
chartAppearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
}
|
||||
if (section.controls.lineInterpolation) {
|
||||
chartAppearance.lineInterpolation =
|
||||
DashboardtypesLineInterpolationDTO.spline;
|
||||
}
|
||||
if (section.controls.fillMode) {
|
||||
chartAppearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
}
|
||||
if (Object.keys(chartAppearance).length > 0) {
|
||||
spec.chartAppearance = chartAppearance;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return spec;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { listViewInitialLogQuery, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
|
||||
|
||||
/** Seed query for a new panel. Only List needs one (logs, timestamp desc) so its
|
||||
* preview runs on open; other kinds start empty and seed from the builder. */
|
||||
export function buildDefaultQueries(kind: PanelKind): DashboardtypesQueryDTO[] {
|
||||
if (PANEL_KIND_TO_PANEL_TYPE[kind] === PANEL_TYPES.LIST) {
|
||||
return toPerses(listViewInitialLogQuery, PANEL_TYPES.LIST);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import { deriveQueryType } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
/**
|
||||
* The authoring mode (builder / ClickHouse / PromQL) of a panel's query. Returns
|
||||
* `undefined` when the panel has no query yet so callers can hide query-type chrome
|
||||
* (e.g. the editor preview's "Plotted with" tag) rather than defaulting to builder.
|
||||
*/
|
||||
export function getPanelQueryType(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): EQueryType | undefined {
|
||||
const envelopes = toQueryEnvelopes(panel.spec.queries);
|
||||
if (envelopes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return deriveQueryType(envelopes);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
|
||||
import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
|
||||
import landscapeUrl from '@/assets/Icons/landscape.svg';
|
||||
|
||||
import { useCreatePanel } from '../../hooks/useCreatePanel';
|
||||
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import styles from './DashboardEmptyState.module.scss';
|
||||
|
||||
interface DashboardEmptyStateProps {
|
||||
@@ -15,9 +16,8 @@ interface DashboardEmptyStateProps {
|
||||
function DashboardEmptyState({
|
||||
canAddPanel,
|
||||
}: DashboardEmptyStateProps): JSX.Element {
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
|
||||
return (
|
||||
<section className={styles.emptyState}>
|
||||
@@ -48,7 +48,7 @@ function DashboardEmptyState({
|
||||
<Button
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => setIsPanelTypeSelectionModalOpen(true)}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
@@ -56,6 +56,11 @@ function DashboardEmptyState({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
DashboardtypesPanelPluginKindDTO as PanelKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
@@ -12,15 +10,12 @@ import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/us
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import PanelBody from './PanelBody/PanelBody';
|
||||
import UnsupportedPanelBody from './PanelBody/UnsupportedPanelBody';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
/**
|
||||
* Layout context for the panel actions menu — pure data, present only in
|
||||
* editable mode. No callbacks: the menu resolves its own mutations from
|
||||
* store-backed hooks (useDeletePanel / useMovePanelToSection), and edit is
|
||||
* URL-driven (useOpenPanelEditor).
|
||||
* Layout context for the panel actions menu — present only in editable mode. No
|
||||
* callbacks: the menu resolves its own mutations from store-backed hooks.
|
||||
*/
|
||||
export interface PanelActionsConfig {
|
||||
currentLayoutIndex: number;
|
||||
@@ -37,10 +32,8 @@ interface PanelProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A single dashboard panel: chrome (header) + content (body). Thin orchestrator
|
||||
* — data fetching lives in `usePanelQuery`, cross-panel interactions in
|
||||
* `usePanelInteractions`, and the loading/error/chart state machine in
|
||||
* `PanelBody`.
|
||||
* A single dashboard panel (header + body). Thin orchestrator: fetching lives in
|
||||
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
|
||||
*/
|
||||
function Panel({
|
||||
panel,
|
||||
@@ -48,17 +41,15 @@ function Panel({
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
const name = panel.spec.display?.name;
|
||||
const name = panel.spec.display.name;
|
||||
const description = panel.spec.display?.description;
|
||||
const fullKind = panel.spec.plugin?.kind as unknown as PanelKind;
|
||||
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel.spec.queries?.length ?? 0;
|
||||
const fullKind = panel.spec.plugin.kind;
|
||||
|
||||
// A per-panel relative time preference (anything other than global_time) is
|
||||
// surfaced as a pill in the header. `visualization` is common to every
|
||||
// plugin-spec variant — localized cast reads it without narrowing on 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.
|
||||
const timePreference = (
|
||||
panel.spec.plugin?.spec as
|
||||
panel.spec.plugin.spec as
|
||||
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
|
||||
| undefined
|
||||
)?.visualization?.timePreference;
|
||||
@@ -66,41 +57,28 @@ function Panel({
|
||||
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
|
||||
// Header search: only kinds that declare it (e.g. tables) render the box; the
|
||||
// term is owned here and threaded to both the header (input) and the renderer
|
||||
// (filter), the two being siblings under this orchestrator.
|
||||
// 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).
|
||||
const searchable = !!panelDefinition?.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: only fetch once the section is on screen (undefined → treat as
|
||||
// visible) and a renderer exists for the kind.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
const { data, isFetching, error, refetch, pagination } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) {
|
||||
return name;
|
||||
}
|
||||
return (
|
||||
<TooltipSimple title={description}>
|
||||
<span>{name}</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}, [name, description]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<PanelHeader
|
||||
title={headerTitle}
|
||||
name={name}
|
||||
description={description}
|
||||
panelId={panelId}
|
||||
panelKind={fullKind}
|
||||
isFetching={isFetching}
|
||||
@@ -112,13 +90,13 @@ function Panel({
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
{panelDefinition ? (
|
||||
{panelDefinition && (
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoading={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
@@ -126,9 +104,6 @@ function Panel({
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
// TODO: remove this after all panel kinds are supported
|
||||
<UnsupportedPanelBody kind={kind} queryCount={queryCount} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Generic centred body — used by the loading indicator and the
|
||||
// unsupported-kind fallback.
|
||||
// Generic centred body — used by the loading indicator.
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -11,10 +10,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bodyKind {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// Container for the rendered chart — fills the panel below the header and lets
|
||||
// the chart shrink (min-* 0) so it resizes with the grid cell.
|
||||
.chartContainer {
|
||||
@@ -23,26 +18,3 @@
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Error state — shown only when there's no stale data to fall back to.
|
||||
.error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
max-width: 90%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Loader, TriangleAlert } from '@signozhq/icons';
|
||||
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 { 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';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
@@ -15,8 +15,7 @@ import { panelStatusFromError } from '../PanelStatus/utils';
|
||||
import styles from './PanelBody.module.scss';
|
||||
|
||||
interface PanelBodyProps {
|
||||
/** Resolved renderer for the panel kind — always present (`Panel` renders the
|
||||
* unsupported fallback itself when none is registered). */
|
||||
/** Resolved renderer for the panel kind (`Panel` handles the unsupported case). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
@@ -36,13 +35,8 @@ interface PanelBodyProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a panel whose kind has a registered renderer, as an explicit state
|
||||
* machine:
|
||||
*
|
||||
* error + no data → error message with retry
|
||||
* first load (no data) → loading indicator
|
||||
* otherwise → the kind's renderer (owns its own "No Data" state and keeps
|
||||
* stale data mounted during background refetches)
|
||||
* Renders a panel's body as a state machine: not-configured / error+no-data /
|
||||
* first-load / renderer. The renderer keeps stale data mounted across refetches.
|
||||
*/
|
||||
function PanelBody({
|
||||
panelDefinition,
|
||||
@@ -58,25 +52,39 @@ function PanelBody({
|
||||
searchTerm,
|
||||
pagination,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// react-query keeps the previous response during background refetches, so
|
||||
// `data.response` presence is the "have something to show" signal — surface a
|
||||
// hard failure only when there's nothing to keep on screen.
|
||||
// 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.
|
||||
const hasData = !!data.response;
|
||||
|
||||
// Not-configured panel: no runnable query, so nothing to error/load on.
|
||||
if (!hasRunnableQueries(panel.spec.queries)) {
|
||||
return (
|
||||
<PanelMessage
|
||||
icon={<SquarePlus size={18} />}
|
||||
title="Nothing to visualize yet"
|
||||
description="This panel has no query. Add one to start plotting data."
|
||||
data-testid="panel-no-query"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !hasData) {
|
||||
// Parse the API error like the header popover does, so the body shows the
|
||||
// backend message (not the raw axios "status code 4xx").
|
||||
// Parse the API error (as the header popover does) to show the backend
|
||||
// message, not the raw axios "status code 4xx".
|
||||
const errorDetail = panelStatusFromError(error);
|
||||
return (
|
||||
<div className={styles.error} data-testid="panel-error">
|
||||
<TriangleAlert size={20} className={styles.errorIcon} />
|
||||
<Typography.Text className={styles.errorMessage}>
|
||||
{errorDetail?.message || 'Failed to load panel data'}
|
||||
</Typography.Text>
|
||||
<Button variant="outlined" color="secondary" onClick={refetch}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
<PanelMessage
|
||||
icon={<TriangleAlert size={18} />}
|
||||
tone="danger"
|
||||
title="Couldn’t load panel data"
|
||||
description={errorDetail?.message || 'Something went wrong while fetching.'}
|
||||
action={{
|
||||
label: 'Retry',
|
||||
onClick: refetch,
|
||||
icon: <RotateCw size={14} />,
|
||||
}}
|
||||
data-testid="panel-error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,6 +106,7 @@ function PanelBody({
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
enableDrillDown={false}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import styles from './PanelBody.module.scss';
|
||||
|
||||
interface UnsupportedPanelBodyProps {
|
||||
/** Short, signoz-prefix-stripped panel kind (e.g. "TablePanel"). */
|
||||
kind: string;
|
||||
queryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body shown when no renderer is registered for the panel's kind. Split out so
|
||||
* `PanelBody` only ever runs with a resolved renderer.
|
||||
*/
|
||||
function UnsupportedPanelBody({
|
||||
kind,
|
||||
queryCount,
|
||||
}: UnsupportedPanelBodyProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-unknown-kind-fallback">
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet supported
|
||||
in V2
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnsupportedPanelBody;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import PanelBody from '../PanelBody';
|
||||
|
||||
// Stub the renderer so these tests focus on PanelBody's state machine.
|
||||
const MockRenderer = (): JSX.Element => <div data-testid="mock-renderer" />;
|
||||
|
||||
const panelDefinition = {
|
||||
Renderer: MockRenderer,
|
||||
} as unknown as RenderablePanelDefinition;
|
||||
|
||||
function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'P' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
panelDefinition,
|
||||
panelId: 'p1',
|
||||
data: {} as PanelQueryData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
};
|
||||
|
||||
describe('PanelBody', () => {
|
||||
it('shows the not-configured state when the panel has no runnable query', () => {
|
||||
render(<PanelBody {...baseProps} panel={panelWith([])} />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-query')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing to visualize yet')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the kind renderer once a runnable query is present', () => {
|
||||
const panel = panelWith([
|
||||
{
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{ type: 'builder_query', spec: { signal: 'traces', name: 'A' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<PanelBody {...baseProps} panel={panel} />);
|
||||
|
||||
expect(screen.getByTestId('mock-renderer')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-no-query')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -53,3 +53,8 @@
|
||||
border: 1px solid var(--l3-border);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.descriptionTooltip {
|
||||
max-width: 240px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
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 { Loader } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
|
||||
@@ -18,9 +18,10 @@ import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
name: string;
|
||||
description?: string;
|
||||
panelId: string;
|
||||
/** Full plugin kind — drives kind-gated menu actions; */
|
||||
/** Full plugin kind — drives kind-gated menu actions. */
|
||||
panelKind: PanelKind;
|
||||
/** Background refresh in flight — shows a spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
@@ -38,11 +39,18 @@ interface PanelHeaderProps {
|
||||
searchTerm?: string;
|
||||
/** Pushes a new search term up to the shell. */
|
||||
onSearchChange?: (value: string) => void;
|
||||
/**
|
||||
* Suppress the actions menu entirely — for the editor preview, where
|
||||
* panel-level actions don't apply (some survive their gates without
|
||||
* `panelActions`, so omitting it isn't enough).
|
||||
*/
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
|
||||
function PanelHeader({
|
||||
title,
|
||||
name,
|
||||
description,
|
||||
panelId,
|
||||
panelKind,
|
||||
isFetching,
|
||||
@@ -53,6 +61,7 @@ function PanelHeader({
|
||||
searchable,
|
||||
searchTerm = '',
|
||||
onSearchChange,
|
||||
hideActions,
|
||||
}: PanelHeaderProps): JSX.Element {
|
||||
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
|
||||
|
||||
@@ -64,7 +73,20 @@ function PanelHeader({
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
|
||||
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
|
||||
{description && (
|
||||
<TooltipSimple
|
||||
title={description}
|
||||
arrow
|
||||
tooltipContentProps={{ className: styles.descriptionTooltip }}
|
||||
>
|
||||
<Info
|
||||
className={styles.headerInfoIcon}
|
||||
size={14}
|
||||
data-testid="panel-header-info-icon"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{isFetching && (
|
||||
<Loader
|
||||
size={12}
|
||||
@@ -73,8 +95,8 @@ function PanelHeader({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* `panel-no-drag` opts this region out of the grid drag handle so the
|
||||
actions menu is clickable instead of starting a panel drag. */}
|
||||
{/* `panel-no-drag` opts this region out of the drag handle so clicks hit
|
||||
the controls instead of starting a panel drag. */}
|
||||
<div className={cx('panel-no-drag', styles.actions)}>
|
||||
{searchable && onSearchChange && (
|
||||
<PanelHeaderSearch value={searchTerm ?? ''} onChange={onSearchChange} />
|
||||
@@ -91,11 +113,13 @@ function PanelHeader({
|
||||
<PanelStatusPopover variant="warning" detail={warningDetail} />
|
||||
)}
|
||||
{/* Renders nothing when no action survives its gates (kind/role/context). */}
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
panelKind={panelKind}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
{!hideActions && (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
panelKind={panelKind}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from './constants';
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
|
||||
interface PanelTypeSelectionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (pluginKind: string) => void;
|
||||
onSelect: (pluginKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
function PanelTypeSelectionModal({
|
||||
|
||||
@@ -15,7 +15,11 @@ export const PANEL_TYPES: PanelType[] = [
|
||||
label: 'Time Series',
|
||||
icon: <ChartLine size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
|
||||
{
|
||||
pluginKind: 'signoz/NumberPanel',
|
||||
label: 'Number',
|
||||
icon: <Hash size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
|
||||
{
|
||||
pluginKind: 'signoz/BarChartPanel',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
export interface PanelType {
|
||||
pluginKind: string;
|
||||
pluginKind: PanelKind;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
@@ -7,23 +7,23 @@ import type { Warning } from 'types/api';
|
||||
import PanelHeader from '../PanelHeader/PanelHeader';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
// PanelHeader's status indicators render a radix tooltip, which needs a
|
||||
// TooltipProvider ancestor (supplied globally by AppLayout at runtime).
|
||||
// Status indicators use a radix tooltip, which needs a TooltipProvider ancestor
|
||||
// (supplied globally by AppLayout at runtime).
|
||||
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
|
||||
render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
// The actions menu has its own gating logic (kind/role/context) and its own
|
||||
// tests; stub it so this test exercises only the header's status indicators.
|
||||
// Stub the actions menu (its gating logic is tested separately) so this asserts
|
||||
// only whether the menu mounts, per the `hideActions` switch.
|
||||
jest.mock(
|
||||
'../PanelActionsMenu/PanelActionsMenu',
|
||||
() =>
|
||||
function MockPanelActionsMenu(): null {
|
||||
return null;
|
||||
function MockPanelActionsMenu(): ReactElement {
|
||||
return <div data-testid="panel-actions-menu" />;
|
||||
},
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
title: 'My panel',
|
||||
name: 'My panel',
|
||||
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
|
||||
panelId: 'panel-1',
|
||||
isFetching: false,
|
||||
@@ -36,6 +36,27 @@ const warning: Warning = {
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe('PanelHeader title and description', () => {
|
||||
it('renders the panel name', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} />);
|
||||
expect(screen.getByText('My panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the description info icon when a description is provided', () => {
|
||||
renderWithProvider(
|
||||
<PanelHeader {...baseProps} description="What this panel measures" />,
|
||||
);
|
||||
expect(screen.getByTestId('panel-header-info-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no description info icon when there is no description', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} />);
|
||||
expect(
|
||||
screen.queryByTestId('panel-header-info-icon'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelHeader status indicators', () => {
|
||||
it('shows the error indicator whenever an error is present', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} error={new Error('boom')} />);
|
||||
@@ -76,8 +97,8 @@ describe('PanelHeader search', () => {
|
||||
|
||||
await user.click(screen.getByTestId('panel-header-search-trigger'));
|
||||
|
||||
// The input is controlled to the (fixed) `searchTerm` here, so each keystroke
|
||||
// reports a single character — assert one to confirm changes are propagated.
|
||||
// Input is controlled to a fixed `searchTerm`, so each keystroke reports a
|
||||
// single character — one is enough to confirm changes propagate.
|
||||
const input = screen.getByTestId('panel-header-search-input');
|
||||
await user.type(input, 'f');
|
||||
expect(onSearchChange).toHaveBeenCalledWith('f');
|
||||
@@ -103,6 +124,18 @@ describe('PanelHeader search', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelHeader actions menu', () => {
|
||||
it('mounts the actions menu by default', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} />);
|
||||
expect(screen.getByTestId('panel-actions-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the actions menu when hideActions is set (editor preview)', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} hideActions />);
|
||||
expect(screen.queryByTestId('panel-actions-menu')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelHeader time-preference pill', () => {
|
||||
it('shows the pill with the short label when the panel overrides the dashboard time', () => {
|
||||
renderWithProvider(
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
addPanelToSectionOps,
|
||||
createDefaultPanel,
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSection[];
|
||||
}
|
||||
|
||||
export interface AddPanelArgs {
|
||||
layoutIndex: number;
|
||||
pluginKind: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new panel and places its item ref at the bottom of the target
|
||||
* section, as one atomic patch. Structure-only: the panel is a valid minimal
|
||||
* placeholder (its query is filled in once the panel editor lands).
|
||||
*/
|
||||
export function useAddPanelToSection({
|
||||
sections,
|
||||
}: Params): (args: AddPanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
|
||||
const target = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelId = uuid();
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
addPanelToSectionOps({
|
||||
panelId,
|
||||
panel: createDefaultPanel(pluginKind),
|
||||
layoutIndex,
|
||||
item: {
|
||||
x: 0,
|
||||
y: nextY,
|
||||
width: 6,
|
||||
height: 6,
|
||||
content: { $ref: panelRef(panelId) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,6 @@ export function useClonePanel({
|
||||
}),
|
||||
);
|
||||
|
||||
// toast.promise reports the failure, so no separate error modal here.
|
||||
toast.promise(clone, {
|
||||
loading: 'Cloning panel…',
|
||||
success: 'Panel cloned',
|
||||
@@ -67,13 +66,13 @@ export function useClonePanel({
|
||||
position: 'top-center',
|
||||
});
|
||||
|
||||
// Refetch only on success; swallow the rejection (toast owns the error
|
||||
// UX) to avoid an unhandled rejection.
|
||||
// Refetch only on success; toast.promise owns the error UX, so swallow
|
||||
// the rejection to avoid an unhandled rejection.
|
||||
try {
|
||||
await clone;
|
||||
refetch();
|
||||
} catch {
|
||||
// no-op — toast.promise owns the error UX.
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch],
|
||||
|
||||
@@ -3,11 +3,10 @@ import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import { useCreatePanel } from '../../../hooks/useCreatePanel';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
@@ -22,24 +21,16 @@ import styles from './Section.module.scss';
|
||||
|
||||
interface SectionProps {
|
||||
section: DashboardSection;
|
||||
/** Adds a panel to this section; present only in editable sectioned mode. */
|
||||
onAddPanel?: (args: AddPanelArgs) => void;
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
}
|
||||
|
||||
function Section({
|
||||
section,
|
||||
onAddPanel,
|
||||
sections,
|
||||
dragHandle,
|
||||
}: SectionProps): JSX.Element {
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
@@ -65,15 +56,6 @@ function Section({
|
||||
[rename],
|
||||
);
|
||||
|
||||
const [isAddingPanel, setIsAddingPanel] = useState(false);
|
||||
const handleSelectPanelType = useCallback(
|
||||
(pluginKind: string): void => {
|
||||
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
|
||||
setIsAddingPanel(false);
|
||||
},
|
||||
[onAddPanel, section.layoutIndex],
|
||||
);
|
||||
|
||||
const { deleteSection } = useDeleteSection({ section });
|
||||
const handleDeleteSection = useCallback((): void => {
|
||||
void deleteSection();
|
||||
@@ -121,7 +103,7 @@ function Section({
|
||||
isEditable
|
||||
? {
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => setIsAddingPanel(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}
|
||||
: undefined
|
||||
@@ -138,7 +120,7 @@ function Section({
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => setIsPanelTypeSelectionModalOpen(true)}
|
||||
onClick={(): void => openPicker(section.layoutIndex)}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
@@ -156,9 +138,9 @@ function Section({
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
<PanelTypeSelectionModal
|
||||
open={isAddingPanel}
|
||||
onClose={(): void => setIsAddingPanel(false)}
|
||||
onSelect={handleSelectPanelType}
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import Section from './Section/Section';
|
||||
@@ -35,8 +34,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
onDragCancel,
|
||||
} = useSectionDragReorder({ sections, layouts });
|
||||
|
||||
const onAddPanel = useAddPanelToSection({ sections });
|
||||
|
||||
// Only titled sections participate in reordering; untitled (free-flow)
|
||||
// blocks render in place without a drag handle.
|
||||
const sortableIds = useMemo(
|
||||
@@ -66,19 +63,9 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{orderedSections.map((section) =>
|
||||
section.title ? (
|
||||
<SortableSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
/>
|
||||
<SortableSection key={section.id} section={section} sections={sections} />
|
||||
) : (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
/>
|
||||
<Section key={section.id} section={section} sections={sections} />
|
||||
),
|
||||
)}
|
||||
</SortableContext>
|
||||
|
||||
@@ -2,19 +2,16 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
|
||||
import Section from './Section/Section';
|
||||
|
||||
interface SortableSectionProps {
|
||||
section: DashboardSection;
|
||||
sections: DashboardSection[];
|
||||
onAddPanel: (args: AddPanelArgs) => void;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
sections,
|
||||
onAddPanel,
|
||||
}: SortableSectionProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -41,7 +38,6 @@ function SortableSection({
|
||||
<Section
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type {
|
||||
DashboardGridItemDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { createDefaultPanel, createPanelOps } from '../patchOps';
|
||||
|
||||
function item(y: number, height: number): DashboardGridItemDTO {
|
||||
return { x: 0, y, width: 6, height, content: { $ref: '#/spec/panels/x' } };
|
||||
}
|
||||
|
||||
function itemAt(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): DashboardGridItemDTO {
|
||||
return { x, y, width, height, content: { $ref: '#/spec/panels/x' } };
|
||||
}
|
||||
|
||||
function section(items: DashboardGridItemDTO[]): DashboardtypesLayoutDTO {
|
||||
return {
|
||||
kind: 'Grid',
|
||||
spec: { display: { title: 'S' }, items },
|
||||
} as DashboardtypesLayoutDTO;
|
||||
}
|
||||
|
||||
describe('createDefaultPanel', () => {
|
||||
it('builds a Panel of the given kind with no queries (filled in on save)', () => {
|
||||
const panel = createDefaultPanel('signoz/NumberPanel');
|
||||
expect(panel.kind).toBe('Panel');
|
||||
expect(panel.spec.plugin.kind).toBe('signoz/NumberPanel');
|
||||
expect(panel.spec.queries).toStrictEqual([]);
|
||||
expect(panel.spec.display.name).toBe('New panel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPanelOps', () => {
|
||||
const panel = createDefaultPanel('signoz/TimeSeriesPanel');
|
||||
|
||||
it('adds the panel + a grid item in the requested section', () => {
|
||||
const layouts = [section([item(0, 6)]), section([])];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
expect(ops).toHaveLength(2);
|
||||
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/panels/p1' });
|
||||
expect(ops[1]).toMatchObject({
|
||||
op: 'add',
|
||||
path: '/spec/layouts/0/spec/items/-',
|
||||
});
|
||||
expect((ops[1].value as DashboardGridItemDTO).content?.$ref).toBe(
|
||||
'#/spec/panels/p1',
|
||||
);
|
||||
});
|
||||
|
||||
it('fills the empty right half of a row instead of wrapping to a new one', () => {
|
||||
// Left half filled → new 6-wide panel fits at x:6 in the same row.
|
||||
const layouts = [section([item(0, 6)])];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(6);
|
||||
expect(value.y).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps to a new row when the last row is full', () => {
|
||||
// Full-width (12) row leaves no room → panel drops to the next row.
|
||||
const layouts = [section([itemAt(0, 0, 12, 6)])];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
expect(value.y).toBe(6);
|
||||
});
|
||||
|
||||
it('ignores a gap in an upper row and only fills the last row', () => {
|
||||
// Upper-row gap is ignored when the last row is full → starts a fresh row.
|
||||
const layouts = [section([itemAt(0, 0, 6, 6), itemAt(0, 6, 12, 6)])];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
expect(value.y).toBe(12);
|
||||
});
|
||||
|
||||
it('fills the right of the last row when it has room', () => {
|
||||
// Half-filled last row → panel sits at x:6 of that row.
|
||||
const layouts = [section([itemAt(0, 0, 12, 6), itemAt(0, 6, 6, 6)])];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(6);
|
||||
expect(value.y).toBe(6);
|
||||
});
|
||||
|
||||
it('checks the last row of the target section only, not other sections', () => {
|
||||
// Placement uses the target section's (1) last row, ignoring section 0's gap.
|
||||
const layouts = [
|
||||
section([itemAt(0, 0, 6, 6)]),
|
||||
section([itemAt(0, 0, 12, 6)]),
|
||||
];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 1, panelId: 'p1', panel });
|
||||
|
||||
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
expect(value.y).toBe(6);
|
||||
});
|
||||
|
||||
it('falls back to the last section when no index is requested', () => {
|
||||
const layouts = [section([]), section([item(0, 6)])];
|
||||
const ops = createPanelOps({
|
||||
layouts,
|
||||
layoutIndex: undefined,
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
|
||||
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
|
||||
});
|
||||
|
||||
it('falls back to the last section when the requested index is out of range', () => {
|
||||
const layouts = [section([])];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 5, panelId: 'p1', panel });
|
||||
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
|
||||
});
|
||||
|
||||
it('creates a section first when the dashboard has none', () => {
|
||||
const ops = createPanelOps({
|
||||
layouts: [],
|
||||
layoutIndex: undefined,
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
|
||||
expect(ops).toHaveLength(3);
|
||||
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/layouts/-' });
|
||||
expect(ops[1]).toMatchObject({ op: 'add', path: '/spec/panels/p1' });
|
||||
expect(ops[2].path).toBe('/spec/layouts/0/spec/items/-');
|
||||
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
|
||||
import type { PanelKind } from '../Panels/types/panelKind';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
interface UseCreatePanelResult {
|
||||
isPickerOpen: boolean;
|
||||
/** Pass the target section's layout index; omit → last/new section. */
|
||||
openPicker: (layoutIndex?: number) => void;
|
||||
closePicker: () => void;
|
||||
createPanel: (panelKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives new-panel creation from any "Add panel" trigger: owns the panel-type
|
||||
* picker state and navigates to the editor on a draft panel. Nothing is persisted
|
||||
* until save.
|
||||
*/
|
||||
export function useCreatePanel(): UseCreatePanelResult {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
// Captured on open, consumed on select.
|
||||
const [layoutIndex, setLayoutIndex] = useState<number | undefined>(undefined);
|
||||
|
||||
const openPicker = useCallback((index?: number): void => {
|
||||
setLayoutIndex(index);
|
||||
setIsPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const closePicker = useCallback((): void => {
|
||||
setIsPickerOpen(false);
|
||||
}, []);
|
||||
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind): void => {
|
||||
setIsPickerOpen(false);
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
);
|
||||
|
||||
return { isPickerOpen, openPicker, closePicker, createPanel };
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export function usePanelQuery({
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries ?? []), [queries]);
|
||||
|
||||
const runnable = useMemo(() => hasRunnableQueries(queries ?? []), [queries]);
|
||||
const runnable = useMemo(() => hasRunnableQueries(queries), [queries]);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelectionModal';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
@@ -66,9 +65,6 @@ function DashboardContainer({
|
||||
/>
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
</div>
|
||||
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
|
||||
trigger; navigates to the widget editor route on selection. */}
|
||||
<PanelTypeSelectionModal />
|
||||
</FullScreen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesPanelKindDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from './Panels/types/panelKind';
|
||||
import type { DefaultPluginSpec } from './Panels/utils/buildDefaultPluginSpec';
|
||||
import type { GridItem } from './utils';
|
||||
|
||||
/**
|
||||
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
|
||||
* intentionally side-effect-free (no React, no network) so they can be unit
|
||||
* tested and reused by the layout hooks. JSON pointers target the postable
|
||||
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
|
||||
* patches in DashboardSettings/Overview and DashboardDescription).
|
||||
* Pure (no React/network) RFC-6902 JSON-Patch builders for the V2 dashboard
|
||||
* spec. Pointers target the postable shape: `/spec/layouts/...`,
|
||||
* `/spec/panels/...`.
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesPatchOpDTO;
|
||||
@@ -25,29 +30,27 @@ export function panelRef(panelId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
|
||||
* requires exactly one query whose plugin kind is allowed for the panel;
|
||||
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
|
||||
* validated, so an empty builder query is the safe default. The real query is
|
||||
* filled in once the panel editor lands.
|
||||
* Builds a fresh panel of the given kind to seed the editor. The caller resolves
|
||||
* `pluginSpec` (config defaults) and `queries` (a kind's seed query) so this stays
|
||||
* free of the React panel registry.
|
||||
*/
|
||||
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
|
||||
// The DTO types plugin/query kinds as large generated enum unions; the kind
|
||||
// here is chosen dynamically by the user, so we build the structurally-valid
|
||||
// shape and assert the type.
|
||||
export function createDefaultPanel(
|
||||
pluginKind: PanelKind,
|
||||
pluginSpec: DefaultPluginSpec = {},
|
||||
queries: DashboardtypesQueryDTO[] = [],
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
kind: DashboardtypesPanelKindDTO.Panel,
|
||||
spec: {
|
||||
display: { name: 'New panel' },
|
||||
plugin: { kind: pluginKind, spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
|
||||
},
|
||||
],
|
||||
// `plugin` is a discriminated union; kind is runtime-chosen, so assert here.
|
||||
plugin: {
|
||||
kind: pluginKind,
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
|
||||
@@ -115,6 +118,93 @@ export function addPanelToSectionOps({
|
||||
];
|
||||
}
|
||||
|
||||
interface CreatePanelOpsArgs {
|
||||
/** Current sections, used to resolve the target and the next free row. */
|
||||
layouts: DashboardtypesLayoutDTO[];
|
||||
/** Preferred section (from the "Add panel" trigger); falls back to the last. */
|
||||
layoutIndex: number | undefined;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const NEW_PANEL_SIZE = { width: 6, height: 6 };
|
||||
|
||||
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
|
||||
const GRID_COLS = 12;
|
||||
|
||||
/**
|
||||
* Placement for a new grid item: drop it right of the last row if there's room,
|
||||
* else wrap to a fresh row at the bottom. Only the last row is considered (items
|
||||
* sharing the greatest top-y); gaps in earlier rows are left alone.
|
||||
*/
|
||||
function findFreeSlot(
|
||||
items: DashboardGridItemDTO[],
|
||||
width: number,
|
||||
): { x: number; y: number } {
|
||||
const w = Math.min(width, GRID_COLS);
|
||||
if (items.length === 0) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const bottom = items.reduce(
|
||||
(max, it) => Math.max(max, (it.y ?? 0) + (it.height ?? 0)),
|
||||
0,
|
||||
);
|
||||
const lastRowY = items.reduce((max, it) => Math.max(max, it.y ?? 0), 0);
|
||||
const lastRowRightEdge = items
|
||||
.filter((it) => (it.y ?? 0) === lastRowY)
|
||||
.reduce((max, it) => Math.max(max, (it.x ?? 0) + (it.width ?? 0)), 0);
|
||||
|
||||
if (lastRowRightEdge + w <= GRID_COLS) {
|
||||
return { x: lastRowRightEdge, y: lastRowY };
|
||||
}
|
||||
return { x: 0, y: bottom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ops to persist a brand-new panel (editor save path): resolve the target
|
||||
* section (requested index if valid, else last, else a freshly-created one) and
|
||||
* place the panel via `findFreeSlot`.
|
||||
*/
|
||||
export function createPanelOps({
|
||||
layouts,
|
||||
layoutIndex,
|
||||
panelId,
|
||||
panel,
|
||||
}: CreatePanelOpsArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
|
||||
const requested =
|
||||
layoutIndex !== undefined && layouts[layoutIndex] !== undefined
|
||||
? layoutIndex
|
||||
: layouts.length - 1;
|
||||
|
||||
let targetIndex = requested;
|
||||
let items: DashboardGridItemDTO[] = layouts[requested]?.spec.items ?? [];
|
||||
if (targetIndex < 0) {
|
||||
// No sections yet — create an untitled one and target it.
|
||||
ops.push(addSectionOp(''));
|
||||
targetIndex = 0;
|
||||
items = [];
|
||||
}
|
||||
|
||||
const { x, y } = findFreeSlot(items, NEW_PANEL_SIZE.width);
|
||||
ops.push(
|
||||
...addPanelToSectionOps({
|
||||
panelId,
|
||||
panel,
|
||||
layoutIndex: targetIndex,
|
||||
item: {
|
||||
x,
|
||||
y,
|
||||
...NEW_PANEL_SIZE,
|
||||
content: { $ref: panelRef(panelId) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return ops;
|
||||
}
|
||||
|
||||
interface MovePanelArgs {
|
||||
sourceIndex: number;
|
||||
sourceItems: GridItem[];
|
||||
|
||||
@@ -124,14 +124,33 @@ describe('prepareRawTable', () => {
|
||||
expect(table?.columns).not.toContain('attributes_string');
|
||||
});
|
||||
|
||||
it('does not flatten for non-log signals (traces return flat data)', () => {
|
||||
// Trace rows nest resource attributes the same way logs do: the span fields
|
||||
// (`name`, `duration_nano`) are top-level, but `service.name` lives under
|
||||
// `resources_string`, so it must be lifted to render (V1 parity).
|
||||
const traceRow = {
|
||||
timestamp: '2026-06-24T16:43:57Z',
|
||||
data: {
|
||||
name: 'resolve',
|
||||
duration_nano: 4120000,
|
||||
attributes_string: {},
|
||||
resources_string: { 'service.name': 'adservice' },
|
||||
},
|
||||
};
|
||||
|
||||
it('flattens nested resource maps for traces so service.name resolves', () => {
|
||||
const table = prepareRawTable({
|
||||
results: [result([logRow])],
|
||||
selectFields: [],
|
||||
results: [result([traceRow])],
|
||||
selectFields: [field('name'), field('duration_nano'), field('service.name')],
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
});
|
||||
|
||||
expect(table?.rows[0]).toMatchObject({
|
||||
name: 'resolve',
|
||||
duration_nano: 4120000,
|
||||
'service.name': 'adservice',
|
||||
});
|
||||
// The structured map is retained for the drawer, lifted child is a column.
|
||||
expect(table?.rows[0]).toHaveProperty('resources_string');
|
||||
expect(table?.rows[0]).not.toHaveProperty('service.name');
|
||||
expect(table?.columns).toContain('service.name');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,10 +51,13 @@ export function panelTypeToRequestType(
|
||||
* invalid (they reference builder queries by name) — warn and drop rather than crash load.
|
||||
*/
|
||||
export function toQueryEnvelopes(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
queries: DashboardtypesQueryDTO[] | null,
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
// Backend invariant: panel.queries.length === 1. Only the first entry is consumed.
|
||||
const plugin = queries[0]?.spec?.plugin;
|
||||
if (!queries || queries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const plugin = queries[0].spec.plugin;
|
||||
if (!plugin?.spec) {
|
||||
return [];
|
||||
}
|
||||
@@ -226,7 +229,9 @@ export function extractLegendMap(
|
||||
* Fetch gate. False with no queries, or when every metrics builder query lacks a metric name —
|
||||
* skipping a guaranteed 400 (V1 parity: `validateMetricNameForMetricsDataSource`).
|
||||
*/
|
||||
export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
|
||||
export function hasRunnableQueries(
|
||||
queries: DashboardtypesQueryDTO[] | null,
|
||||
): boolean {
|
||||
const envelopes = toQueryEnvelopes(queries);
|
||||
if (envelopes.length === 0) {
|
||||
return false;
|
||||
|
||||
@@ -48,7 +48,7 @@ const isBuilderQueryEnvelope = (
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): boolean => envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query;
|
||||
|
||||
function deriveQueryType(
|
||||
export function deriveQueryType(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
): EQueryType {
|
||||
if (envelopes.some((e) => e.type === Querybuildertypesv5QueryTypeDTO.promql)) {
|
||||
|
||||
@@ -25,9 +25,9 @@ interface PrepareRawTableArgs {
|
||||
/** The panel's chosen columns; when empty, columns are derived from the rows. */
|
||||
selectFields: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
/**
|
||||
* Panel telemetry signal. `logs` flattens nested attribute/resource maps one
|
||||
* level so selected fields (e.g. `service.name`) resolve (V1 `FlatLogData`
|
||||
* parity). Absent on the derive-columns fallback.
|
||||
* Panel telemetry signal. `logs`/`traces` flatten nested attribute/resource
|
||||
* maps one level so selected fields (e.g. `service.name`) resolve (V1
|
||||
* `FlatLogData` parity). Absent on the derive-columns fallback.
|
||||
*/
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
@@ -85,7 +85,11 @@ export function prepareRawTable({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const shouldFlatten = signal === TelemetrytypesSignalDTO.logs;
|
||||
// Logs and traces both nest resource/attribute maps; lift them so selected
|
||||
// fields like `service.name` resolve to a column (V1 parity).
|
||||
const shouldFlatten =
|
||||
signal === TelemetrytypesSignalDTO.logs ||
|
||||
signal === TelemetrytypesSignalDTO.traces;
|
||||
const rows: RawTableRow[] = result.rows.map((row, index) => {
|
||||
const data = row.data ?? {};
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
generatePath,
|
||||
Redirect,
|
||||
@@ -12,14 +12,20 @@ import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import {
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import styles from './PanelEditorPage.module.scss';
|
||||
|
||||
/**
|
||||
* Full-page route for editing a V2 dashboard panel. Fetches the dashboard, resolves
|
||||
* the panel from its spec, and hands `PanelEditorContainer` the navigate-back
|
||||
* callbacks. The save round-trip invalidates the dashboard query, so returning shows
|
||||
* the persisted edit without an explicit refetch here.
|
||||
* Full-page route for editing a V2 dashboard panel. Resolves the panel from the
|
||||
* fetched dashboard spec and wires up navigate-back callbacks.
|
||||
*/
|
||||
function PanelEditorPage(): JSX.Element {
|
||||
const { dashboardId, panelId } = useParams<{
|
||||
@@ -33,12 +39,29 @@ function PanelEditorPage(): JSX.Element {
|
||||
id: dashboardId,
|
||||
});
|
||||
const dashboard = data?.data;
|
||||
const panel = dashboard?.spec.panels[panelId];
|
||||
|
||||
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
const existingPanel = dashboard?.spec.panels[panelId];
|
||||
const panel = useMemo(
|
||||
() =>
|
||||
newKind
|
||||
? createDefaultPanel(
|
||||
newKind,
|
||||
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
|
||||
buildDefaultQueries(newKind),
|
||||
)
|
||||
: existingPanel,
|
||||
[newKind, existingPanel],
|
||||
);
|
||||
|
||||
// Target section for a newly-created panel (set by the "Add panel" trigger).
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
const backToDashboard = useCallback((): void => {
|
||||
// Carry only dashboard params back; drop editor-only URL state (chiefly
|
||||
// `compositeQuery`, the query builder's URL sync) so it doesn't leak into the
|
||||
// dashboard. Time lives in Redux, so it survives without being in the URL.
|
||||
// Carry only dashboard params; drop editor-only URL state (chiefly
|
||||
// `compositeQuery`) so it doesn't leak into the dashboard. Time lives in Redux.
|
||||
const params = new URLSearchParams();
|
||||
const variables = new URLSearchParams(search).get(QueryParams.variables);
|
||||
if (variables) {
|
||||
@@ -65,7 +88,7 @@ function PanelEditorPage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// Stale/deleted panel ref: redirect to the dashboard rather than render an empty editor.
|
||||
// No panel (stale/deleted id, or unknown new-panel kind) — send the user back.
|
||||
if (!panel) {
|
||||
return (
|
||||
<Redirect
|
||||
@@ -79,6 +102,8 @@ function PanelEditorPage(): JSX.Element {
|
||||
dashboardId={dashboardId}
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
isNew={!!newKind}
|
||||
layoutIndex={layoutIndex}
|
||||
onClose={backToDashboard}
|
||||
onSaved={backToDashboard}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user