Compare commits

..

15 Commits

Author SHA1 Message Date
Abhi Kumar
9db935fe87 refactor(dashboards-v2): capability- and role-gated panel actions menu
Rework the V2 panel actions menu so each action passes three orthogonal
gates, mirroring V1's WidgetHeader rules, and drop the prop-threading the
old menu relied on.

- Kind gate: PanelDefinition gains a required `actions`
  (PanelActionCapabilities: view/edit/download/createAlert) declared
  per-kind in each kinds/<Kind>/definition.ts — registering a new kind
  forces an explicit decision, like PanelInteractionMap does for gestures.
- Role gate: panelActionMeta maps each action id to a componentPermission
  key (absent = open to every role, V1 parity — edit/clone share
  edit_widget; delete uses delete_widget; move uses edit_dashboard).
- Context gate: dashboard editable (store) + layout config presence.
- usePanelActionItems resolves all three and composes items as groups
  with dividers between non-empty groups; PanelActionsMenu becomes purely
  presentational and renders nothing when no action survives its gates.
- The full V1 action set is present — View, Edit, Clone, Download as CSV,
  Create Alerts, Move to section, Delete — with not-yet-ported actions on
  a temporary placeholder handler.
- De-thread move/delete: the menu calls the store-backed useDeletePanel /
  useMovePanelToSection directly, so PanelActionsConfig is now pure data
  ({ currentLayoutIndex, sections }) and SectionList/Section/SectionGrid/
  SortableSection no longer forward callbacks. Fixes a latent free-flow
  bug where delete was wired to a noop.
- Edit opens the URL-driven overlay via the extracted useOpenPanelEditor.
- Tests: gate matrix (kind/role/context), divider grouping, and mutation
  wiring.

echo "--- commit B done ---"
git log --oneline -4
echo "--- remaining staged: $(git diff --cached --name-only | wc -l) ---"
2026-06-13 03:53:23 +05:30
Abhi Kumar
a795d7d940 fix(dashboards-v2): surface V5 query warnings in panel status
Adapt the panel status helpers to the pure-V5 warning shape
(Querybuildertypesv5QueryWarnDataDTO) instead of the legacy hand-written
Warning type. V5 warnings carry no `code`, so:

- panelStatusFromWarning maps message/url/sub-messages only and filters
  out empty sub-messages; PanelStatusDetail.code becomes optional.
- PanelStatusContent renders the code heading only when present.

echo "--- commit C done ---"
git log --oneline -1
echo "--- remaining staged: $(git diff --cached --name-only | wc -l) ---"
2026-06-13 03:50:52 +05:30
Abhi Kumar
ebae086731 feat(dashboards-v2): panel editor overlay with live preview
Add the V2 panel editor as a full-screen overlay driven by the
`editPanelId` query param — the dashboard stays mounted underneath
(no separate route) and the overlay portals to <body> to escape the
app-content stacking context.

- DashboardContainer resolves the panel from the already-loaded
  dashboard (no extra fetch) and hosts the overlay.
- Resizable split (persisted via useDefaultLayout + scoped
  layoutStorage): left = live PreviewPane rendering the draft through
  the production renderer registry in DASHBOARD_EDIT mode + a query
  builder placeholder; right = ConfigPane (title/description).
- Draft-authoritative state (usePanelEditorDraft) + JSON-patch save of
  the panel display (usePanelEditorSave), invalidating + refetching the
  dashboard on success.
- Preview time is editor-local: PreviewTimePicker keeps fully-local
  state (no global Redux time, no URL writes) and resolves the selection
  to an absolute [startMs, endMs] window passed to usePanelQuery as an
  optional `time` override, floored to int64 ms — so changing it never
  touches or re-runs the dashboard behind the overlay.
- Unit tests for the draft, save patch, ConfigPane, and the query-time
  override (incl. fractional-ms flooring).

echo "--- commit A done ---"
git log --oneline -1
2026-06-13 03:45:11 +05:30
Abhi Kumar
7bd66a354c feat(dashboards-v2): number panel kind + registry entry 2026-06-12 02:43:07 +05:30
Abhi Kumar
7db7b4aeb5 feat(dashboards): support = and != threshold operators 2026-06-12 02:43:07 +05:30
Abhi Kumar
d17578eb59 chore(dashboards-v2): dashboards-list-v2 query-param sort/order adjustments 2026-06-12 02:43:07 +05:30
Abhi Kumar
0f21f0ad46 feat(dashboards-v2): panel chrome - header, body, interactions & grid wiring 2026-06-12 02:43:07 +05:30
Abhi Kumar
966ca178ba feat(dashboards-v2): panel status popover (error/warning surfacing) 2026-06-12 02:43:07 +05:30
Abhi Kumar
d47ac3c836 feat(dashboards-v2): panel plugin registry 2026-06-12 02:43:07 +05:30
Abhi Kumar
1bf580df72 feat(dashboards-v2): PieChartPanel renderer 2026-06-12 02:43:07 +05:30
Abhi Kumar
51852fce5c feat(dashboards-v2): HistogramPanel renderer 2026-06-12 02:43:07 +05:30
Abhi Kumar
20930d666c feat(dashboards-v2): BarChartPanel renderer 2026-06-12 02:43:07 +05:30
Abhi Kumar
93d2ce40eb feat(dashboards-v2): TimeSeriesPanel renderer 2026-06-12 02:43:07 +05:30
Abhi Kumar
10d0480754 feat(dashboards-v2): usePanelQuery data-fetching hook 2026-06-12 02:43:07 +05:30
Abhi Kumar
948d94f50c feat(dashboards-v2): panel type system & shared chart utilities 2026-06-12 02:43:07 +05:30
111 changed files with 7557 additions and 164 deletions

View File

@@ -74,17 +74,6 @@ describe('RouteTab component', () => {
expect(history.location.pathname).toBe('/tab2');
});
it('does not animate tab panels to prevent CSSMotion DOM corruption', () => {
const history = createMemoryHistory();
const { container } = render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
const tabContent = container.querySelector('.ant-tabs-content');
expect(tabContent).not.toHaveClass('ant-tabs-content-animated');
});
it('calls onChangeHandler on tab change', () => {
const onChangeHandler = jest.fn();
const history = createMemoryHistory();

View File

@@ -59,7 +59,7 @@ function RouteTab({
destroyInactiveTabPane
activeKey={currentRoute?.key || activeKey}
defaultActiveKey={currentRoute?.key || activeKey}
animated={{ inkBar: true, tabPane: false }}
animated
items={items}
tabBarExtraContent={
showRightSection && (

View File

@@ -1,5 +1,6 @@
export enum QueryParams {
interval = 'interval',
editPanelId = 'editPanelId',
startTime = 'startTime',
endTime = 'endTime',
service = 'service',

View File

@@ -2,6 +2,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
createColumnsAndDataSource,
evaluateThresholdWithConvertedValue,
getQueryLegend,
sortFunction,
} from '../utils';
@@ -225,3 +226,30 @@ describe('Table Panel utils with QB v5 aggregations', () => {
).toBe(0);
});
});
// No units passed, so `convertUnit` is a no-op and the comparison runs against
// the raw value — exercising `evaluateCondition`'s operator switch directly.
describe('evaluateThresholdWithConvertedValue operators', () => {
it('handles ordering operators', () => {
expect(evaluateThresholdWithConvertedValue(5, 3, '>')).toBe(true);
expect(evaluateThresholdWithConvertedValue(2, 3, '>')).toBe(false);
expect(evaluateThresholdWithConvertedValue(2, 3, '<')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '>=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '<=')).toBe(true);
});
it('treats = and == as equality', () => {
expect(evaluateThresholdWithConvertedValue(3, 3, '=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '==')).toBe(true);
expect(evaluateThresholdWithConvertedValue(4, 3, '=')).toBe(false);
});
it('handles != as inequality', () => {
expect(evaluateThresholdWithConvertedValue(4, 3, '!=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '!=')).toBe(false);
});
it('returns false for an unknown operator', () => {
expect(evaluateThresholdWithConvertedValue(3, 3, '~')).toBe(false);
});
});

View File

@@ -29,8 +29,11 @@ function evaluateCondition(
return value >= thresholdValue;
case '<=':
return value <= thresholdValue;
case '=':
case '==':
return value === thresholdValue;
case '!=':
return value !== thresholdValue;
default:
return false;
}

View File

@@ -2,7 +2,7 @@ import { Dispatch, ReactNode, SetStateAction } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ColumnUnit } from 'types/api/dashboard/getAll';
export type ThresholdOperators = '>' | '<' | '>=' | '<=' | '=';
export type ThresholdOperators = '>' | '<' | '>=' | '<=' | '=' | '!=';
export type ThresholdProps = {
index: string;

View File

@@ -6,6 +6,8 @@ export const operatorOptions: DefaultOptionType[] = [
{ value: '>=', label: '>=' },
{ value: '<', label: '<' },
{ value: '<=', label: '<=' },
{ value: '=', label: '=' },
{ value: '!=', label: '≠' },
];
export const showAsOptions: DefaultOptionType[] = [

View File

@@ -0,0 +1,22 @@
.config {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
gap: 16px;
background-color: var(--l2-background);
border-left: 1px solid var(--l2-border);
overflow-y: auto;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}

View File

@@ -0,0 +1,53 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { PanelDisplayDraft } from '../types';
import styles from './ConfigPane.module.scss';
interface ConfigPaneProps {
display: PanelDisplayDraft;
onChangeDisplay: (next: Partial<PanelDisplayDraft>) => void;
}
/**
* Right-hand configuration pane. Milestone 1 exposes only the panel title and
* description; later milestones render the data-driven section framework
* (Formatting, Axes, Legend, …) below these general fields, keyed off the
* panel kind's `SectionConfig[]`.
*/
function ConfigPane({
display,
onChangeDisplay,
}: ConfigPaneProps): JSX.Element {
return (
<div className={styles.config}>
<div className={styles.section}>
<Typography.Text>Panel settings</Typography.Text>
<div className={styles.field}>
<Typography.Text>Title</Typography.Text>
<Input
data-testid="panel-editor-v2-title"
value={display.name}
placeholder="Panel title"
onChange={(e): void => onChangeDisplay({ name: e.target.value })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Description</Typography.Text>
<Input.TextArea
data-testid="panel-editor-v2-description"
value={display.description}
placeholder="Add a description"
rows={3}
onChange={(e): void => onChangeDisplay({ description: e.target.value })}
/>
</div>
</div>
</div>
);
}
export default ConfigPane;

View File

@@ -0,0 +1,22 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background-color: var(--l2-background);
border-bottom: 1px solid var(--l2-border);
}
.title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,51 @@
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import styles from './Header.module.scss';
interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
onSave: () => void;
onClose: () => void;
}
function Header({
isDirty,
isSaving,
onSave,
onClose,
}: HeaderProps): JSX.Element {
return (
<div className={styles.header}>
<div className={styles.title}>
<Button
variant="ghost"
color="secondary"
size="icon"
suffix={<X size={14} />}
data-testid="panel-editor-v2-close"
onClick={onClose}
/>
<Divider type="vertical" />
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
</div>
</div>
);
}
export default Header;

View File

@@ -0,0 +1,17 @@
.root {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--l1-background);
}
.left {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}

View File

@@ -0,0 +1,61 @@
.preview {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 8px;
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
background-size: 20px 20px;
border-bottom: 1px solid var(--l2-border);
}
.header {
width: 100%;
box-sizing: border-box;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.queryType {
display: inline-flex;
padding: 4px 8px 4px 6px;
align-items: center;
gap: 6px;
border-radius: 4px;
background: var(--l3-background);
backdrop-filter: blur(6px);
width: fit-content;
}
.container {
display: flex;
flex: 1;
padding: 2% 5% 5% 5%;
min-width: 0;
min-height: 0;
}
.surface {
flex: 1;
min-width: 0;
min-height: 0;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
display: flex;
background: var(--l2-background);
padding: 8px;
}
.state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--bg-vanilla-400, #8993ae);
font-size: 13px;
text-align: center;
}

View File

@@ -0,0 +1,129 @@
import { useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports -- seed initial time from global store; never written back
import { useSelector } from 'react-redux';
import { Spin } from 'antd';
import { Loader, Spline } from '@signozhq/icons';
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 getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import {
type PanelQueryTimeOverride,
usePanelQuery,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { AppState } from 'store/reducers';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import PreviewTimePicker, {
type PreviewTime,
} from '../PreviewTimePicker/PreviewTimePicker';
import styles from './PreviewPane.module.scss';
const NS_TO_SEC = 1e9;
const SEC_TO_MS = 1e3;
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
}
/**
* Live preview for the panel editor. Renders the draft panel through the same
* registry + query path the dashboard grid uses (`getPanelDefinition` +
* `usePanelQuery`), so the preview is byte-for-byte the production renderer —
* only the `panelMode` differs (DASHBOARD_EDIT).
*
* Time is editor-local (`PreviewTimePicker` never touches global Redux time or
* the URL), so changing it here neither modifies nor re-runs the dashboard
* behind the overlay. Seeded once from the current global selection so the
* preview opens matching the dashboard. The local window is resolved to an
* absolute `[startMs, endMs]` and handed to `usePanelQuery` as a time override
* (the V5 request takes epoch ms; a relative selection is pinned at the moment
* it's picked).
*/
function PreviewPane({ panelId, panel }: PreviewPaneProps): JSX.Element {
const fullKind = panel.spec?.plugin?.kind;
const panelDef = getPanelDefinition(fullKind);
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [previewTime, setPreviewTime] = useState<PreviewTime>(() =>
globalTime.selectedTime === 'custom'
? {
interval: 'custom',
range: [
Math.floor(globalTime.minTime / NS_TO_SEC),
Math.floor(globalTime.maxTime / NS_TO_SEC),
],
}
: { interval: globalTime.selectedTime, range: null },
);
// Resolve the editor-local selection to an absolute epoch-ms window. Custom
// uses the picked range; relative is computed now-based (Redux-independent)
// and pinned until the user changes the picker — recomputing "now" each
// render would churn the query key into an endless refetch loop.
const time = useMemo<PanelQueryTimeOverride>(() => {
if (previewTime.range) {
return {
startMs: previewTime.range[0] * SEC_TO_MS,
endMs: previewTime.range[1] * SEC_TO_MS,
};
}
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: previewTime.interval,
});
return { startMs: Number(start) * SEC_TO_MS, endMs: Number(end) * SEC_TO_MS };
}, [previewTime]);
const { data, isLoading, error } = usePanelQuery({
panel,
panelId,
enabled: !!panelDef,
time,
});
return (
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.queryType}>
<Spline size={14} />
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
</div>
<PreviewTimePicker value={previewTime} onChange={setPreviewTime} />
</div>
<div className={styles.container}>
<div className={styles.surface}>
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on render state */}
{!panelDef ? (
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
This panel type is not yet supported in V2.
</div>
) : isLoading && !data.response ? (
<div className={styles.state} data-testid="panel-editor-v2-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
) : (
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
panelMode={PanelMode.DASHBOARD_EDIT}
enableDrillDown={false}
/>
)}
</div>
</div>
</div>
);
}
export default PreviewPane;

View File

@@ -0,0 +1,127 @@
import { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import type { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import { getOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import dayjs from 'dayjs';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { useTimezone } from 'providers/Timezone';
const MS_TO_NS = 1e6;
export interface PreviewTime {
/** Relative shorthand (e.g. `30m`) or `custom`. */
interval: Time | CustomTimeType;
/** Custom range `[startSec, endSec]`; null for relative. */
range: [number, number] | null;
}
interface PreviewTimePickerProps {
value: PreviewTime;
onChange: (next: PreviewTime) => void;
}
/**
* Time picker for the panel editor preview. Wraps the shared `CustomTimePicker`
* with fully-local state — it never reads or writes global Redux time or the
* URL, so changing the preview window doesn't touch (or re-run) the dashboard
* behind the editor overlay. Selections are emitted via `onChange`; the parent
* feeds them to the preview fetch.
*/
function PreviewTimePicker({
value,
onChange,
}: PreviewTimePickerProps): JSX.Element {
const { pathname } = useLocation();
const { timezone } = useTimezone();
const [open, setOpen] = useState<boolean>(false);
const [customVisible, setCustomVisible] = useState<boolean>(false);
const { interval, range } = value;
const options = useMemo(() => getOptions(pathname), [pathname]);
// Active window in ms — custom uses the picked range; relative is computed
// now-based (Redux-independent). Drives the relative-duration pill.
const [startMs, endMs] = useMemo<[number, number]>(() => {
if (range) {
return [range[0] * 1000, range[1] * 1000];
}
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval,
});
return [Number(start) * 1000, Number(end) * 1000];
}, [interval, range]);
// Label shown for a custom range; relative selections render their own
// "Last …" label from `selectedTime` inside CustomTimePicker.
const selectedValue = useMemo(() => {
if (!range) {
return '';
}
const fmt = DATE_TIME_FORMATS.UK_DATETIME_SECONDS;
const start = dayjs(startMs).tz(timezone.value).format(fmt);
const end = dayjs(endMs).tz(timezone.value).format(fmt);
return `${start} - ${end}`;
}, [range, startMs, endMs, timezone.value]);
const onSelect = (next: string): void => {
if (next === 'custom') {
setCustomVisible(true);
return;
}
setOpen(false);
onChange({ interval: next as Time, range: null });
};
const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => {
if (!dateTimeRange) {
return;
}
const [startMoment, endMoment] = dateTimeRange;
if (!startMoment || !endMoment) {
return;
}
setCustomVisible(false);
setOpen(false);
onChange({
interval: 'custom',
range: [
Math.floor(startMoment.toDate().getTime() / 1000),
Math.floor(endMoment.toDate().getTime() / 1000),
],
});
};
return (
<CustomTimePicker
newPopover
open={open}
setOpen={setOpen}
items={options}
selectedTime={interval}
selectedValue={selectedValue}
minTime={startMs * MS_TO_NS}
maxTime={endMs * MS_TO_NS}
// Hides the zoom-out button — it mutates global time, which the editor
// must not do.
isModalTimeSelection
onSelect={onSelect}
onError={(): void => {}}
onCustomDateHandler={onCustomDateHandler}
customDateTimeVisible={customVisible}
setCustomDTPickerVisible={setCustomVisible}
onValidCustomDateChange={({ timeStr }): void => {
setOpen(false);
onChange({ interval: timeStr as Time, range: null });
}}
/>
);
}
export default PreviewTimePicker;

View File

@@ -0,0 +1,12 @@
.placeholder {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 30%;
margin: 0 16px 16px;
border: 1px dashed var(--l2-border);
border-radius: 4px;
color: var(--l2-foreground);
font-size: 13px;
}

View File

@@ -0,0 +1,23 @@
import { Terminal } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './QueryBuilderPlaceholder.module.scss';
/**
* Placeholder for the query builder in the panel editor's left pane. Milestone 2
* replaces this with the shared `QueryBuilderV2`, wired through `fromPerses` /
* `toPerses` so query edits flow into the draft and re-fetch the preview.
*/
function QueryBuilderPlaceholder(): JSX.Element {
return (
<div
className={styles.placeholder}
data-testid="panel-editor-v2-query-placeholder"
>
<Terminal size={16} />
<Typography.Text>Query builder coming soon</Typography.Text>
</div>
);
}
export default QueryBuilderPlaceholder;

View File

@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react';
import ConfigPane from '../ConfigPane/ConfigPane';
describe('ConfigPane', () => {
it('renders the seeded title and description', () => {
render(
<ConfigPane
display={{ name: 'CPU', description: 'usage' }}
onChangeDisplay={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-title')).toHaveValue('CPU');
expect(screen.getByTestId('panel-editor-v2-description')).toHaveValue(
'usage',
);
});
it('reports title edits via onChangeDisplay', () => {
const onChangeDisplay = jest.fn();
render(
<ConfigPane
display={{ name: 'CPU', description: 'usage' }}
onChangeDisplay={onChangeDisplay}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
target: { value: 'Memory' },
});
expect(onChangeDisplay).toHaveBeenCalledWith({ name: 'Memory' });
});
});

View File

@@ -0,0 +1,62 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorDraft } from '../usePanelEditorDraft';
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name, description },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('usePanelEditorDraft', () => {
it('seeds display from the initial panel and starts clean', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
expect(result.current.display).toStrictEqual({
name: 'CPU',
description: 'usage',
});
expect(result.current.isDirty).toBe(false);
});
it('updates display and flags the draft dirty', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() => result.current.setDisplay({ name: 'Memory' }));
expect(result.current.display.name).toBe('Memory');
expect(result.current.display.description).toBe('usage');
expect(result.current.isDirty).toBe(true);
// draft stays in perses shape so preview + save consume it directly
expect(result.current.draft.spec?.display?.name).toBe('Memory');
});
it('reset restores the originally-loaded display', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() => result.current.setDisplay({ name: 'Memory', description: 'new' }));
act(() => result.current.reset());
expect(result.current.display).toStrictEqual({
name: 'CPU',
description: 'usage',
});
expect(result.current.isDirty).toBe(false);
});
it('treats a panel without display as empty strings', () => {
const bare = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/PieChartPanel' } },
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() => usePanelEditorDraft(bare));
expect(result.current.display).toStrictEqual({ name: '', description: '' });
});
});

View File

@@ -0,0 +1,72 @@
import { renderHook } from '@testing-library/react';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import { usePanelEditorSave } from '../usePanelEditorSave';
const mockInvalidateQueries = jest.fn();
jest.mock('react-query', () => ({
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
invalidateQueries: mockInvalidateQueries,
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
usePatchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
describe('usePanelEditorSave', () => {
const mutateAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: false,
error: null,
});
});
it('emits an add patch for the panel display and invalidates the dashboard query', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
await result.current.save({ name: 'New title', description: 'desc' });
expect(mutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'dash-1' },
data: [
{
op: 'add',
path: '/spec/panels/panel-9/spec/display',
value: { name: 'New title', description: 'desc' },
},
],
});
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
expect(mockInvalidateQueries).toHaveBeenCalledWith([
'/api/v2/dashboards/dash-1',
]);
});
it('surfaces the mutation loading state as isSaving', () => {
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: true,
error: null,
});
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
expect(result.current.isSaving).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
import { useCallback } from 'react';
import { createPortal } from 'react-dom';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PreviewPane from './PreviewPane/PreviewPane';
import QueryBuilderPlaceholder from './QueryBuilderPlaceholder/QueryBuilderPlaceholder';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorSave } from './usePanelEditorSave';
import styles from './PanelEditor.module.scss';
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/** Dismiss the editor overlay (clears the `editPanelId` query param). */
onClose: () => void;
/** Called after a successful save so the dashboard can refetch. */
onSaved: () => void;
}
/**
* V2 panel editor rendered as a full-screen overlay on top of the dashboard
* view (the dashboard stays mounted underneath). A resizable split holds the
* live preview + query builder on the left and the configuration pane on the
* right. Owns the draft state and the save round-trip.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, display, setDisplay, isDirty } = usePanelEditorDraft(panel);
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const onSave = useCallback(async (): Promise<void> => {
try {
await save(display);
toast.success('Panel saved');
onSaved();
onClose();
} catch {
toast.error('Failed to save panel');
}
}, [save, display, onSaved, onClose]);
// Portal to <body> so the fixed overlay escapes the dashboard content's
// stacking context (AppLayout pins `.app-content` at `z-index: 0`, which
// would otherwise trap the overlay below the side nav).
return createPortal(
<div className={styles.root} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
onSave={onSave}
onClose={onClose}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="70%" maxSize="80%" defaultSize="75%">
<div className={styles.left}>
<PreviewPane panelId={panelId} panel={draft} />
<QueryBuilderPlaceholder />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel minSize="20%" maxSize="30%" defaultSize="25%">
<ConfigPane display={display} onChangeDisplay={setDisplay} />
</ResizablePanel>
</ResizablePanelGroup>
</div>,
document.body,
);
}
export default PanelEditorContainer;

View File

@@ -0,0 +1,17 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
/**
* `Storage`-shaped adapter (just `getItem`/`setItem`, which is all
* `useDefaultLayout` consumes) backed by the scoped localStorage wrappers. The
* wrappers prefix keys with the URL base path, so the persisted resizable
* layout stays isolated per deployment instead of touching the raw global.
*/
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
getItem: (key: string): string | null => getLocalStorageApi(key),
setItem: (key: string, value: string): void => {
setLocalStorageApi(key, value);
},
};
export default layoutStorage;

View File

@@ -0,0 +1,25 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
/** The panel display fields editable in milestone 1 of the V2 panel editor. */
export interface PanelDisplayDraft {
name: string;
description: string;
}
/**
* Local draft state for the panel being edited. The draft is kept as a perses
* `DashboardtypesPanelDTO` so the live preview (which feeds the panel renderer)
* and the save patch share a single shape — no intermediate translation.
*/
export interface PanelEditorDraftApi {
/** The current (possibly edited) panel. Always a defined object once seeded. */
draft: DashboardtypesPanelDTO;
/** Read the current display values (name/description) for the config pane. */
display: PanelDisplayDraft;
/** Patch the panel's display (title/description). */
setDisplay: (next: Partial<PanelDisplayDraft>) => void;
/** True when the draft diverges from the originally-loaded panel. */
isDirty: boolean;
/** Restore the draft to the originally-loaded panel. */
reset: () => void;
}

View File

@@ -0,0 +1,52 @@
import { useCallback, useMemo, useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelDisplayDraft, PanelEditorDraftApi } from './types';
function readDisplay(panel: DashboardtypesPanelDTO): PanelDisplayDraft {
return {
name: panel.spec?.display?.name ?? '',
description: panel.spec?.display?.description ?? '',
};
}
/**
* Owns the editable draft of a single panel. Seeded once from the loaded panel
* (`useState` initializer), then mutated locally until the user saves. Keeping
* the draft in the perses `DashboardtypesPanelDTO` shape lets the preview pane
* render it through the same renderer registry the dashboard uses, and lets the
* save hook diff/patch it without any conversion.
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
const setDisplay = useCallback((next: Partial<PanelDisplayDraft>): void => {
setDraft((prev) => ({
...prev,
spec: {
...prev.spec,
display: {
...prev.spec?.display,
...next,
},
},
}));
}, []);
const reset = useCallback((): void => {
setDraft(initialPanel);
}, [initialPanel]);
const display = useMemo(() => readDisplay(draft), [draft]);
const isDirty = useMemo(() => {
const initial = readDisplay(initialPanel);
return (
initial.name !== display.name || initial.description !== display.description
);
}, [initialPanel, display]);
return { draft, display, setDisplay, isDirty, reset };
}

View File

@@ -0,0 +1,60 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import {
type DashboardtypesJSONPatchOperationDTO,
DashboardtypesPatchOpDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelDisplayDraft } from './types';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
}
interface UsePanelEditorSaveApi {
save: (display: PanelDisplayDraft) => Promise<void>;
isSaving: boolean;
error: Error | null;
}
/**
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch.
*
* Milestone 1 only touches the panel's display (title/description), so it emits
* a single `add` op against `/spec/panels/{panelId}/spec/display`. `add` doubles
* as create-or-replace for the display object, so panels that loaded without a
* display are handled without a separate existence check. Later milestones add
* ops for queries and the per-kind plugin spec.
*/
export function usePanelEditorSave({
dashboardId,
panelId,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
const save = useCallback(
async (display: PanelDisplayDraft): Promise<void> => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: DashboardtypesPatchOpDTO.add,
path: `/spec/panels/${panelId}/spec/display`,
value: { name: display.name, description: display.description },
},
];
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(
getGetDashboardV2QueryKey({ id: dashboardId }),
);
},
[dashboardId, panelId, mutateAsync, queryClient],
);
return { save, isSaving: isLoading, error: (error as Error) ?? null };
}

View File

@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Builds a record keyed by builder-query name to that query's groupBy keys
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
* call site that needs the V1 shape; the rest of V2 panel code stays on
* v5 types.
*/
export function useGroupByPerQuery(
builderQueries: BuilderQuery[],
): Record<string, BaseAutocompleteData[]> {
return useMemo(() => {
const result: Record<string, BaseAutocompleteData[]> = {};
builderQueries.forEach((q) => {
if (!q.name) {
return;
}
result[q.name] = (q.groupBy ?? []).map((g) => ({
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
}));
});
return result;
}, [builderQueries]);
}

View File

@@ -0,0 +1,48 @@
import { RefObject, useEffect, useRef, useState } from 'react';
const MIN_FONT_PX = 16;
const MAX_FONT_PX = 60;
// The value font is sized to a fraction of the container's smaller dimension so
// it scales with the panel without overflowing.
const FONT_SCALE_DIVISOR = 5;
/**
* Sizes a single large value to its container, recomputing on resize via a
* ResizeObserver. Returns the ref to attach to the container and the current
* font size (px) to apply to the value text.
*/
export function useResponsiveFontSize(): {
containerRef: RefObject<HTMLDivElement>;
fontSize: string;
} {
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState('2.5vw');
useEffect(() => {
const updateFontSize = (): void => {
if (!containerRef.current) {
return;
}
const { width, height } = containerRef.current.getBoundingClientRect();
const minDimension = Math.min(width, height);
const newSize = Math.max(
Math.min(minDimension / FONT_SCALE_DIVISOR, MAX_FONT_PX),
MIN_FONT_PX,
);
setFontSize(`${newSize}px`);
};
updateFontSize();
const resizeObserver = new ResizeObserver(updateFontSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return (): void => {
resizeObserver.disconnect();
};
}, []);
return { containerRef, fontSize };
}

View File

@@ -0,0 +1,36 @@
import { definition as barChart } from './kinds/BarChartPanel/definition';
import { definition as histogram } from './kinds/HistogramPanel/definition';
import { definition as number } from './kinds/NumberPanel/definition';
import { definition as pieChart } from './kinds/PieChartPanel/definition';
import { definition as timeSeries } from './kinds/TimeSeriesPanel/definition';
import type {
PanelRegistry,
RenderablePanelDefinition,
} from './types/panelDefinition';
import type { 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.
export const PANELS: PanelRegistry = {
[timeSeries.kind]: timeSeries,
[barChart.kind]: barChart,
[histogram.kind]: histogram,
[number.kind]: number,
[pieChart.kind]: pieChart,
};
export function getPanelDefinition(
kind: string | undefined,
): 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 PanelKind] as unknown as
| RenderablePanelDefinition
| undefined;
}

View File

@@ -0,0 +1,175 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getExecStats,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildBarChartConfig } from './buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function BarPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesBarChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
// X-scale clamps come from the request that produced the data (falls back
// to the global picker inside the helper). The generated request DTO is
// structurally the hand-written V5 request; the cast is the boundary.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data?.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data?.requestPayload]);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const flatSeries = useMemo(
() =>
flattenTimeSeries(
getTimeSeriesResults(data?.response),
data?.legendMap ?? {},
),
[data?.response, data?.legendMap],
);
const config = useMemo(
() =>
buildBarChartConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
stepIntervals: getExecStats(data?.response)?.stepIntervals,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
flatSeries,
data?.response,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
// The uPlot key prop is the only way to force a full teardown and re-mount
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
// to these preferences trigger a fresh chart instance, preventing stale
// sync wiring from being inherited.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default BarPanelRenderer;

View File

@@ -0,0 +1,138 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { toClickPluginPayload } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
spec: DashboardtypesBarChartPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
* one bar series per result row.
*/
export function buildBarChartConfig({
panelId,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
stepIntervals,
clickPayload: toClickPluginPayload(series),
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesBarChartPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
}
/**
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
* when `spec.visualization.stackedBarChart` is set. Each series receives its
* own per-query step interval so bar widths line up with the actual
* sampling cadence reported by the backend.
*
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(series.length + 1));
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
const stepInterval = s.queryName ? stepIntervals?.[s.queryName] : undefined;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
stepInterval,
metric: s.labels,
});
});
}

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: { view: true, edit: true, download: false, createAlert: true },
};

View File

@@ -0,0 +1,9 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
];

View File

@@ -0,0 +1,139 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveLegendPosition } from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
panel,
data,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesHistogramPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const flatSeries = useMemo(
() =>
flattenTimeSeries(
getTimeSeriesResults(data?.response),
data?.legendMap ?? {},
),
[data?.response, data?.legendMap],
);
const config = useMemo(
() =>
buildHistogramConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
isDarkMode,
timezone,
panelMode,
}),
[panelId, spec, builderQueries, flatSeries, isDarkMode, timezone, panelMode],
);
const chartData = useMemo(
() =>
prepareHistogramData({
series: flatSeries,
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
}),
[
flatSeries,
spec.histogramBuckets?.bucketWidth,
spec.histogramBuckets?.bucketCount,
spec.histogramBuckets?.mergeAllActiveQueries,
],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
),
[panelId],
);
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Histogram
key={panelId}
config={config}
data={chartData as uPlot.AlignedData}
legendConfig={{ position: legendPosition }}
canPinTooltip
isQueriesMerged={isQueriesMerged}
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default HistogramPanelRenderer;

View File

@@ -0,0 +1,129 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const POINT_SIZE = 5;
const BAR_WIDTH_FACTOR = 1;
// Merged-series colors mirror the V1 default — single histogram bin gets a
// fixed blue-ish pair so the merged view looks the same as before.
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
*
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
* axes, click plugin) but then override the X/Y scales to be auto-linear
* (`time: false, auto: true`) and install a histogram-specific cursor that
* disables drag-pan and tightens focus proximity.
*/
export function buildHistogramConfig({
panelId,
spec,
builderQueries,
series,
isDarkMode,
timezone,
panelMode,
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
// Histograms have no time axis — no stepIntervals, and no click plugin
// (the renderer passes no onClick), so the base config needs no response.
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,
});
builder.setCursor({
drag: { x: false, y: false, setScale: true },
focus: { prox: 1e3 },
});
// Override the time-axis scales from `buildBaseConfig` — histograms are
// distribution plots, not time series.
builder.addScale({ scaleKey: 'x', time: false, auto: true });
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
addSeries({ builder, spec, builderQueries, series, isDarkMode });
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesHistogramPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
isDarkMode: boolean;
}
/**
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
* set, `prepareHistogramData` produces a single Y column, so we add exactly
* one series with the fixed merged-mode colors. Otherwise one series per
* result row, with labels resolved via the standard legend matrix.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
const mergeAllActiveQueries =
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
if (mergeAllActiveQueries) {
builder.addSeries({
scaleKey: 'y',
label: '',
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
lineColor: MERGED_SERIES_LINE_COLOR,
fillColor: MERGED_SERIES_FILL_COLOR,
isDarkMode,
});
return;
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
label,
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
isDarkMode,
});
});
}

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: { view: true, edit: true, download: false, createAlert: true },
};

View File

@@ -0,0 +1,148 @@
import { histogramBucketSizes } from '@grafana/data';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import {
buildHistogramBuckets,
mergeAlignedDataTables,
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { AlignedData } from 'uplot';
import { incrRoundDn, roundDecimals } from 'utils/round';
export interface PrepareHistogramDataArgs {
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
bucketWidth?: number;
bucketCount?: number;
mergeAllActiveQueries?: boolean;
}
const BUCKET_OFFSET = 0;
const sortAscending = (a: number, b: number): number => a - b;
/**
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
* either from `bucketWidth` (explicit override) or the smallest predefined
* Grafana bucket that fits the data's `range / bucketCount` target while
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
* the resolution of the input).
*
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
*/
export function prepareHistogramData({
series,
bucketWidth,
bucketCount = DEFAULT_BUCKET_COUNT,
mergeAllActiveQueries = false,
}: PrepareHistogramDataArgs): AlignedData {
const values = extractNumericValues(series);
if (values.length === 0) {
return [[]];
}
const sorted = [...values].sort(sortAscending);
const range = sorted[sorted.length - 1] - sorted[0];
const smallestDelta = computeSmallestDelta(sorted);
let bucketSize = selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride: bucketWidth,
});
if (bucketSize <= 0) {
bucketSize = range > 0 ? range / bucketCount : 1;
}
const getBucket = (v: number): number =>
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(series, mergeAllActiveQueries);
// Merged mode folds every query into frame 0 and leaves trailing empty
// frames — drop those. Per-query mode must keep one column per result row
// (even empty queries), or the data column count drifts below the series
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
const histograms: AlignedData[] = frames
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
if (histograms.length === 0) {
return [[]];
}
const merged = mergeAlignedDataTables(histograms);
replaceUndefinedWithNullInAlignedData(merged);
prependNullBinToFirstHistogramSeries(merged, bucketSize);
return merged;
}
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
function toBinnableValue(value: number): number {
return Number.isFinite(value) ? value : 0;
}
function extractNumericValues(series: PanelSeries[]): number[] {
const values: number[] = [];
for (const s of series) {
for (const point of s.values) {
values.push(toBinnableValue(point.value));
}
}
return values;
}
function computeSmallestDelta(sortedValues: number[]): number {
if (sortedValues.length <= 1) {
return 0;
}
let smallest = Infinity;
for (let i = 1; i < sortedValues.length; i++) {
const delta = sortedValues[i] - sortedValues[i - 1];
if (delta > 0) {
smallest = Math.min(smallest, delta);
}
}
return smallest === Infinity ? 0 : smallest;
}
function selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride,
}: {
range: number;
bucketCount: number;
smallestDelta: number;
bucketWidthOverride?: number;
}): number {
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
return bucketWidthOverride;
}
const targetSize = range / bucketCount;
for (const candidate of histogramBucketSizes) {
if (targetSize < candidate && candidate >= smallestDelta) {
return candidate;
}
}
return 0;
}
// When merging is on, fold all frames into the first; the trailing empty
// frames stay in the array so downstream `.filter(length > 0)` drops them.
function buildFrames(
series: PanelSeries[],
mergeAllActiveQueries: boolean,
): number[][] {
const frames: number[][] = series.map((s) =>
s.values.map((point) => toBinnableValue(point.value)),
);
if (mergeAllActiveQueries && frames.length > 1) {
const first = frames[0];
for (let i = 1; i < frames.length; i++) {
first.push(...frames[i]);
frames[i] = [];
}
}
return frames;
}

View File

@@ -0,0 +1,6 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'buckets', controls: { count: true } },
];

View File

@@ -0,0 +1,81 @@
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { formatPanelValue } from '../../utils/formatPanelValue';
import { resolveDecimalPrecision } from '../../utils/chartAppearanceMappings';
import { prepareNumberData } from './prepareData';
import { mapNumberThresholds } from './utils';
import ValueDisplay from './components/ValueDisplay/ValueDisplay';
function NumberPanelRenderer({
panel,
data,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesNumberPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const value = useMemo(
() =>
prepareNumberData(
prepareScalarTables({
results: getScalarResults(data?.response),
legendMap: data?.legendMap ?? {},
requestPayload: data?.requestPayload,
}),
),
[data?.response, data?.legendMap, data?.requestPayload],
);
const thresholds = useMemo(
() => mapNumberThresholds(spec.thresholds),
[spec.thresholds],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const unit = spec.formatting?.unit;
// Precision is applied regardless of whether a unit is set (see
// `formatPanelValue`), so decimal-precision changes always take effect.
const formattedValue = useMemo(
() => (value === null ? '' : formatPanelValue(value, unit, decimalPrecision)),
[value, unit, decimalPrecision],
);
return (
<div
data-testid="number-panel-renderer"
className={PanelStyles.panelContainer}
>
{value === null ? (
<Typography.Text data-testid="number-panel-no-data">
No Data
</Typography.Text>
) : (
<ValueDisplay
value={formattedValue}
rawValue={value}
thresholds={thresholds}
unit={unit}
/>
)}
</div>
);
}
export default NumberPanelRenderer;

View File

@@ -0,0 +1,155 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesNumberPanelSpecDTO,
type DashboardtypesPanelDTO,
DashboardtypesThresholdFormatDTO,
type QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { render } from 'tests/test-utils';
import { BaseRendererProps } from '../../../types/rendererProps';
import BaseNumberPanelRenderer from '../Renderer';
// The kind's interaction map is `Record<string, never>`, which makes the strict
// `PanelRendererProps<'signoz/NumberPanel'>` intersection impossible to satisfy
// with a literal. NumberPanel reads no interaction props, so render it against
// the base prop surface.
const NumberPanelRenderer =
BaseNumberPanelRenderer as React.FC<BaseRendererProps>;
// ValueDisplay observes its container to size the font.
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function panelWith(
spec: DashboardtypesNumberPanelSpecDTO,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/NumberPanel', spec } },
} as unknown as DashboardtypesPanelDTO;
}
// V5 scalar response: one table per query, value in the aggregation column.
function dataWith(value: string | number): PanelQueryData {
return {
response: {
status: 'success',
data: {
type: 'scalar',
data: {
results: [
{
queryName: 'A',
columns: [
{
name: '__result',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 0,
},
],
data: [[value]],
},
],
},
},
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
}
const emptyData: PanelQueryData = {
response: {
status: 'success',
data: { type: 'scalar', data: { results: [] } },
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
// NumberPanel adds no interaction props (its interaction map is
// `Record<string, never>`), so the base renderer props fully describe it.
function renderPanel(
props: Partial<BaseRendererProps>,
): ReturnType<typeof render> {
const baseProps: BaseRendererProps = {
panelId: 'panel-1',
panel: panelWith({}),
data: undefined,
isLoading: false,
error: null,
panelMode: PanelMode.DASHBOARD_VIEW,
...props,
};
return render(<NumberPanelRenderer {...baseProps} />);
}
describe('NumberPanelRenderer', () => {
it('renders the value with its y-axis unit', () => {
const { getByText } = renderPanel({
panel: panelWith({ formatting: { unit: 'ms' } }),
data: dataWith('295.4299833508185'),
});
expect(getByText('295.43')).toBeInTheDocument();
expect(getByText('ms')).toBeInTheDocument();
});
// Regression: with no unit configured, decimal precision must still apply.
// Previously the renderer fell back to `value.toString()` whenever the unit
// was empty, so precision changes had no effect on unitless panels.
it('applies decimal precision even when no unit is set', () => {
const { getByText, queryByText } = renderPanel({
panel: panelWith({}),
data: dataWith('3.14159'),
});
expect(getByText('3.14')).toBeInTheDocument();
expect(queryByText('3.14159')).not.toBeInTheDocument();
});
it('renders No Data when the response has no scalar results', () => {
const { getByTestId } = renderPanel({ data: emptyData });
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
});
it('renders No Data when the response is absent', () => {
const { getByTestId } = renderPanel({ data: undefined });
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
});
it('surfaces the conflicting-thresholds indicator when a value matches multiple thresholds', () => {
const { getByTestId } = renderPanel({
panel: panelWith({
thresholds: [
{
color: '#f00',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 0,
format: DashboardtypesThresholdFormatDTO.background,
},
{
color: '#0f0',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 100,
format: DashboardtypesThresholdFormatDTO.background,
},
],
}),
data: dataWith('295.4299833508185'),
});
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,62 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { prepareNumberData } from '../prepareData';
function tableWith(
columns: PanelTable['columns'],
rows: PanelTable['rows'],
): PanelTable {
return { queryName: 'A', legend: '', columns, rows };
}
describe('prepareNumberData', () => {
it('returns null for no tables', () => {
expect(prepareNumberData([])).toBeNull();
});
it('reads the first row of the value column', () => {
const table = tableWith(
[
{ name: 'group', queryName: 'A', isValueColumn: false, id: 'group' },
{ name: 'value', queryName: 'A', isValueColumn: true, id: 'val' },
],
[
{ data: { group: 'prod', val: '295.4299833508185' } },
{ data: { group: 'dev', val: '7' } },
],
);
expect(prepareNumberData([table])).toBeCloseTo(295.43, 2);
});
it('falls back to the row first value when no column is tagged isValueColumn', () => {
const table = tableWith(
[{ name: 'value', queryName: 'A', isValueColumn: false, id: 'value' }],
[{ data: { value: '7' } }],
);
expect(prepareNumberData([table])).toBe(7);
});
it('skips empty tables and reads the first one with rows', () => {
const empty = tableWith(
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
[],
);
const filled = tableWith(
[{ name: 'value', queryName: 'B', isValueColumn: true, id: 'B' }],
[{ data: { B: 42 } }],
);
expect(prepareNumberData([empty, filled])).toBe(42);
});
it('returns null when the value is non-numeric', () => {
const table = tableWith(
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
[{ data: { A: 'n/a' } }],
);
expect(prepareNumberData([table])).toBeNull();
});
});

View File

@@ -0,0 +1,99 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import { mapNumberThresholds } from '../utils';
describe('mapNumberThresholds', () => {
it('returns [] for null / undefined / empty', () => {
expect(mapNumberThresholds(null)).toStrictEqual([]);
expect(mapNumberThresholds(undefined)).toStrictEqual([]);
expect(mapNumberThresholds([])).toStrictEqual([]);
});
it('maps comparison operators to symbol operators', () => {
const thresholds: DashboardtypesComparisonThresholdDTO[] = [
{
color: '#f00',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 1,
},
{
color: '#0f0',
operator: DashboardtypesComparisonOperatorDTO.below,
value: 2,
},
{
color: '#00f',
operator: DashboardtypesComparisonOperatorDTO.above_or_equal,
value: 3,
},
{
color: '#ff0',
operator: DashboardtypesComparisonOperatorDTO.below_or_equal,
value: 4,
},
{
color: '#0ff',
operator: DashboardtypesComparisonOperatorDTO.equal,
value: 5,
},
];
const mapped = mapNumberThresholds(thresholds);
expect(mapped.map((t) => t.operator)).toStrictEqual([
'>',
'<',
'>=',
'<=',
'=',
]);
});
it('maps not_equal to !=', () => {
const mapped = mapNumberThresholds([
{
color: '#f00',
operator: DashboardtypesComparisonOperatorDTO.not_equal,
value: 1,
},
]);
expect(mapped[0].operator).toBe('!=');
});
it('maps format and carries value/unit/color', () => {
const mapped = mapNumberThresholds([
{
color: '#abcdef',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 100,
unit: 'ms',
format: DashboardtypesThresholdFormatDTO.background,
},
]);
expect(mapped[0]).toStrictEqual({
color: '#abcdef',
operator: '>',
value: 100,
unit: 'ms',
format: 'background',
});
});
it('maps text format to text', () => {
const mapped = mapNumberThresholds([
{
color: '#000',
value: 1,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
expect(mapped[0].format).toBe('text');
});
});

View File

@@ -0,0 +1,36 @@
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.textContainer {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: center;
flex-wrap: nowrap;
}
.valueText {
text-align: center;
font-weight: 400;
}
.conflictBackground {
position: absolute;
right: 10px;
bottom: 10px;
}
.conflictText {
margin-left: 10px;
margin-top: 20px;
}
.conflictIcon {
color: var(--warning-background);
}

View File

@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Tooltip } from 'antd';
import { CircleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type { PanelThreshold } from '../../../../types/threshold';
import { resolveActiveThreshold } from '../../../../utils/evaluateThresholds';
import { parseFormattedValue } from '../../../../utils/parseFormattedValue';
import styles from './ValueDisplay.module.scss';
import { useResponsiveFontSize } from '../../../../hooks/useResponsiveFontSize';
import ValueUnit from '../ValueUnit/ValueUnit';
interface ValueDisplayProps {
/** The pre-formatted value string (may include a unit label). */
value: string;
/** The raw numeric value, used for threshold evaluation. */
rawValue: number;
thresholds: PanelThreshold[];
/** The panel's unit, used to convert threshold units before comparison. */
unit?: string;
}
/**
* Renders a single large scalar with optional prefix/suffix units and threshold
* recoloring (text or background). A V2-native replacement for the V1
* `ValueGraph` — depends only on V2 threshold utilities and the shared icon/
* typography primitives.
*/
function ValueDisplay({
value,
rawValue,
thresholds,
unit,
}: ValueDisplayProps): JSX.Element {
const { t } = useTranslation(['valueGraph']);
const { containerRef, fontSize } = useResponsiveFontSize();
const { numericValue, prefixUnit, suffixUnit } = useMemo(
() => parseFormattedValue(value),
[value],
);
const { threshold, isConflicting } = useMemo(
() => resolveActiveThreshold(thresholds, rawValue, unit),
[thresholds, rawValue, unit],
);
const isBackground = threshold?.format === 'background';
const textColor = threshold?.format === 'text' ? threshold.color : undefined;
const backgroundColor = isBackground ? threshold?.color : undefined;
return (
<div
ref={containerRef}
className={styles.container}
style={{ backgroundColor }}
>
<div className={styles.textContainer}>
{prefixUnit && (
<ValueUnit
type="prefix"
unit={prefixUnit}
color={textColor}
fontSize={fontSize}
/>
)}
<Typography.Text
className={styles.valueText}
data-testid="number-panel-value"
style={{ color: textColor, fontSize }}
>
{numericValue}
</Typography.Text>
{suffixUnit && (
<ValueUnit
type="suffix"
unit={suffixUnit}
color={textColor}
fontSize={fontSize}
/>
)}
</div>
{isConflicting && (
<div
className={isBackground ? styles.conflictBackground : styles.conflictText}
>
<Tooltip title={t('this_value_satisfies_multiple_thresholds')}>
<CircleAlert
className={styles.conflictIcon}
data-testid="conflicting-thresholds"
size="md"
/>
</Tooltip>
</div>
)}
</div>
);
}
export default ValueDisplay;

View File

@@ -0,0 +1,5 @@
.unit {
margin-left: 4px;
font-weight: 300;
opacity: 0.8;
}

View File

@@ -0,0 +1,31 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './ValueUnit.module.scss';
interface ValueUnitProps {
type: 'prefix' | 'suffix';
unit: string;
/** Text color, set only when a "text" threshold is active. */
color?: string;
fontSize: string;
}
/** A prefix/suffix unit label rendered alongside the numeric value. */
function ValueUnit({
type,
unit,
color,
fontSize,
}: ValueUnitProps): JSX.Element {
return (
<Typography.Text
className={styles.unit}
data-testid={`value-display-${type}-unit`}
style={{ color, fontSize: `calc(${fontSize} * 0.7)` }}
>
{unit}
</Typography.Text>
);
}
export default ValueUnit;

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: { view: true, edit: true, download: false, createAlert: true },
};

View File

@@ -0,0 +1,33 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
/**
* Reduces the scalar tables of a V5 response to the single number a
* NumberPanel renders.
*
* V2 always issues `requestType: 'scalar'` for VALUE panels, so the response
* is a scalar table per query (see `prepareScalarTables`). The value is the
* first row's `isValueColumn` cell of the first table that has rows —
* falling back to the row's first cell when no column is marked as the
* value (mirrors the V1 `formatForWeb` fallback read).
*
* Returns `null` when there is no numeric value to show, which the renderer
* maps to the "No Data" state.
*/
export function prepareNumberData(tables: PanelTable[]): number | null {
for (const table of tables) {
if (table.rows.length === 0) {
continue;
}
const row = table.rows[0].data;
const valueColumn = table.columns.find((column) => column.isValueColumn);
const raw = valueColumn
? row[valueColumn.id || valueColumn.name]
: Object.values(row)[0];
const value = Number(raw);
if (Number.isFinite(value)) {
return value;
}
}
return null;
}

View File

@@ -0,0 +1,8 @@
import type { SectionConfig } from '../../types/sections';
// A number panel renders one scalar — no axes, legend, or stacking. Just value
// formatting and thresholds that recolor the value/background.
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { list: true } },
];

View File

@@ -0,0 +1,54 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
PanelThreshold,
ThresholdComparisonOperator,
ThresholdDisplayFormat,
} from '../../types/threshold';
// Perses comparison operators → the symbol operators V2 threshold evaluation
// uses.
const OPERATOR_MAP: Record<
DashboardtypesComparisonOperatorDTO,
ThresholdComparisonOperator
> = {
[DashboardtypesComparisonOperatorDTO.above]: '>',
[DashboardtypesComparisonOperatorDTO.below]: '<',
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
[DashboardtypesComparisonOperatorDTO.equal]: '=',
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
};
const FORMAT_MAP: Record<
DashboardtypesThresholdFormatDTO,
ThresholdDisplayFormat
> = {
[DashboardtypesThresholdFormatDTO.text]: 'text',
[DashboardtypesThresholdFormatDTO.background]: 'background',
};
/**
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
* V2-native `PanelThreshold` consumed by `ValueDisplay` / threshold
* evaluation. No dependency on the V1 `ThresholdProps` shape.
*/
export function mapNumberThresholds(
thresholds: DashboardtypesComparisonThresholdDTO[] | null | undefined,
): PanelThreshold[] {
if (!thresholds || thresholds.length === 0) {
return [];
}
return thresholds.map((threshold) => ({
color: threshold.color,
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
value: threshold.value,
unit: threshold.unit,
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
}));
}

View File

@@ -0,0 +1,88 @@
import { useCallback, useMemo } from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { preparePieData } from './prepareData';
function PiePanelRenderer({
panelId,
panel,
data,
onClick,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesPieChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const slices = useMemo(
() =>
preparePieData({
tables: prepareScalarTables({
results: getScalarResults(data?.response),
legendMap: data?.legendMap ?? {},
requestPayload: data?.requestPayload,
}),
customColors: spec.legend?.customColors,
isDarkMode,
}),
[
data?.response,
data?.legendMap,
data?.requestPayload,
spec.legend?.customColors,
isDarkMode,
],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const handleSliceClick = useCallback(
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
},
[onClick],
);
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
<Pie
data={slices}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={handleSliceClick}
data-testid="pie-chart"
/>
</div>
);
}
export default PiePanelRenderer;

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: { view: true, edit: true, download: false, createAlert: true },
};

View File

@@ -0,0 +1,58 @@
import { themeColors } from 'constants/theme';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
export interface PreparePieDataArgs {
/** Scalar tables from the V5 response (see `prepareScalarTables`). */
tables: PanelTable[];
/** Per-label colour overrides from `spec.legend.customColors`. */
customColors?: Record<string, string> | null;
isDarkMode: boolean;
}
/**
* Turns the scalar tables of a V5 response into pie slices: one slice per
* group row. The aggregation column holds the value, the group column(s)
* form the label. Colours honour `customColors` then fall back to a
* deterministic palette colour; non-positive / non-numeric values are
* dropped.
*/
export function preparePieData({
tables,
customColors,
isDarkMode,
}: PreparePieDataArgs): PieSlice[] {
const colorMap = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const slices: PieSlice[] = [];
tables.forEach((table) => {
const valueColumn = table.columns.find((column) => column.isValueColumn);
if (!valueColumn) {
return;
}
const valueKey = valueColumn.id || valueColumn.name;
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
table.rows.forEach((row) => {
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ') ||
table.legend ||
table.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
});
});
return slices.filter(
(slice) => Number.isFinite(slice.value) && slice.value > 0,
);
}

View File

@@ -0,0 +1,8 @@
import type { SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a
// legend. `mode` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
];

View File

@@ -0,0 +1,180 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getExecStats,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildTimeSeriesConfig } from './buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function TimeSeriesPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
// documented boundary narrowing — not a blind assertion. Memoized so the
// `?? {}` fallback doesn't produce a fresh object on each render.
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
// X-scale clamps come from the request that produced the data, so each
// panel pins to the window it actually fetched — important during
// drag-zoom transitions when the time picker has moved but new data
// hasn't arrived yet. Falls back to the global picker inside the helper.
// The generated request DTO is structurally the hand-written V5 request;
// the cast is the documented boundary.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data?.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data?.requestPayload]);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const flatSeries = useMemo(
() =>
flattenTimeSeries(
getTimeSeriesResults(data?.response),
data?.legendMap ?? {},
),
[data?.response, data?.legendMap],
);
const config = useMemo(
() =>
buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
stepIntervals: getExecStats(data?.response)?.stepIntervals,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
flatSeries,
data?.response,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
/**
* The uPlot key prop is the only way to force a full teardown and re-mount
* of the chart. By including the syncMode and syncFilterMode in the key,
* we ensure that changes to these preferences trigger a fresh chart instance,
* preventing stale sync settings from being inherited.
*/
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default TimeSeriesPanelRenderer;

View File

@@ -0,0 +1,159 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
FILL_MODE_MAP,
LINE_INTERPOLATION_MAP,
LINE_STYLE_MAP,
resolveSpanGaps,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/chartAppearanceMappings';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import {
hasSingleVisiblePoint,
toClickPluginPayload,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const DEFAULT_POINT_SIZE = 5;
export interface BuildTimeSeriesConfigArgs {
panelId: string;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the TimeSeries-specific concern: one series per result, with visuals
* resolved from `spec.chartAppearance`.
*/
export function buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.TIME_SERIES,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
stepIntervals,
clickPayload: toClickPluginPayload(series),
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeries({ builder, spec, builderQueries, series, isDarkMode });
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
isDarkMode: boolean;
}
/**
* Adds one uPlot series per flattened V5 series to the scaffolded builder.
* The visual resolution (line style, interpolation, fill mode, span gaps)
* reads from `spec.chartAppearance`; the label is resolved via the legend
* matrix in `resolveSeriesLabelV5`. Mutates the builder in place.
*
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
isDarkMode,
}: AddSeriesArgs): void {
const chartAppearance = spec.chartAppearance;
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]
: LineStyle.Solid;
const lineInterpolation = chartAppearance?.lineInterpolation
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
: LineInterpolation.Spline;
const fillMode = chartAppearance?.fillMode
? FILL_MODE_MAP[chartAppearance.fillMode]
: FillMode.None;
series.forEach((s) => {
const hasSingleValidPoint = hasSingleVisiblePoint(s.values);
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
// A single visible point can't be drawn as a line — degrade to points
// so the user still sees the datum (matches V1 behavior).
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping,
spanGaps,
lineStyle,
lineInterpolation,
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
pointSize: DEFAULT_POINT_SIZE,
fillMode,
isDarkMode,
metric: s.labels,
});
});
}

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: { view: true, edit: true, download: false, createAlert: true },
};

View File

@@ -0,0 +1,15 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'formatting',
controls: {
unit: true,
decimals: true,
},
},
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
];

View File

@@ -0,0 +1,9 @@
.panelContainer {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}

View File

@@ -0,0 +1,59 @@
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
/**
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
* each non-chart kind carries the context its drill-down needs. The `source`
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
* handler) discriminate without assuming a chart shape.
*/
export type ChartClickEvent = ChartClickData;
export type TableClickEvent = {
rowData: Record<string, unknown>;
columnId?: string;
};
export type ListClickEvent = {
rowData: Record<string, unknown>;
};
export type PieClickEvent = { label: string; value: number };
/** Union of every panel click event — switched on by `source` at the boundary. */
export type PanelClickEvent =
| ChartClickEvent
| TableClickEvent
| ListClickEvent
| PieClickEvent;
type DragSelect = (start: number, end: number) => void;
/**
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
* rendererProps.ts indexes this map, so a missing kind is a compile error there.
*/
export interface PanelInteractionMap {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
'signoz/NumberPanel': Record<string, never>;
}
/**
* Widest interaction surface — used where the panel kind is not known
* statically (the registry render boundary; see `getPanelDefinition`). It is
* the structural supertype the per-kind shapes are cast to exactly once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onDragSelect?: DragSelect;
}

View File

@@ -0,0 +1,56 @@
import type { ComponentType } from 'react';
import { DataSource } from 'types/common/queryBuilder';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/**
* Kind-level action capabilities: which panel actions THIS kind supports.
* Declared per-kind in `kinds/<Kind>/definition.ts` — the field is required,
* so registering a new kind forces an explicit decision for every action
* (mirroring how PanelInteractionMap forces per-kind interaction coverage).
*
* Chrome actions (move to section, clone, delete) are dashboard-layout
* concerns, available for every panel — including kinds V2 can't render —
* and are intentionally not declarable here.
*/
export interface PanelActionCapabilities {
/** Kind has a full-screen view — gates the "View" action. */
view: boolean;
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
edit: boolean;
/**
* Kind's data can be exported as CSV — gates "Download as CSV". V1 parity:
* only table panels carry tabular data worth exporting.
*/
download: boolean;
/** Kind's query can seed a new alert — gates "Create Alerts". */
createAlert: boolean;
}
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
actions: PanelActionCapabilities;
}
// Keyed registry that preserves the kind ↔ definition correlation: indexing
// with a literal kind yields that kind's exactly-typed PanelDefinition.
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
// At the render boundary the concrete kind isn't known statically (a registry
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
// concentrating the single unavoidable cast in one place instead of leaking it
// to every call site.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}

View File

@@ -0,0 +1,20 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
export type PanelKind =
| 'signoz/TimeSeriesPanel'
| 'signoz/BarChartPanel'
| 'signoz/NumberPanel'
| 'signoz/PieChartPanel'
| 'signoz/TablePanel'
| 'signoz/HistogramPanel'
| 'signoz/ListPanel';
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
'signoz/NumberPanel': PANEL_TYPES.VALUE,
'signoz/PieChartPanel': PANEL_TYPES.PIE,
'signoz/TablePanel': PANEL_TYPES.TABLE,
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
};

View File

@@ -0,0 +1,75 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { PanelInteractionMap } from './interactions';
import type { PanelKind } from './panelKind';
/**
* Dashboard-wide rendering preferences propagated down to every panel renderer
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
* sync, tooltip filter mode, dashboard id for scoped state) without each
* renderer rediscovering them via hooks. All fields are optional — non-
* dashboard render contexts (PanelEditor preview, standalone view) can pass
* an empty object and the renderer will fall back to sensible defaults.
*/
export interface DashboardPreference {
/**
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
* hovering one panel highlights the corresponding x on every other panel.
*/
syncMode?: DashboardCursorSync;
/**
* Filter applied to the synced tooltip across panels (e.g. only show series
* whose label matches the hovered series).
*/
syncFilterMode?: SyncTooltipFilterMode;
/**
* Dashboard id — useful for renderers that scope per-dashboard state
* (e.g. pinned-tooltip persistence, drill-down history).
*/
dashboardId?: string;
}
// Kind-agnostic props every renderer receives, regardless of panel kind. The
// kind-specific interaction props (onClick payload, onDragSelect) are layered
// on per-kind by PanelRendererProps<K>.
export interface BaseRendererProps {
panelId: string;
/**
* The whole perses panel — renderers derive their concrete `spec` and the
* perses-shaped `queries` from this. Passing the full panel keeps the prop
* surface stable as new panel-level fields are added to the wire format.
*/
panel: DashboardtypesPanelDTO | undefined;
/** Raw V5 fetch result — response + the request that produced it. */
data?: PanelQueryData;
isLoading: boolean;
error: Error | null;
/** Gate for the drill-down right-click menu. Off by default in V2. */
enableDrillDown?: boolean;
/**
* Render context — varies behavior (e.g. dashboard widget vs. standalone
* full-screen vs. inside the editor). See PanelMode for the contract.
*/
panelMode: PanelMode;
/**
* Dashboard-level preferences that should propagate to every panel
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
* resolving these; the renderer just consumes them.
*/
dashboardPreference?: DashboardPreference;
}
// Renderer props for a specific panel kind: the shared base plus that kind's
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
// only reference the gestures that kind supports. Indexing PanelInteractionMap
// here forces the map 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];

View File

@@ -0,0 +1,55 @@
import {
BarChart,
Columns3,
Hash,
ListEnd,
Palette,
Ruler,
SlidersHorizontal,
} from '@signozhq/icons';
// Derived from an actual icon component so the type stays exact (size is a
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
export type SectionIcon = typeof Hash;
export interface SectionMetadata {
title: string;
icon: SectionIcon;
description?: string;
}
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
// Section components type their controls prop via `SectionControls['axes']`.
export type SectionControls = {
formatting: { unit?: boolean; decimals?: boolean };
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
legend: { position?: boolean; mode?: boolean };
thresholds: { list?: boolean };
chartAppearance: {
lineStyle?: boolean;
fillOpacity?: boolean;
stacked?: boolean;
};
columnUnits: { perColumnUnit?: boolean };
buckets: { count?: boolean; min?: boolean; max?: boolean };
};
// Source of truth for sections. Its keys define SectionKind; its values are the
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
// one entry here + one entry in SectionControls.
export const SECTIONS = {
formatting: { title: 'Formatting', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: ListEnd },
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
chartAppearance: { title: 'Chart appearance', icon: Palette },
columnUnits: { title: 'Column units', icon: Columns3 },
buckets: { title: 'Buckets', icon: BarChart },
} as const satisfies Record<string, SectionMetadata>;
export type SectionKind = keyof typeof SECTIONS;
// Discriminated union derived from SectionControls — kept in lockstep automatically.
export type SectionConfig = {
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
}[SectionKind];

View File

@@ -0,0 +1,29 @@
/**
* V2-native threshold model.
*
* The panel spec carries thresholds as `DashboardtypesComparisonThresholdDTO`
* (operator/format expressed as `above`/`below`/`text`/`background`). For
* evaluation and rendering we work with the symbol operators and lowercase
* display formats, kept here so V2 panels never reach into the V1
* `container/NewWidget` `ThresholdProps` shape.
*/
/** Comparison operators a threshold can use, as evaluable symbols. */
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
/** How a matched threshold recolors the panel. */
export type ThresholdDisplayFormat = 'text' | 'background';
/**
* A threshold normalized for evaluation/rendering. `operator`/`format` are
* optional because the spec allows partially-configured thresholds; a
* threshold with no operator never matches.
*/
export interface PanelThreshold {
color: string;
operator?: ThresholdComparisonOperator;
value: number;
/** Unit the threshold value is expressed in; converted to the panel unit before comparison. */
unit?: string;
format?: ThresholdDisplayFormat;
}

View File

@@ -0,0 +1,71 @@
import type { PanelThreshold } from '../../types/threshold';
import {
doesValueMatchThreshold,
resolveActiveThreshold,
} from '../evaluateThresholds';
const threshold = (overrides: Partial<PanelThreshold>): PanelThreshold => ({
color: '#f00',
value: 100,
operator: '>',
...overrides,
});
describe('doesValueMatchThreshold', () => {
it.each([
['>', 150, 100, true],
['>', 50, 100, false],
['<', 50, 100, true],
['>=', 100, 100, true],
['<=', 100, 100, true],
['=', 100, 100, true],
['!=', 150, 100, true],
] as const)('evaluates %s (%d vs %d)', (operator, value, target, expected) => {
expect(
doesValueMatchThreshold(value, threshold({ operator, value: target })),
).toBe(expected);
});
it('never matches a threshold without an operator', () => {
expect(doesValueMatchThreshold(150, threshold({ operator: undefined }))).toBe(
false,
);
});
it('compares the raw value when units are in different categories', () => {
// 'bytes' vs 'ms' belong to different categories, so conversion is invalid
// and the comparison falls back to the raw value (150 > 100).
expect(
doesValueMatchThreshold(150, threshold({ value: 100, unit: 'bytes' }), 'ms'),
).toBe(true);
});
});
describe('resolveActiveThreshold', () => {
it('returns no threshold when none match', () => {
const result = resolveActiveThreshold([threshold({ value: 1000 })], 10);
expect(result.threshold).toBeNull();
expect(result.isConflicting).toBe(false);
});
it('flags a conflict and picks the earliest-declared match', () => {
const first = threshold({ color: '#aaa', operator: '>', value: 0 });
const second = threshold({ color: '#bbb', operator: '>', value: 100 });
const result = resolveActiveThreshold([first, second], 150);
expect(result.isConflicting).toBe(true);
expect(result.threshold).toBe(first);
});
it('returns the single matching threshold without a conflict', () => {
const only = threshold({ color: '#abc', operator: '>', value: 100 });
const result = resolveActiveThreshold(
[only, threshold({ value: 9999 })],
150,
);
expect(result.threshold).toBe(only);
expect(result.isConflicting).toBe(false);
});
});

View File

@@ -0,0 +1,33 @@
import { PrecisionOptionsEnum } from 'components/Graph/types';
import { formatPanelValue } from '../formatPanelValue';
describe('formatPanelValue', () => {
it('applies the configured precision and appends the unit label', () => {
// The unit-aware formatter returns value + label as one string; the
// ValueDisplay splits it into numeric/suffix parts when rendering.
expect(formatPanelValue(295.4299833508185, 'ms', 2)).toBe('295.43 ms');
});
// Regression: precision must apply even with no unit. The old gate
// (`unit ? format() : value.toString()`) dropped precision on unitless
// panels, so decimal-precision changes had no visible effect.
it('applies precision when NO unit is set', () => {
expect(formatPanelValue(3.14159, undefined, 2)).toBe('3.14');
expect(formatPanelValue(3.14159, '', 2)).toBe('3.14');
});
it('honors full precision without a unit', () => {
expect(formatPanelValue(3.14159, undefined, PrecisionOptionsEnum.FULL)).toBe(
'3.14159',
);
});
it('drops the fractional part at precision 0', () => {
expect(formatPanelValue(3.14159, undefined, 0)).toBe('3');
});
it('renders whole numbers without a trailing decimal', () => {
expect(formatPanelValue(5, undefined, 2)).toBe('5');
});
});

View File

@@ -0,0 +1,120 @@
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import { resolveSeriesLabelV5 } from '../resolveSeriesLabel';
// Fixtures cast at the boundary; the v5 BuilderQuery union is too verbose to
// construct field-typed inline.
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
return spec as unknown as BuilderQuery;
}
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
return {
queryName: 'A',
legend: '',
labels: { host: 'h1' },
kind: 'series',
values: [],
aggregation: { index: 0, alias: '' },
...overrides,
};
}
describe('resolveSeriesLabelV5', () => {
it('returns baseLabel for panels without builder queries (promql/clickhouse)', () => {
expect(resolveSeriesLabelV5(panelSeries(), [], 'base')).toBe('base');
});
it('returns baseLabel when no query matches the series queryName', () => {
expect(
resolveSeriesLabelV5(
panelSeries({ queryName: 'Z' }),
[builderQuery({ name: 'A' })],
'base',
),
).toBe('base');
});
it('falls back to baseLabel || queryName when the aggregation has no alias/expression (metrics)', () => {
const queries = [
builderQuery({ name: 'A', aggregations: [{ metricName: 'cpu' }] }),
];
expect(resolveSeriesLabelV5(panelSeries(), queries, 'base')).toBe('base');
expect(resolveSeriesLabelV5(panelSeries(), queries, '')).toBe('A');
});
it('single query + groupBy + single aggregation → baseLabel', () => {
const queries = [
builderQuery({
name: 'A',
groupBy: [{ name: 'host' }],
aggregations: [{ expression: 'count()', alias: '' }],
}),
];
expect(resolveSeriesLabelV5(panelSeries(), queries, 'h1')).toBe('h1');
});
it('single query + groupBy + multiple aggregations → "alias-or-expression"-baseLabel', () => {
const queries = [
builderQuery({
name: 'A',
groupBy: [{ name: 'host' }],
aggregations: [
{ expression: 'count()', alias: '' },
{ expression: 'avg(x)', alias: 'mean' },
],
}),
];
expect(
resolveSeriesLabelV5(
panelSeries({ aggregation: { index: 1, alias: 'mean' } }),
queries,
'h1',
),
).toBe('mean-h1');
});
it('single query, no groupBy, single aggregation → alias || legend || expression', () => {
const queries = [
builderQuery({
name: 'A',
legend: 'My legend',
aggregations: [{ expression: 'count()', alias: '' }],
}),
];
expect(resolveSeriesLabelV5(panelSeries({ labels: {} }), queries, 'A')).toBe(
'My legend',
);
});
it('multiple queries, no groupBy, single aggregation → alias || baseLabel', () => {
const queries = [
builderQuery({ name: 'A', aggregations: [{ expression: 'count()' }] }),
builderQuery({ name: 'B', aggregations: [{ expression: 'sum(y)' }] }),
];
expect(
resolveSeriesLabelV5(panelSeries({ labels: {} }), queries, 'base'),
).toBe('base');
});
it('resolves via the aggregation index carried on the series', () => {
const queries = [
builderQuery({
name: 'A',
aggregations: [
{ expression: 'count()', alias: 'hits' },
{ expression: 'avg(x)', alias: 'mean' },
],
}),
];
expect(
resolveSeriesLabelV5(
panelSeries({ labels: {}, aggregation: { index: 1, alias: 'mean' } }),
queries,
'',
),
).toBe('mean');
});
});

View File

@@ -0,0 +1,207 @@
import type {
DashboardtypesPanelFormattingDTO,
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
/**
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
* name but accepts perses-shaped inputs directly (so callers don't translate
* once per panel). The series-rendering step is panel-specific and lives in
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
* thresholds, axes, drag-to-zoom, click plugin).
*/
export interface BuildBaseConfigArgs {
panelId: string;
panelType: PANEL_TYPES;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
/** From `spec.axes` — drives the Y scale and (when log) both scales' base. */
isLogScale?: boolean;
softMin?: number;
softMax?: number;
/** From `spec.formatting.unit` — drives Y axis tick formatting + threshold formatting. */
formatting?: DashboardtypesPanelFormattingDTO;
/** From `spec.thresholds` — perses shape, mapped to the draw-hook shape internally. */
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
/**
* Tuple-shaped payload for the shared click plugin (see
* `toClickPluginPayload`). Omitted by panels without click interactions.
*/
clickPayload?: MetricRangePayloadProps;
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
minTimeScale?: number;
maxTimeScale?: number;
/** Optional — histogram and other non-time panels omit drag-to-zoom. */
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
}
/**
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
* then call `addSeries`/`addPlugin` on the returned builder for their own
* panel-specific rendering.
*/
export function buildBaseConfig({
panelId,
panelType,
isDarkMode,
timezone,
panelMode,
isLogScale,
softMin,
softMax,
formatting,
thresholds,
stepIntervals,
clickPayload,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
}: BuildBaseConfigArgs): UPlotConfigBuilder {
const yAxisUnit = formatting?.unit;
const builder = new UPlotConfigBuilder({
id: panelId,
onDragSelect,
tzDate: makeTzDate(timezone),
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
stepInterval: stepIntervals ? minStepInterval(stepIntervals) : undefined,
});
const thresholdOptions: ThresholdsDrawHookOptions = {
scaleKey: 'y',
thresholds: mapThresholds(thresholds),
yAxisUnit,
};
builder.addThresholds(thresholdOptions);
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
builder.addScale({
scaleKey: 'y',
time: false,
min: undefined,
max: undefined,
softMin,
softMax,
thresholds: thresholdOptions,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
if (typeof onClick === 'function') {
builder.addPlugin(onClickPlugin({ onClick, apiResponse: clickPayload }));
}
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
isLogScale,
panelType,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;
}
function makeTzDate(
timezone: Timezone,
): ((timestamp: number) => Date) | undefined {
if (!timezone) {
return undefined;
}
return (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
}
function resolveSelectionPreferencesSource(
panelMode: PanelMode,
): SelectionPreferencesSource {
return panelMode === PanelMode.DASHBOARD_VIEW ||
panelMode === PanelMode.STANDALONE_VIEW
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY;
}
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
// panels that need to feed the same threshold list elsewhere (e.g. to a series
// `addSeries` thresholds hook) don't have to redo the mapping.
export function mapThresholds(
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
): ThresholdsDrawHookOptions['thresholds'] {
if (!thresholds || thresholds.length === 0) {
return [];
}
return thresholds.map((t) => ({
thresholdValue: t.value,
thresholdColor: t.color,
thresholdUnit: t.unit,
thresholdLabel: t.label,
}));
}
/**
* V5 backend reports per-query step intervals; we feed the smallest one through
* to uPlot so the X-axis tick density matches the densest query. An empty map
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math —
* fall back to `undefined` (uPlot's "auto") in that case.
*/
function minStepInterval(
stepIntervals: Record<string, number>,
): number | undefined {
const values = Object.values(stepIntervals);
if (values.length === 0) {
return undefined;
}
const min = Math.min(...values);
return Number.isFinite(min) ? min : undefined;
}

View File

@@ -0,0 +1,108 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesPrecisionOptionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
/**
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
* two — don't coerce, map.
*
* Kept as a single source of truth so every panel that reads chart-appearance
* fields stays in sync as either side's enum evolves.
*/
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {
[DashboardtypesLineStyleDTO.solid]: LineStyle.Solid,
[DashboardtypesLineStyleDTO.dashed]: LineStyle.Dashed,
};
export const LINE_INTERPOLATION_MAP: Record<
DashboardtypesLineInterpolationDTO,
LineInterpolation
> = {
[DashboardtypesLineInterpolationDTO.linear]: LineInterpolation.Linear,
[DashboardtypesLineInterpolationDTO.spline]: LineInterpolation.Spline,
[DashboardtypesLineInterpolationDTO.step_after]: LineInterpolation.StepAfter,
[DashboardtypesLineInterpolationDTO.step_before]: LineInterpolation.StepBefore,
};
export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
[DashboardtypesFillModeDTO.solid]: FillMode.Solid,
[DashboardtypesFillModeDTO.gradient]: FillMode.Gradient,
[DashboardtypesFillModeDTO.none]: FillMode.None,
};
export const LEGEND_POSITION_MAP: Record<
DashboardtypesLegendPositionDTO,
LegendPosition
> = {
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
};
/**
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
* (`'0'``'4'` plus the sentinel `'full'`). The chart consumes a numeric
* `PrecisionOption` (`0``4`) or the same `'full'` sentinel from its own
* enum. Missing / unknown → `undefined` (chart uses its default).
*/
export function resolveDecimalPrecision(
precision: DashboardtypesPrecisionOptionDTO | undefined,
): PrecisionOption | undefined {
if (!precision) {
return undefined;
}
if (precision === DashboardtypesPrecisionOptionDTO.full) {
return PrecisionOptionsEnum.FULL;
}
const parsed = Number(precision);
if (
parsed === 0 ||
parsed === 1 ||
parsed === 2 ||
parsed === 3 ||
parsed === 4
) {
return parsed;
}
return undefined;
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
* the threshold so uPlot only bridges short runs of nulls.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
): boolean | number {
if (!fillLessThan) {
return true;
}
const parsed = Number(fillLessThan);
return Number.isFinite(parsed) ? parsed : true;
}
/**
* Resolves the legend position for a panel. Missing / unknown values fall
* back to `BOTTOM` to match the chart's default and the V1 behavior.
*/
export function resolveLegendPosition(
position: DashboardtypesLegendPositionDTO | undefined,
): LegendPosition {
if (position && position in LEGEND_POSITION_MAP) {
return LEGEND_POSITION_MAP[position];
}
return LegendPosition.BOTTOM;
}

View File

@@ -0,0 +1,139 @@
import {
UniversalUnitToGrafanaUnit,
YAxisCategoryNames,
} from 'components/YAxisUnitSelector/constants';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
import { convertValue } from 'lib/getConvertedValue';
import type {
PanelThreshold,
ThresholdComparisonOperator,
} from '../types/threshold';
/**
* Threshold evaluation for V2 panels — a self-contained port of the V1
* `GridTableComponent`/`ValueGraph` logic that depends only on shared,
* non-V1 primitives (`convertValue`, the Y-axis unit catalog). No imports
* from `container/NewWidget`, `container/GridTableComponent`, or
* `components/ValueGraph`.
*/
/** Resolves which unit category a unit id belongs to, or null if unknown. */
function getCategoryName(unitId: string): YAxisCategoryNames | null {
const categories = getYAxisCategories(YAxisSource.DASHBOARDS);
const foundCategory = categories.find((category) =>
category.units.some((unit) => {
// Category units use universal ids; thresholds/panel units may use
// Grafana-style ids. Match either the universal id directly or its
// mapped Grafana id.
if (unit.id === unitId) {
return true;
}
return UniversalUnitToGrafanaUnit[unit.id] === unitId;
}),
);
return foundCategory ? foundCategory.name : null;
}
/**
* Converts `value` from `fromUnit` to `toUnit`, returning null when the
* conversion is invalid (unknown unit, or units in different categories).
*/
function convertUnit(
value: number,
fromUnit?: string,
toUnit?: string,
): number | null {
if (!fromUnit || !toUnit) {
return null;
}
const fromCategory = getCategoryName(fromUnit);
const toCategory = getCategoryName(toUnit);
if (!fromCategory || !toCategory || fromCategory !== toCategory) {
return null;
}
return convertValue(value, fromUnit, toUnit);
}
function evaluateCondition(
operator: ThresholdComparisonOperator | undefined,
value: number,
thresholdValue: number,
): boolean {
switch (operator) {
case '>':
return value > thresholdValue;
case '<':
return value < thresholdValue;
case '>=':
return value >= thresholdValue;
case '<=':
return value <= thresholdValue;
case '=':
return value === thresholdValue;
case '!=':
return value !== thresholdValue;
default:
return false;
}
}
/**
* Whether `value` (expressed in `panelUnit`) satisfies `threshold`. When the
* threshold declares its own unit, the panel value is converted into that unit
* before comparing; if the conversion is invalid we compare the raw value.
*/
export function doesValueMatchThreshold(
value: number,
threshold: PanelThreshold,
panelUnit?: string,
): boolean {
if (threshold.operator === undefined) {
return false;
}
const convertedValue = convertUnit(value, panelUnit, threshold.unit);
const comparable = convertedValue ?? value;
return evaluateCondition(threshold.operator, comparable, threshold.value);
}
export interface ActiveThreshold {
/** The matched threshold to apply, or null when none match. */
threshold: PanelThreshold | null;
/** True when more than one threshold matched the value. */
isConflicting: boolean;
}
/**
* Resolves the threshold to apply for `value`. Among matching thresholds the
* one declared earliest (lowest index) wins, mirroring V1 precedence; a match
* count greater than one flags a conflict.
*/
export function resolveActiveThreshold(
thresholds: PanelThreshold[],
value: number,
panelUnit?: string,
): ActiveThreshold {
const matching = thresholds.filter((threshold) =>
doesValueMatchThreshold(value, threshold, panelUnit),
);
if (matching.length === 0) {
return { threshold: null, isConflicting: false };
}
const highestPrecedence = matching.reduce((winner, candidate) =>
thresholds.indexOf(candidate) < thresholds.indexOf(winner)
? candidate
: winner,
);
return { threshold: highestPrecedence, isConflicting: matching.length > 1 };
}

View File

@@ -0,0 +1,22 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
/**
* Formats a scalar for display in a V2 panel, honoring the configured decimal
* precision. The shared, unit-aware `getYAxisFormattedValue` is the single
* formatting helper across V2 panels (number/table/list/pie); this wrapper is
* the only seam through which panels touch it.
*
* Precision is applied REGARDLESS of whether a unit is set. When no unit is
* configured we format through the `'none'` unit, which still respects
* precision — this is the fix for decimal precision being silently dropped on
* unitless panels (the old `unit ? format() : value.toString()` gate threw the
* precision away whenever the unit was empty).
*/
export function formatPanelValue(
value: number,
unit?: string,
precision?: PrecisionOption,
): string {
return getYAxisFormattedValue(String(value), unit || 'none', precision);
}

View File

@@ -0,0 +1,38 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Flattens a panel's queries into the list of builder queries it contains —
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
* carry the legend / groupBy / aggregation context downstream code needs.
*
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
* summary type — so callers consume the same type the wire format defines.
*/
export function getBuilderQueries(
queries: DashboardtypesQueryDTO[] | null | undefined,
): BuilderQuery[] {
if (!queries) {
return [];
}
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope?.spec?.plugin;
if (!plugin) {
return;
}
if (plugin.kind === 'signoz/BuilderQuery') {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries ?? []).forEach((sub) => {
if (sub.type === 'builder_query') {
flattened.push(sub.spec as BuilderQuery);
}
});
}
});
return flattened;
}

View File

@@ -0,0 +1,26 @@
export interface ParsedFormattedValue {
/** The numeric portion (e.g. "295.43", "1.2K"). */
numericValue: string;
/** A leading unit symbol such as a currency prefix, if any. */
prefixUnit: string;
/** A trailing unit label such as "ms" or "MB", if any. */
suffixUnit: string;
}
/**
* Splits a formatted value string (e.g. "$ 1.2K", "295.43 ms") into its
* numeric core and any prefix/suffix unit so each part can be styled
* independently. Falls back to treating the whole string as the numeric value
* when it doesn't match the expected shape.
*/
export function parseFormattedValue(value: string): ParsedFormattedValue {
const matches = value.match(
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
);
return {
numericValue: matches?.[2] || value,
prefixUnit: matches?.[1]?.trim() || '',
suffixUnit: matches?.[3]?.trim() || '',
};
}

View File

@@ -0,0 +1,138 @@
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
/**
* Identity of one series for label resolution: which query produced it and
* which of that query's aggregations.
*/
interface SeriesIdentity {
queryName: string;
/** Index into the matched query's aggregation list. */
aggregationIndex: number;
/** Fallback when the base label is empty and the aggregation is bare. */
fallbackName?: string;
}
/** Resolves the display label for one flattened V5 series. */
export function resolveSeriesLabelV5(
series: PanelSeries,
builderQueries: BuilderQuery[],
baseLabel: string,
): string {
return resolveLabel(
{
queryName: series.queryName,
aggregationIndex: series.aggregation.index,
fallbackName: series.queryName,
},
builderQueries,
baseLabel,
);
}
/**
* Applies the V1 legend matrix: `single-vs-many builder queries ×
* with/without groupBy × single-vs-many aggregations`. Returns `baseLabel`
* unchanged for panels without builder queries (PromQL, ClickHouseSQL) and
* for builder series whose aggregation carries no alias/expression — metric
* aggregations don't have those fields, so they naturally short-circuit to
* the base label here.
*/
function resolveLabel(
identity: SeriesIdentity,
builderQueries: BuilderQuery[],
baseLabel: string,
): string {
if (builderQueries.length === 0) {
return baseLabel;
}
const matching = builderQueries.find((q) => q.name === identity.queryName);
if (!matching) {
return baseLabel;
}
const aggIndex = identity.aggregationIndex;
const aggregations = matching.aggregations ?? [];
const aggregation = aggregations[aggIndex];
// `alias` / `expression` exist on Log/Trace aggregations only —
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
// `in` guards narrow the union without a cast.
const aggregationAlias =
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
const aggregationExpression =
aggregation && 'expression' in aggregation
? (aggregation.expression ?? '')
: '';
if (!aggregationAlias && !aggregationExpression) {
return baseLabel || identity.fallbackName || matching.name || '';
}
const ctx: FormatContext = {
aggregationAlias,
aggregationExpression,
baseLabel,
legend: matching.legend ?? '',
hasGroupBy: (matching.groupBy?.length ?? 0) > 0,
singleAggregation: aggregations.length === 1,
};
return builderQueries.length === 1
? formatForSinglePanelQuery(ctx)
: formatForMultiplePanelQueries(ctx);
}
interface FormatContext {
aggregationAlias: string;
aggregationExpression: string;
baseLabel: string;
legend: string;
hasGroupBy: boolean;
singleAggregation: boolean;
}
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
function formatForSinglePanelQuery({
aggregationAlias,
aggregationExpression,
baseLabel,
legend,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || legend || aggregationExpression;
}
return aggregationAlias || aggregationExpression;
}
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
// Differs from the single-query path in two cells: the no-groupBy / single-agg
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
// multi-agg cell prepends the base label.
function formatForMultiplePanelQueries({
aggregationAlias,
aggregationExpression,
baseLabel,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || baseLabel || aggregationExpression;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}

View File

@@ -3,8 +3,8 @@
flex-direction: column;
height: 100%;
width: 100%;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-border);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
}
@@ -14,7 +14,7 @@
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l2-border);
cursor: grab;
}
@@ -36,6 +36,15 @@
margin-inline-end: 0;
}
// Actions sit inside the drag-handle row but opt out of dragging
// (`panel-no-drag`); reset the grab cursor so the menu reads as clickable.
.actions {
display: flex;
align-items: center;
gap: 4px;
cursor: default;
}
.body {
flex: 1;
display: flex;
@@ -50,3 +59,41 @@
.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.
.chartBody {
flex: 1;
display: flex;
min-height: 0;
min-width: 0;
}
// Subtle background-refetch spinner in the header (chart stays mounted).
.refetchIndicator {
color: var(--l2-foreground);
flex-shrink: 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;
}

View File

@@ -1,38 +1,41 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody';
import PanelHeader from './PanelHeader';
import styles from './Panel.module.scss';
/** Panel action context — present together only in editable sectioned mode. */
/**
* 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).
*/
export interface PanelActionsConfig {
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
interface PanelProps {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
/** True once this panel's section enters the viewport — gates the fetch. */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* 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`.
*/
function Panel({
panel,
panelId,
@@ -41,9 +44,22 @@ function Panel({
}: PanelProps): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const fullKind = panel?.spec?.plugin?.kind;
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const panelDef = getPanelDefinition(fullKind);
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDef && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const headerTitle = useMemo(() => {
if (!description) {
return name;
@@ -60,35 +76,28 @@ function Panel({
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
<PanelHeader
title={headerTitle}
panelId={panelId}
panelKind={fullKind}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
panelActions={panelActions}
/>
<PanelBody
panelDef={panelDef}
panel={panel}
panelId={panelId}
kind={kind}
queryCount={queryCount}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
/>
</div>
);
}

View File

@@ -1,81 +1,37 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { EllipsisVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import type { PanelActionsConfig } from '../Panel';
import { usePanelActionItems } from './usePanelActionItems';
import styles from './PanelActionsMenu.module.scss';
interface PanelActionsMenuProps {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); undefined when absent. */
panelKind: string | undefined;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* Purely presentational: the trigger button + dropdown. Which items appear —
* and whether the menu renders at all — is owned by `usePanelActionItems`
* (kind ∧ role ∧ context gating per action).
*/
function PanelActionsMenu({
panelId,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: PanelActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
panelKind,
panelActions,
}: PanelActionsMenuProps): JSX.Element | null {
const items = usePanelActionItems({ panelId, panelKind, panelActions });
if (onMovePanel) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
disabled: true,
});
} else {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
onMovePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
});
}
}
if (onDeletePanel) {
if (result.length > 0) {
result.push({ type: 'divider' });
}
result.push({
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
});
}
return result;
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
if (items.length === 0) {
return null;
}
return (
<DropdownMenuSimple menu={{ items }}>
<DropdownMenuSimple menu={{ items }} align="end">
<Button
type="button"
variant="ghost"

View File

@@ -0,0 +1,193 @@
import { renderHook } from '@testing-library/react';
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
const mockOpenEditor = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
() => ({
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
}),
);
const mockMovePanel = jest.fn();
jest.mock('../../hooks/useMovePanelToSection', () => ({
useMovePanelToSection: (): jest.Mock => mockMovePanel,
}));
const mockDeletePanel = jest.fn();
jest.mock('../../hooks/useDeletePanel', () => ({
useDeletePanel: (): jest.Mock => mockDeletePanel,
}));
// Role is the only thing read off the app context; useComponentPermission runs
// for real so the tests exercise the actual role → permission mapping.
let mockRole: ROLES = 'ADMIN';
jest.mock('providers/App/App', () => ({
useAppContext: (): { user: { role: ROLES } } => ({
user: { role: mockRole },
}),
}));
function section(
layoutIndex: number,
title: string | undefined,
): DashboardSection {
return {
id: `section-${layoutIndex}`,
layoutIndex,
title,
items: [],
repeatVariable: undefined,
};
}
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
const baseArgs = {
panelId: 'panel-1',
panelKind: 'signoz/TimeSeriesPanel',
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
};
function itemKeys(items: ReturnType<typeof usePanelActionItems>): unknown[] {
return items.map((item) =>
'key' in item && item.key !== undefined ? item.key : item.type,
);
}
describe('usePanelActionItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRole = 'ADMIN';
useDashboardStore.setState({ isEditable: true });
});
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
// download stays hidden: no current kind declares the capability
// (V1 parity — CSV export was table-only).
});
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
mockRole = 'AUTHOR';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
});
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
mockRole = 'VIEWER';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
]);
});
it('unknown panel kind hides all kind-gated actions but keeps the chrome ones (clone/move/delete)', () => {
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelKind: 'signoz/TablePanel' }),
);
expect(itemKeys(result.current)).toStrictEqual([
'clone-panel',
'divider',
'move',
'divider',
'delete-panel',
]);
});
it('read-only dashboard keeps only View (V1 parity)', () => {
useDashboardStore.setState({ isEditable: false });
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
});
it('move is disabled when there is no other titled section to move to', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: {
currentLayoutIndex: 0,
sections: [section(0, 'Overview'), section(1, undefined)],
},
}),
);
const move = result.current.find((i) => 'key' in i && i.key === 'move');
expect(move).toMatchObject({ disabled: true });
});
it('edit opens the panel editor for this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const edit = result.current.find((i) => 'key' in i && i.key === 'edit-panel');
(edit as { onClick: () => void }).onClick();
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
});
it('move targets call the mutation with from/to layout indexes', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const move = result.current.find((i) => 'key' in i && i.key === 'move') as {
children: { key: string; onClick: () => void }[];
};
expect(move.children).toHaveLength(1);
move.children[0].onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 0,
toLayoutIndex: 1,
});
});
it('delete calls the mutation with the panel and its layout index', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const del = result.current.find(
(i) => 'key' in i && i.key === 'delete-panel',
);
(del as { onClick: () => void }).onClick();
expect(mockDeletePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 0,
});
});
it('not-yet-implemented actions (view/clone/create-alert) fire the placeholder alert with the feature name', () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
['view-panel', 'clone-panel', 'create-alert'].forEach((key) => {
const item = result.current.find((i) => 'key' in i && i.key === key);
(item as { onClick: () => void }).onClick();
});
expect(alertSpy).toHaveBeenCalledTimes(3);
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
expect(alertSpy).toHaveBeenCalledWith('Clone option clicked');
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
alertSpy.mockRestore();
});
});

View File

@@ -0,0 +1,48 @@
import type { PanelActionCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { ComponentTypes } from 'utils/permission';
/**
* Every action the panel menu can offer. `Record<PanelActionId, …>` below
* forces a meta entry per id — adding an action without declaring its gates is
* a compile error.
*/
export type PanelActionId =
| 'view'
| 'edit'
| 'clone'
| 'download'
| 'createAlert'
| 'move'
| 'delete';
export interface PanelActionMeta {
/**
* Role gate: componentPermission key checked against the current user.
* Absent = available to every role (V1 parity: view, download and
* create-alerts were never role-gated).
*/
permission?: ComponentTypes;
/**
* Kind gate: the PanelActionCapabilities flag this action requires.
* Chrome actions (move/clone/delete) are layout concerns available for
* every panel kind — including kinds V2 can't render — so they declare none.
*/
capability?: keyof PanelActionCapabilities;
}
/**
* Single source of truth for how each panel action is gated, mirroring V1's
* WidgetHeader rules. The third gate — context (dashboard editable, target
* sections available) — is runtime state resolved in `usePanelActionItems`,
* not declarable here.
*/
export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
view: { capability: 'view' },
edit: { permission: 'edit_widget', capability: 'edit' },
clone: { permission: 'edit_widget' },
download: { capability: 'download' },
createAlert: { capability: 'createAlert' },
// Moving a panel between sections mutates the dashboard layout.
move: { permission: 'edit_dashboard' },
delete: { permission: 'delete_widget' },
};

View File

@@ -0,0 +1,195 @@
import { useMemo } from 'react';
import {
Bell,
CloudDownload,
Copy,
FolderInput,
Fullscreen,
PenLine,
Trash2,
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import useComponentPermission from 'hooks/useComponentPermission';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { useAppContext } from 'providers/App/App';
import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
const EMPTY_SECTIONS: DashboardSection[] = [];
// Placeholder for the V1-parity actions whose V2 implementations land in
// later milestones (view, clone, download, create-alerts).
function notImplementedYet(feature: string): void {
// eslint-disable-next-line no-alert -- temporary placeholder, see above
alert(`${feature} option clicked`);
}
interface UsePanelActionItemsArgs {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); undefined when absent. */
panelKind: string | undefined;
/** Layout context for move/delete — absent outside editable mode. */
panelActions?: PanelActionsConfig;
}
/**
* Resolves the panel actions menu items (the V1 WidgetHeader action set plus
* V2's "Move to section"). Every action passes three gates before it appears:
*
* kind — what the panel kind declares it supports (PanelDefinition.actions);
* unknown kinds support no kind-gated actions.
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
* actions without a permission key are open to every role, V1 parity).
* context — runtime state: dashboard editable (store), layout config present.
* View and Download remain available on read-only dashboards, as in V1.
*
* Items are composed as groups with dividers inserted between non-empty
* groups, so adding an action never touches divider bookkeeping.
*/
export function usePanelActionItems({
panelId,
panelKind,
panelActions,
}: UsePanelActionItemsArgs): MenuItem[] {
const { user } = useAppContext();
const [canEditWidget, canMove, canDelete] = useComponentPermission(
[
// edit_widget gates both Edit and Clone, exactly as in V1.
PANEL_ACTION_META.edit.permission ?? 'edit_widget',
PANEL_ACTION_META.move.permission ?? 'edit_dashboard',
PANEL_ACTION_META.delete.permission ?? 'delete_widget',
],
user.role,
);
// Folds in the dashboard lock + edit_dashboard permission (set once by
// DashboardContainer). Mutating actions respect it; view/download don't.
const isEditable = useDashboardStore((s) => s.isEditable);
const openPanelEditor = useOpenPanelEditor();
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
// supplies data (`sections`), so no callbacks are threaded through it.
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
const movePanel = useMovePanelToSection({ sections });
const deletePanel = useDeletePanel({ sections });
const kindActions = getPanelDefinition(panelKind)?.actions;
return useMemo<MenuItem[]>(() => {
// Group 1 — open/author the panel: View, Edit, Clone.
const panelGroup: MenuItem[] = [];
if (kindActions?.view) {
panelGroup.push({
key: 'view-panel',
label: 'View',
icon: <Fullscreen size={14} />,
onClick: (): void => notImplementedYet('View'),
});
}
if (isEditable && canEditWidget && kindActions?.edit) {
panelGroup.push({
key: 'edit-panel',
label: 'Edit panel',
icon: <PenLine size={14} />,
onClick: (): void => openPanelEditor(panelId),
});
}
if (isEditable && canEditWidget) {
panelGroup.push({
key: 'clone-panel',
label: 'Clone',
icon: <Copy size={14} />,
onClick: (): void => notImplementedYet('Clone'),
});
}
// Group 2 — derive from the panel's data: Download, Create Alerts.
const dataGroup: MenuItem[] = [];
if (kindActions?.download) {
dataGroup.push({
key: 'download-panel',
label: 'Download as CSV',
icon: <CloudDownload size={14} />,
onClick: (): void => notImplementedYet('Download'),
});
}
if (isEditable && kindActions?.createAlert) {
dataGroup.push({
key: 'create-alert',
label: 'Create Alerts',
icon: <Bell size={14} />,
onClick: (): void => notImplementedYet('Create Alerts'),
});
}
// Group 3 — layout: Move to section.
const moveGroup: MenuItem[] = [];
if (canMove && panelActions) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
);
moveGroup.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: panelActions.currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
});
}
// Group 4 — danger: Delete.
const deleteGroup: MenuItem[] =
canDelete && panelActions
? [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
void deletePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
}),
},
]
: [];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
.flatMap((group, index) =>
index === 0 ? group : [{ type: 'divider' as const }, ...group],
);
}, [
isEditable,
canEditWidget,
canMove,
canDelete,
kindActions,
panelActions,
sections,
panelId,
openPanelEditor,
movePanel,
deletePanel,
]);
}

View File

@@ -0,0 +1,112 @@
import { Spin } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Loader, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import styles from './Panel.module.scss';
interface PanelBodyProps {
/** Resolved renderer for the panel kind; undefined when the kind is unknown. */
panelDef: RenderablePanelDefinition | undefined;
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
kind: string;
queryCount: number;
data: PanelQueryData;
isLoading: boolean;
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
dashboardPreference: DashboardPreference;
}
/**
* Renders the panel content as an explicit state machine so each state is
* handled deliberately (no implicit fall-through):
*
* unknown-kind → unsupported fallback
* error + no data → error message with retry
* first load (no data) → loading indicator
* otherwise → the kind's renderer (which owns its own "No Data" state, and
* keeps stale data mounted during background refetches)
*/
function PanelBody({
panelDef,
panel,
panelId,
kind,
queryCount,
data,
isLoading,
error,
refetch,
onDragSelect,
dashboardPreference,
}: PanelBodyProps): JSX.Element {
if (!panelDef) {
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>
);
}
// Surface a hard failure only when there's no (stale) data to show; otherwise
// keep the last-good chart and let the header indicate the refresh.
// react-query keeps the previous response during background refetches, so
// `data.response` presence is the "have something to show" signal.
const hasData = !!data.response;
if (error && !hasData) {
return (
<div className={styles.error} data-testid="panel-error">
<TriangleAlert size={20} className={styles.errorIcon} />
<Typography.Text className={styles.errorMessage}>
{error.message || 'Failed to load panel data'}
</Typography.Text>
<Button variant="outlined" color="secondary" onClick={refetch}>
Retry
</Button>
</div>
);
}
// First load only — background refetches keep the response populated so the
// chart stays mounted instead of blinking.
if (isLoading && !hasData) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
);
}
return (
<div className={styles.chartBody}>
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_VIEW}
enableDrillDown={false}
dashboardPreference={dashboardPreference}
/>
</div>
);
}
export default PanelBody;

View File

@@ -0,0 +1,81 @@
import { useMemo, type ReactNode } from 'react';
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 { PanelActionsConfig } from './Panel';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import PanelStatusPopover from './PanelStatus/PanelStatusPopover';
import {
panelStatusFromError,
panelStatusFromWarning,
} from './PanelStatus/utils';
import styles from './Panel.module.scss';
interface PanelHeaderProps {
title: ReactNode;
panelId: string;
/** Full plugin kind — drives kind-gated menu actions; undefined when absent. */
panelKind: string | undefined;
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
isFetching: boolean;
/** Latest query error, if any — surfaced as a header error indicator. */
error?: Error | null;
/** Non-fatal query warning lifted from the response payload. */
warning?: WarningDTO;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
title,
panelId,
panelKind,
isFetching,
error,
warning,
panelActions,
}: PanelHeaderProps): JSX.Element {
const errorDetail = useMemo(
() => panelStatusFromError(error ?? null),
[error],
);
const warningDetail = useMemo(
() => panelStatusFromWarning(warning),
[warning],
);
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
{isFetching && (
<Loader
size={12}
className={cx('animate-spin', styles.refetchIndicator)}
data-testid="panel-refetching"
/>
)}
</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. */}
<div className={cx('panel-no-drag', styles.actions)}>
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
{warningDetail && (
<PanelStatusPopover variant="warning" detail={warningDetail} />
)}
{/* Renders nothing when no action survives its gates (kind/role/context). */}
<PanelActionsMenu
panelId={panelId}
panelKind={panelKind}
panelActions={panelActions}
/>
</div>
</div>
);
}
export default PanelHeader;

View File

@@ -0,0 +1,51 @@
import { BookOpenText } from '@signozhq/icons';
import type { PanelStatusDetail } from './types';
import styles from './PanelStatusPopover.module.scss';
interface PanelStatusContentProps {
detail: PanelStatusDetail;
}
/**
* Popover body for a panel status (error or warning): a code + summary header
* with an optional docs link, followed by any per-item messages. Pure
* presentation — the variant's icon/colour is owned by `PanelStatusPopover`.
*/
function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
const { code, message, docsUrl, messages } = detail;
return (
<section className={styles.content} data-testid="panel-status-content">
<header className={styles.summary}>
<div className={styles.summaryText}>
{code && <h2 className={styles.code}>{code}</h2>}
<p className={styles.message}>{message}</p>
</div>
{docsUrl && (
<a
className={styles.docsLink}
href={docsUrl}
target="_blank"
rel="noreferrer"
data-testid="panel-status-docs"
>
<BookOpenText size={14} />
Open Docs
</a>
)}
</header>
{messages.length > 0 && (
<ul className={styles.messageList}>
{messages.map((m) => (
<li key={m} className={styles.messageItem}>
{m}
</li>
))}
</ul>
)}
</section>
);
}
export default PanelStatusContent;

View File

@@ -0,0 +1,64 @@
.trigger {
display: inline-flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
}
.content {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 600px;
padding: 12px;
}
.summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.summaryText {
min-width: 0;
}
.code {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--l2-foreground);
}
.message {
margin: 4px 0 0;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-word;
}
.docsLink {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-size: 12px;
white-space: nowrap;
}
.messageList {
margin: 0;
padding-left: 16px;
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.messageItem {
font-size: 12px;
color: var(--l1-foreground);
word-break: break-word;
}

View File

@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { CircleX, TriangleAlert } from '@signozhq/icons';
import { Popover } from 'antd';
import PanelStatusContent from './PanelStatusContent';
import type { PanelStatusDetail, PanelStatusVariant } from './types';
import styles from './PanelStatusPopover.module.scss';
const VARIANT_CONFIG: Record<
PanelStatusVariant,
{ color: string; ariaLabel: string }
> = {
error: { color: Color.BG_CHERRY_500, ariaLabel: 'Panel error' },
warning: { color: Color.BG_AMBER_500, ariaLabel: 'Panel warning' },
};
interface PanelStatusPopoverProps {
variant: PanelStatusVariant;
detail: PanelStatusDetail;
}
/**
* Header status indicator: a variant-coloured icon (error → CircleX,
* warning → TriangleAlert) that opens a popover with the status detail. One
* component drives both variants so error and warning surfacing stay in lockstep.
*/
function PanelStatusPopover({
variant,
detail,
}: PanelStatusPopoverProps): JSX.Element {
const { color, ariaLabel } = VARIANT_CONFIG[variant];
const Icon = variant === 'error' ? CircleX : TriangleAlert;
const content = useMemo(
() => <PanelStatusContent detail={detail} />,
[detail],
);
return (
<Popover
content={content}
overlayInnerStyle={{ padding: 0 }}
autoAdjustOverflow
>
{/* Wrapping span gives antd a ref-able, hoverable trigger (icon
components don't forward refs) and a stable testid anchor. */}
<span
className={styles.trigger}
aria-label={ariaLabel}
data-testid={`panel-status-${variant}`}
>
<Icon size={16} color={color} />
</span>
</Popover>
);
}
export default PanelStatusPopover;

View File

@@ -0,0 +1,67 @@
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import APIError from 'types/api/error';
import { StatusCodes } from 'http-status-codes';
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
describe('panelStatusFromError', () => {
it('returns null when there is no error', () => {
expect(panelStatusFromError(null)).toBeNull();
});
it('maps a structured APIError to code/message/docs/sub-messages', () => {
const apiError = new APIError({
httpStatusCode: StatusCodes.BAD_REQUEST,
error: {
code: 'invalid_query',
message: 'Query is invalid',
url: 'https://docs/err',
errors: [{ message: 'missing aggregation' }, { message: 'bad filter' }],
},
});
expect(panelStatusFromError(apiError)).toStrictEqual({
code: 'invalid_query',
message: 'Query is invalid',
docsUrl: 'https://docs/err',
messages: ['missing aggregation', 'bad filter'],
});
});
it('falls back to a generic 500 for a plain Error', () => {
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
code: '500',
message: 'boom',
messages: [],
});
});
it('omits docsUrl when the API error has no url', () => {
const apiError = new APIError({
httpStatusCode: StatusCodes.INTERNAL_SERVER_ERROR,
error: { code: 'x', message: 'y', url: '', errors: [] },
});
expect(panelStatusFromError(apiError)?.docsUrl).toBeUndefined();
});
});
describe('panelStatusFromWarning', () => {
it('returns null when there is no warning', () => {
expect(panelStatusFromWarning(undefined)).toBeNull();
});
it('maps a warning to the normalized status shape (no code — V5 warnings carry none)', () => {
const warning: WarningDTO = {
message: 'Some series were dropped',
url: 'https://docs/warn',
warnings: [{ message: 'series A truncated' }],
};
expect(panelStatusFromWarning(warning)).toStrictEqual({
message: 'Some series were dropped',
docsUrl: 'https://docs/warn',
messages: ['series A truncated'],
});
});
});

View File

@@ -0,0 +1,19 @@
/** Which kind of non-fatal panel status is being surfaced in the header. */
export type PanelStatusVariant = 'error' | 'warning';
/**
* Normalized status shape that both an API error and a query warning adapt into,
* so a single popover can render either. Mirrors the fields the backend supplies
* on its `ErrorV2` / `Warning` envelopes (code + summary + optional docs link +
* per-item messages).
*/
export interface PanelStatusDetail {
/** Short status code (e.g. an error/warning code) shown as the heading. Only present in error cases. */
code?: string;
/** Human-readable summary line. */
message: string;
/** Optional docs link; renders an "Open Docs" action when present. */
docsUrl?: string;
/** Additional per-item messages listed under the summary. */
messages: string[];
}

View File

@@ -0,0 +1,66 @@
import type APIError from 'types/api/error';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelStatusDetail } from './types';
const FALLBACK_CODE = '500';
const FALLBACK_MESSAGE = 'Something went wrong';
/**
* Narrows a thrown `Error` to our `APIError` (which carries the structured
* `error.error` envelope). react-query types failures as `Error`, so a runtime
* guard is the typed way to recover the richer shape.
*/
function isAPIError(error: Error): error is APIError {
return (
'error' in error &&
typeof (error as APIError).error === 'object' &&
(error as APIError).error !== null
);
}
/**
* Adapts a query failure into the normalized status shape. Structured
* `APIError`s yield their backend code/message/docs/sub-messages; any other
* `Error` falls back to a generic 500 with its message.
*/
export function panelStatusFromError(
error: Error | null,
): PanelStatusDetail | null {
if (!error) {
return null;
}
if (isAPIError(error)) {
const detail = error.error.error;
return {
code: detail?.code || FALLBACK_CODE,
message: detail?.message || FALLBACK_MESSAGE,
docsUrl: detail?.url || undefined,
messages: (detail?.errors ?? []).map((e) => e.message),
};
}
return {
code: FALLBACK_CODE,
message: error.message || FALLBACK_MESSAGE,
messages: [],
};
}
/** Adapts a query warning into the normalized status shape. */
export function panelStatusFromWarning(
warning: WarningDTO | undefined,
): PanelStatusDetail | null {
if (!warning) {
return null;
}
return {
message: warning.message || 'Warning',
docsUrl: warning.url || undefined,
messages: (warning.warnings ?? [])
.map((w) => w.message)
.filter((message): message is string => Boolean(message)),
};
}

View File

@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import type { Warning } from 'types/api';
import PanelHeader from '../PanelHeader';
// 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.
jest.mock(
'../PanelActionsMenu/PanelActionsMenu',
() =>
function MockPanelActionsMenu(): null {
return null;
},
);
const baseProps = {
title: 'My panel',
panelKind: 'signoz/TimeSeriesPanel',
panelId: 'panel-1',
isFetching: false,
};
const warning: Warning = {
code: 'partial_data',
message: 'Some series were dropped',
url: '',
warnings: [],
};
describe('PanelHeader status indicators', () => {
it('shows the error indicator whenever an error is present', () => {
render(<PanelHeader {...baseProps} error={new Error('boom')} />);
expect(screen.getByTestId('panel-status-error')).toBeInTheDocument();
});
it('shows the warning indicator whenever a warning is present', () => {
render(<PanelHeader {...baseProps} warning={warning} />);
expect(screen.getByTestId('panel-status-warning')).toBeInTheDocument();
});
it('renders no status indicators when there is no error or warning', () => {
render(<PanelHeader {...baseProps} />);
expect(screen.queryByTestId('panel-status-error')).not.toBeInTheDocument();
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { useCallback, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
import { useDispatch } from 'react-redux';
import { useLocation, useParams } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
export interface PanelInteractions {
/**
* Drag-select a time range on a chart → write the window to the URL + global
* time so every panel re-fetches against the same range.
*/
onDragSelect: (start: number, end: number) => void;
/**
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
* off the dashboard id from the route.
*/
dashboardPreference: DashboardPreference;
}
/**
* Encapsulates the cross-panel interactions shared by every dashboard-view
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
* preferences. Keeping this out of the `Panel` component keeps the component a
* thin render orchestrator and lets the wiring be unit-tested in isolation.
*/
export function usePanelInteractions(): PanelInteractions {
const dispatch = useDispatch();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const { dashboardId } = useParams<{ dashboardId: string }>();
const [syncMode] = useDashboardCursorSyncMode(
dashboardId,
PanelMode.DASHBOARD_VIEW,
);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
const dashboardPreference = useMemo<DashboardPreference>(
() => ({ syncMode, syncFilterMode, dashboardId }),
[syncMode, syncFilterMode, dashboardId],
);
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
safeNavigate(`${pathname}?${urlQuery.toString()}`);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, safeNavigate, urlQuery],
);
return { onDragSelect, dashboardPreference };
}

View File

@@ -8,8 +8,6 @@ import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/pan
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
@@ -26,10 +24,8 @@ interface SectionProps {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
}
@@ -38,8 +34,6 @@ function Section({
section,
onAddPanel,
sections,
onMovePanel,
onDeletePanel,
dragHandle,
}: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
@@ -92,8 +86,6 @@ function Section({
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
);

View File

@@ -9,4 +9,11 @@
z-index: 2;
user-select: none;
}
:global(.react-resizable-handle) {
&::after {
border-right-color: var(--l2-border) !important;
border-bottom-color: var(--l2-border) !important;
}
}
}

View File

@@ -2,8 +2,6 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import Panel from '../../Panel/Panel';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
@@ -16,10 +14,8 @@ interface SectionGridProps {
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function SectionGrid({
@@ -27,8 +23,6 @@ function SectionGrid({
layoutIndex,
isVisible,
sections,
onMovePanel,
onDeletePanel,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
@@ -54,6 +48,7 @@ function SectionGrid({
useCSSTransforms
layout={rglLayout}
draggableHandle=".panel-drag-handle"
draggableCancel=".panel-no-drag"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}
@@ -67,12 +62,10 @@ function SectionGrid({
panelId={item.id}
isVisible={isVisible}
panelActions={
isEditable && onMovePanel && onDeletePanel
isEditable
? {
currentLayoutIndex: layoutIndex,
sections: sections ?? [],
onMovePanel,
onDeletePanel,
}
: undefined
}

View File

@@ -12,8 +12,6 @@ import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.sche
import type { DashboardSection } from '../../utils';
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import Section from './Section/Section';
@@ -38,8 +36,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
} = useSectionDragReorder({ sections, layouts });
const onAddPanel = useAddPanelToSection({ sections });
const onMovePanel = useMovePanelToSection({ sections });
const onDeletePanel = useDeletePanel({ sections });
// Only titled sections participate in reordering; untitled (free-flow)
// blocks render in place without a drag handle.
@@ -75,8 +71,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<Section
@@ -84,8 +78,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
),
)}

View File

@@ -3,24 +3,18 @@ import { CSS } from '@dnd-kit/utilities';
import type { DashboardSection } from '../../utils';
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
import Section from './Section/Section';
interface SortableSectionProps {
section: DashboardSection;
sections: DashboardSection[];
onAddPanel: (args: AddPanelArgs) => void;
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
function SortableSection({
section,
sections,
onAddPanel,
onMovePanel,
onDeletePanel,
}: SortableSectionProps): JSX.Element {
const {
attributes,
@@ -48,8 +42,6 @@ function SortableSection({
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
</div>

View File

@@ -48,8 +48,11 @@ function PanelsAndSectionsLayout({
return <SectionList sections={sections} layouts={layouts} />;
}
// Free-flow (no titled sections): panels still get the layout context so
// the menu's delete action can patch the section's items (previously a
// silent noop in this mode).
return sections.map((section) => (
<Section key={section.id} section={section} />
<Section key={section.id} section={section} sections={sections} />
));
};

View File

@@ -0,0 +1,289 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelQuery } from '../usePanelQuery';
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('../useGetQueryRangeV5', () => ({
useGetQueryRangeV5: jest.fn(),
}));
const mockUseSelector = useSelector as unknown as jest.Mock;
const mockUseGetQueryRangeV5 = useGetQueryRangeV5 as unknown as jest.Mock;
// ---- helpers ---------------------------------------------------------------
// Test fixtures are cast at the outer boundary; the perses-generated panel and
// query plugin unions are too verbose to construct field-typed inline.
function panelWith(
panelKind: string,
querySpec: Record<string, unknown>,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: panelKind, spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: querySpec },
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function builderPanel(): DashboardtypesPanelDTO {
return panelWith('signoz/TimeSeriesPanel', {
name: 'A',
signal: 'logs',
filter: { expression: '' },
});
}
function emptyPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
// Nanoseconds, as redux globalTime stores them. 1e15 ns = 1e9 ms.
const DEFAULT_GLOBAL_TIME = {
selectedTime: 'GLOBAL_TIME',
minTime: 1_000_000_000_000_000,
maxTime: 2_000_000_000_000_000,
isAutoRefreshDisabled: false,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseSelector.mockImplementation((selector: unknown) => {
// usePanelQuery passes a selector `(state) => state.globalTime`.
return (
selector as (state: { globalTime: typeof DEFAULT_GLOBAL_TIME }) => unknown
)({ globalTime: DEFAULT_GLOBAL_TIME });
});
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
});
});
// ---- tests -----------------------------------------------------------------
describe('usePanelQuery', () => {
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.schemaVersion).toBe('v1');
expect(requestPayload.compositeQuery.queries).toStrictEqual([
{
type: 'builder_query',
spec: { name: 'A', signal: 'logs', filter: { expression: '' } },
},
]);
});
it('converts redux nanosecond time to epoch ms on the request', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.start).toBe(1_000_000_000);
expect(requestPayload.end).toBe(2_000_000_000);
});
it.each([
['signoz/TimeSeriesPanel', 'time_series'],
['signoz/ListPanel', 'raw'],
// HISTOGRAM and BAR panels bin/derive from raw time-series data
// client-side, so the backend must receive `time_series` (V1 parity).
['signoz/HistogramPanel', 'time_series'],
['signoz/BarChartPanel', 'time_series'],
['signoz/NumberPanel', 'scalar'],
['signoz/PieChartPanel', 'scalar'],
])('%s panel sends requestType=%s', (panelKind, requestType) => {
renderHook(() =>
usePanelQuery({
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
panelId: 'p1',
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.requestType).toBe(requestType);
});
it('exposes the raw V5 response, request payload, and legend map on data', () => {
const v5Response = { status: 'success', data: { type: 'time_series' } };
mockUseGetQueryRangeV5.mockReturnValue({
data: v5Response,
isLoading: false,
isFetching: false,
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data.response).toBe(v5Response);
expect(result.current.data.legendMap).toStrictEqual({ A: '' });
expect(result.current.data.requestPayload?.schemaVersion).toBe('v1');
});
it('exposes an undefined response before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data.response).toBeUndefined();
});
it('exposes error from the fetch hook', () => {
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error?.message).toBe('boom');
});
it('combines isLoading and isFetching into a single isLoading flag', () => {
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: true,
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(true);
});
it('coerces a missing/undefined error to null', () => {
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to the fetch hook when the caller disables it', () => {
renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
});
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
});
it('auto-disables the fetch when every metrics query is missing a metric name', () => {
renderHook(() =>
usePanelQuery({
panel: panelWith('signoz/TimeSeriesPanel', {
name: 'A',
signal: 'metrics',
aggregations: [{}],
}),
panelId: 'p1',
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
});
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
const panel = builderPanel();
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(queryKey).toStrictEqual(
expect.arrayContaining([
'p1',
DEFAULT_GLOBAL_TIME.minTime,
DEFAULT_GLOBAL_TIME.maxTime,
DEFAULT_GLOBAL_TIME.selectedTime,
'signoz/TimeSeriesPanel',
panel.spec?.queries,
]),
);
});
it('uses the time override (not redux) for the request window and cache key', () => {
const panel = builderPanel();
renderHook(() =>
usePanelQuery({
panel,
panelId: 'p1',
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
}),
);
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
// Window comes from the override, not the redux nanosecond time.
expect(requestPayload.start).toBe(1_700_000_000_000);
expect(requestPayload.end).toBe(1_700_000_600_000);
// Cache key keys off the override so the preview refetches independently
// of the dashboard and never collides with its redux-keyed entry.
expect(queryKey).toStrictEqual(
expect.arrayContaining([
'p1',
'override-1700000000000-1700000600000',
'signoz/TimeSeriesPanel',
panel.spec?.queries,
]),
);
expect(queryKey).not.toContain(DEFAULT_GLOBAL_TIME.minTime);
});
it('floors fractional override ms — V1 time helpers emit floats but start/end are int64', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
}),
);
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.start).toBe(1_700_000_000_000);
expect(requestPayload.end).toBe(1_700_000_600_000);
// The cache key carries the floored values so it matches the request.
expect(queryKey).toStrictEqual(
expect.arrayContaining(['override-1700000000000-1700000600000']),
);
});
it('builds an empty composite and disables the fetch when panel is undefined (no crash)', () => {
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
const [{ requestPayload, enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.compositeQuery.queries).toStrictEqual([]);
expect(enabled).toBe(false);
});
});

View File

@@ -0,0 +1,49 @@
import { useQuery, UseQueryResult } from 'react-query';
import { isAxiosError } from 'axios';
import { queryRangeV5 } from 'api/generated/services/querier';
import type {
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
export interface UseGetQueryRangeV5Args {
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined;
queryKey: unknown[];
enabled: boolean;
}
// 4xx responses are deterministic (bad query, auth) — retrying re-sends a
// request that will fail identically. Same policy as V1's useGetQueryRange.
function retryUnlessClientError(failureCount: number, error: Error): boolean {
if (isAxiosError(error)) {
if (error.code === 'ERR_CANCELED') {
return false;
}
const status = error.response?.status;
if (status && status >= 400 && status < 500) {
return false;
}
}
return failureCount < MAX_QUERY_RETRIES;
}
/**
* Pure-V5 query-range fetch: posts the generated request DTO via the
* generated `queryRangeV5` call and returns the raw generated response —
* no V1 `Query` shape on either leg. Wrapped in `useQuery` (not the
* generated `useQueryRangeV5` mutation hook) because panel fetches need
* caching, `enabled` gating, and refetch semantics.
*/
export function useGetQueryRangeV5({
requestPayload,
queryKey,
enabled,
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
return useQuery<QueryRangeV5200, Error>({
queryKey,
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
enabled: enabled && !!requestPayload,
retry: retryUnlessClientError,
});
}

View File

@@ -0,0 +1,27 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
/**
* Returns a callback that opens the V2 panel editor overlay for a panel by
* setting the `editPanelId` query param on the current dashboard URL.
* DashboardContainer reads the param and renders the editor — the dashboard
* stays mounted underneath. URL-driven so any entry point (panel actions menu,
* future title interactions, empty states) can open the editor without
* threading callbacks through the layout tree.
*/
export function useOpenPanelEditor(): (panelId: string) => void {
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
return useCallback(
(panelId: string): void => {
urlQuery.set(QueryParams.editPanelId, panelId);
safeNavigate(`${pathname}?${urlQuery.toString()}`);
},
[urlQuery, safeNavigate, pathname],
);
}

View File

@@ -0,0 +1,161 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
buildQueryRangeRequest,
extractLegendMap,
hasRunnableQueries,
} from '../queryV5/buildQueryRangeRequest';
import type { PanelQueryData } from '../queryV5/types';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../Panels/types/panelKind';
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Gate the underlying fetch. Defaults to true. PanelV2 sets this false when
* no plugin is registered for the panel's kind so the unknown-kind fallback
* UI doesn't trigger a wasted API call.
*
* The hook *also* auto-disables internally when the panel has no runnable
* queries — callers don't need to compute that themselves.
*/
enabled?: boolean;
/**
* Override the time window instead of reading global Redux time. Used by the
* panel editor preview to stay isolated from the dashboard — changing the
* preview time neither touches nor re-runs the dashboard behind the overlay.
*/
time?: PanelQueryTimeOverride;
}
/**
* Editor-local time window in epoch milliseconds (the V5 request native unit).
* The caller resolves its selection — relative or custom — to an absolute
* window so the fetch can ignore global Redux time entirely. Fractional ms are
* floored before the request: the V1 time helpers some callers resolve through
* (e.g. getStartEndRangeTime → getMicroSeconds) divide without truncating, and
* the V5 start/end are int64 — a float breaks the API call.
*/
export interface PanelQueryTimeOverride {
startMs: number;
endMs: number;
}
export interface UsePanelQueryResult {
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
/** Combines `isLoading` (first fetch) and `isFetching` (background refresh). */
isLoading: boolean;
/** Background refresh in flight while data is already present. */
isFetching: boolean;
error: Error | null;
/** Re-run the query (e.g. a retry button on the error state). */
refetch: () => void;
}
/**
* Fetches the query-range data for a V2 panel over the pure-V5 contract.
*
* 1. Request — `buildQueryRangeRequest` assembles the generated
* `Querybuildertypesv5QueryRangeRequestDTO` directly from the panel's
* perses queries (a CompositeQuery plugin already nests the V5
* envelope list). No V1 `Query` intermediary.
* 2. Time + variables — reads the global time selection from Redux
* (variables substitution is intentionally deferred until V2 has its
* own variable plumbing).
* 3. Fetch — `useGetQueryRangeV5` posts via the generated `queryRangeV5`
* call with a react-query cache key composed from panel identity +
* time range + kind + queries.
*
* Renderers consume the raw V5 response through the `queryV5` prep utils
* (`flattenTimeSeries`, `prepareScalarTables`, …).
*
* The hook is consumed today by PanelV2 (renderer dispatch) and will be
* consumed by PanelEditor (1.8) for "preview as you edit."
*/
export function usePanelQuery({
panel,
panelId,
enabled = true,
time,
}: UsePanelQueryArgs): UsePanelQueryResult {
const fullKind = panel?.spec?.plugin?.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
const queries = panel?.spec?.queries;
const {
selectedTime: globalSelectedInterval,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Redux global time is in nanoseconds; the V5 API takes epoch ms. An editor
// time override (already in ms) wins so the preview fetch is independent of
// the dashboard's global selection. Floor both paths: start/end are int64
// on the wire, and override values can carry fractional ms (see
// PanelQueryTimeOverride).
const startMs = Math.floor(time ? time.startMs : minTime / 1e6);
const endMs = Math.floor(time ? time.endMs : maxTime / 1e6);
const requestPayload = useMemo(
() =>
buildQueryRangeRequest({
queries: queries ?? [],
panelType,
startMs,
endMs,
}),
[queries, panelType, startMs, endMs],
);
const legendMap = useMemo(() => extractLegendMap(queries ?? []), [queries]);
const runnable = useMemo(() => hasRunnableQueries(queries ?? []), [queries]);
const response = useGetQueryRangeV5({
requestPayload,
queryKey: [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
panelId,
// Dashboard keys off Redux min/max + interval; the editor passes an
// explicit ms window. Keep each distinct so they refetch on their own
// time without colliding in the react-query cache. The floored values
// key the cache so it matches what was actually requested.
...(time
? [`override-${startMs}-${endMs}`]
: [minTime, maxTime, globalSelectedInterval]),
fullKind,
queries,
],
enabled: enabled && runnable,
});
const data = useMemo<PanelQueryData>(
() => ({ response: response.data, requestPayload, legendMap }),
[response.data, requestPayload, legendMap],
);
return {
data,
isLoading: response.isLoading || response.isFetching,
isFetching: response.isFetching,
// Coerce undefined → null so the contract is `Error | null`, not
// `Error | null | undefined`. Consumers can rely on a single
// "no error" sentinel.
error: (response.error as Error | null) ?? null,
refetch: response.refetch,
};
}

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