Compare commits

...

5 Commits

Author SHA1 Message Date
Abhi Kumar
2b48afc4e7 chore: pr review changes 2026-07-01 12:53:22 +05:30
Abhi Kumar
c080d150d8 refactor(dashboards-v2): address review feedback on panel create-alert
- buildCreateAlertUrl: drop redundant `?? []` (spec.queries is a required array)
- buildCreateAlertUrl: extract FormattablePluginSpec type for the formatting cast
- usePanelActionItems: rename kindActions -> panelCapabilities (its type is
  PanelActionCapabilities; avoids the clash with the panelActions prop)
- useCreateAlerts (V1): mark @deprecated pointing at the V2 create-alert path
2026-07-01 12:52:54 +05:30
Abhi Kumar
edbf1b1ff7 feat(dashboards-v2): add a Create alert rule action to the panel editor
Add an "Actions" group at the foot of the editor config pane — a list of
cross-page navigation links kept distinct from the collapsible config
sections above. The first action, "Create alert", reuses
useCreateAlertFromPanel to seed an alert from the draft query; the group
hides for kinds that can't seed an alert and scales to more actions.

ConfigPane now derives the panel kind from its spec and takes the draft
panel + panelId so the group can build the link.
2026-07-01 12:52:54 +05:30
Abhi Kumar
2d8ff5a5f8 feat(dashboards-v2): create alerts from a dashboard panel
Wire the panel actions menu's "Create Alerts" item to seed a new alert
from the panel's query. `buildCreateAlertUrl` translates the panel's V5
queries into the V1 compositeQuery the alert page reads (tagged with the
panel type, v5 version and a dashboards source), and
`useCreateAlertFromPanel` opens /alerts/new in a new tab and logs the
action. Available regardless of edit access (V1 parity: create-alert
works on locked dashboards too).

To reach the query, the full panel is threaded through
Panel -> PanelHeader -> PanelActionsMenu -> usePanelActionItems instead
of just its kind; the header now derives its name/description from the
panel as well.
2026-07-01 12:52:54 +05:30
Ashwin Bhatkal
3ea62d3d50 feat(dashboard-v2): link variables to panels and substitute them into panel queries (#11909)
* feat(dashboard-v2): build V5 variables payload from selection

Add buildVariablesPayload, a pure builder mapping a dashboard's variable
definitions + runtime selection into the V5 query-range `variables` map
({ name: { type, value } }). Mirrors V1 getDashboardVariables: maps the
QUERY/CUSTOM/TEXT/DYNAMIC UI types to wire types, collapses a multi-select
dynamic ALL to the __all__ sentinel, falls back to configured defaults, and
omits empties. buildQueryRangeRequest now accepts a `variables` arg (defaults
to {}) instead of hardcoding an empty map.

* feat(dashboard-v2): add resolvedVariables store channel

Add a transient (non-persisted) resolvedVariables map to the variable-selection
slice, keyed by dashboardId, with a setResolvedVariables setter and a
selectResolvedVariables selector. This is the published-to-store channel the
panel query reads from, mirroring the edit-context publish pattern so the
dashboard spec is not threaded down the panel tree.

* feat(dashboard-v2): substitute variable selection into panel queries

Add useResolvedVariables, which derives the variable definitions from the spec,
reads the runtime selection from the store, builds the V5 payload, and publishes
it via setResolvedVariables. DashboardContainer calls it once. usePanelQuery
reads selectResolvedVariables(dashboardId) and threads it into the request and
the query key, so each panel (and the editor preview) substitutes the bar's
selected values and refetches when a selection changes.
2026-07-01 07:12:14 +00:00
27 changed files with 1016 additions and 82 deletions

View File

@@ -24,6 +24,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
import { getGraphType } from 'utils/getGraphType';
/**
* @deprecated V1-only. V2 dashboards seed alerts from a panel via
* `useCreateAlertFromPanel` / `buildCreateAlertUrl`
* (pages/DashboardPageV2/.../Panel). Do not use in new code.
*/
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const queryRangeMutation = useMutation(getSubstituteVars);

View File

@@ -0,0 +1,43 @@
import { SquareArrowOutUpRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { ReactNode } from 'react';
import styles from './ConfigActions.module.scss';
interface ConfigActionRowProps {
/** Leading glyph for the action. */
icon: ReactNode;
label: string;
onClick: () => void;
testId?: string;
}
/**
* One row in the config pane's "Actions" list — a cross-page navigation link
* (leading icon, label, trailing external-link affordance). The whole row is the
* click target.
*/
function ConfigActionRow({
icon,
label,
onClick,
testId,
}: ConfigActionRowProps): JSX.Element {
return (
<Button
type="button"
variant="outlined"
color="secondary"
className={styles.row}
data-testid={testId}
onClick={onClick}
prefix={<span className={styles.icon}>{icon}</span>}
suffix={<SquareArrowOutUpRight size={14} />}
>
<Typography.Text className={styles.label}>{label}</Typography.Text>
</Button>
);
}
export default ConfigActionRow;

View File

@@ -0,0 +1,57 @@
/* The "Actions" group: a list of cross-page navigation links, visually separated
from the collapsible config sections above by the same hairline divider. */
.divider {
height: 1px;
background: var(--l2-border);
margin: 18px 0;
}
.container {
padding: 0 16px;
}
.eyebrow {
display: block;
margin: 0 2px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l1-foreground);
}
.list {
display: flex;
flex-direction: column;
}
/* A navigation-link row: leading icon, label, trailing external-link affordance. */
.row {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 44px;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
&:hover {
background: color-mix(in srgb, var(--bg-vanilla-100) 6%, transparent);
}
}
.icon {
display: grid;
place-items: center;
flex: none;
color: var(--l2-foreground);
}
.label {
flex: 1;
text-align: left;
}

View File

@@ -0,0 +1,51 @@
import { Bell } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
import ConfigActionRow from './ConfigActionRow';
import styles from './ConfigActions.module.scss';
interface ConfigActionsProps {
/** The draft panel — its current query seeds the actions (e.g. Create alert). */
panel: DashboardtypesPanelDTO;
panelId: string;
}
/**
* The "Actions" group at the foot of the config pane: cross-page navigation links,
* kept distinct from the collapsible config sections above. Each link is gated by the
* panel kind's capabilities; the whole group hides when none apply.
*/
function ConfigActions({
panel,
panelId,
}: ConfigActionsProps): JSX.Element | null {
const createAlert = useCreateAlertFromPanel();
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
// Only kinds whose query can seed an alert offer this today; mirror the panel
// menu's create-alert capability.
if (!actions.createAlert) {
return null;
}
return (
<>
<div className={styles.divider} />
<div className={styles.container}>
<span className={styles.eyebrow}>Actions</span>
<div className={styles.list}>
<ConfigActionRow
testId="panel-editor-v2-create-alert"
icon={<Bell size={14} />}
label="Create alert"
onClick={(): void => createAlert(panel, panelId)}
/>
</div>
</div>
</>
);
}
export default ConfigActions;

View File

@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigActions from '../ConfigActions';
const mockCreateAlert = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
() => ({
useCreateAlertFromPanel: jest.fn(() => mockCreateAlert),
}),
);
function makePanel(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind, spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('ConfigActions', () => {
beforeEach(() => jest.clearAllMocks());
it('offers "Create alert rule" for a create-alert-capable kind and seeds from the panel', async () => {
const user = userEvent.setup();
const panel = makePanel('signoz/TimeSeriesPanel');
render(<ConfigActions panel={panel} panelId="panel-1" />);
const row = screen.getByTestId('panel-editor-v2-create-alert');
expect(row).toHaveTextContent('Create alert');
await user.click(row);
expect(mockCreateAlert).toHaveBeenCalledWith(panel, 'panel-1');
});
it('renders nothing for a kind that cannot seed an alert', () => {
const { container } = render(
<ConfigActions panel={makePanel('signoz/TablePanel')} panelId="panel-1" />,
);
expect(
screen.queryByTestId('panel-editor-v2-create-alert'),
).not.toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -1,20 +1,22 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
panelKind: PanelKind;
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
@@ -32,6 +34,12 @@ interface ConfigPaneProps {
tableColumns: TableColumnOption[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/**
* The draft panel and its id — the "Actions" group seeds cross-page links
* (Create alert) from the current query.
*/
panel: DashboardtypesPanelDTO;
panelId: string;
}
/**
@@ -41,7 +49,6 @@ interface ConfigPaneProps {
* generically via the section registry — only sections with a built editor appear.
*/
function ConfigPane({
panelKind,
spec,
onChangeSpec,
onChangePanelKind,
@@ -49,7 +56,10 @@ function ConfigPane({
legendSeries,
tableColumns,
stepInterval,
panel,
panelId,
}: ConfigPaneProps): JSX.Element {
const panelKind = spec.plugin.kind;
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
@@ -114,6 +124,8 @@ function ConfigPane({
</div>
</>
)}
<ConfigActions panel={panel} panelId={panelId} />
</div>
);
}

View File

@@ -1,9 +1,20 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import ConfigPane from '../ConfigPane';
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
() => ({
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
}),
);
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
@@ -19,13 +30,14 @@ function renderConfigPane(
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
): React.ComponentProps<typeof ConfigPane> {
const props: React.ComponentProps<typeof ConfigPane> = {
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
queryType: EQueryType.QUERY_BUILDER,
legendSeries: [],
tableColumns: [],
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,
panelId: 'panel-1',
...overrides,
};
render(<ConfigPane {...props} />);
@@ -63,4 +75,28 @@ describe('ConfigPane', () => {
screen.getByTestId('config-section-formatting-&-units'),
).toBeInTheDocument();
});
it('renders the Actions group for a create-alert-capable panel', () => {
// renderConfigPane defaults to a TimeSeries panel, which can seed an alert.
renderConfigPane();
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-create-alert'),
).toBeInTheDocument();
});
it('omits the create-alert action for a kind that cannot seed an alert', () => {
// Table panels can't seed alerts → the Actions group hides its row. Only the
// panel passed to ConfigActions needs the kind; sections are asserted elsewhere.
const panel = {
kind: 'Panel',
spec: { ...spec(), plugin: { kind: 'signoz/TablePanel', spec: {} } },
} as DashboardtypesPanelDTO;
renderConfigPane({ panel });
expect(
screen.queryByTestId('panel-editor-v2-create-alert'),
).not.toBeInTheDocument();
});
});

View File

@@ -71,10 +71,8 @@ function PreviewPane({
<div className={styles.container}>
<div className={styles.surface}>
<PanelHeader
name={panel.spec.display.name}
description={panel.spec.display.description}
panelId={panelId}
panelKind={panel.spec.plugin.kind}
panel={panel}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}

View File

@@ -242,7 +242,8 @@ function PanelEditorContainer({
className={styles.right}
>
<ConfigPane
panelKind={draft.spec.plugin.kind}
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}

View File

@@ -41,10 +41,6 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
const name = panel.spec.display.name;
const description = panel.spec.display?.description;
const fullKind = panel.spec.plugin.kind;
// A per-panel time preference is surfaced as a header pill. `visualization` is
// common to every plugin-spec variant — localized cast reads it without
// narrowing on kind.
@@ -55,7 +51,8 @@ function Panel({
)?.visualization?.timePreference;
const timeLabel = panelTimePreferenceLabel(timePreference);
const panelDefinition = getPanelDefinition(fullKind);
const panelKind = panel.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
@@ -77,10 +74,8 @@ function Panel({
data-panel-visible={isVisible ? 'true' : 'false'}
>
<PanelHeader
name={name}
description={description}
panelId={panelId}
panelKind={fullKind}
panel={panel}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}

View File

@@ -1,17 +1,17 @@
import { EllipsisVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { PanelActionsConfig } from '../Panel';
import { usePanelActionItems } from './usePanelActionItems';
import styles from './PanelActionsMenu.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
interface PanelActionsMenuProps {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
panelKind: PanelKind;
/** The panel itself — its query seeds "Create Alerts". */
panel: DashboardtypesPanelDTO;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
@@ -23,12 +23,12 @@ interface PanelActionsMenuProps {
*/
function PanelActionsMenu({
panelId,
panelKind,
panel,
panelActions,
}: PanelActionsMenuProps): JSX.Element | null {
const { items, deleteConfirm } = usePanelActionItems({
panelId,
panelKind,
panel,
panelActions,
});

View File

@@ -1,10 +1,10 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
const mockOpenEditor = jest.fn();
jest.mock(
@@ -29,6 +29,11 @@ jest.mock('../../hooks/useClonePanel', () => ({
useClonePanel: (): jest.Mock => mockClonePanel,
}));
const mockCreateAlert = jest.fn();
jest.mock('../../hooks/useCreateAlertFromPanel', () => ({
useCreateAlertFromPanel: (): jest.Mock => mockCreateAlert,
}));
// Role is the only thing read off the app context; useComponentPermission runs
// for real so the tests exercise the actual role → permission mapping.
let mockRole: ROLES = 'ADMIN';
@@ -55,9 +60,20 @@ const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
// translation it drives is covered by buildCreateAlertUrl's own tests.
const mockPanel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const baseArgs = {
panelId: 'panel-1',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panel: mockPanel,
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
};
@@ -115,29 +131,18 @@ describe('usePanelActionItems', () => {
]);
});
it('unknown panel kind hides all kind-gated actions (incl. clone), keeping only move/delete', () => {
const { result } = renderHook(() =>
// A kind with no registered definition — exercises the "unsupported kind"
// branch. Clone is kind-gated (needs the kind to declare actions.clone),
// so it drops too; only the kind-agnostic layout actions remain.
usePanelActionItems({
...baseArgs,
panelKind: 'signoz/UnsupportedPanel' as PanelKind,
}),
);
expect(itemKeys(result.current)).toStrictEqual([
'move',
'divider',
'delete-panel',
]);
});
it('read-only dashboard keeps only View (V1 parity)', () => {
it('read-only dashboard keeps View and Create Alerts (V1 parity: both survive a lock)', () => {
useDashboardStore.setState({ isEditable: false });
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
// Create Alerts opens a new tab and never mutates the dashboard, so it
// isn't gated on edit access — matching V1's locked-dashboard menu.
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
]);
});
it('move is disabled when there is no other titled section to move to', () => {
@@ -259,18 +264,26 @@ describe('usePanelActionItems', () => {
});
});
it('not-yet-implemented actions (view/create-alert) fire the placeholder alert with the feature name', () => {
it('not-yet-implemented actions (view) fire the placeholder alert with the feature name', () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
['view-panel', 'create-alert'].forEach((key) => {
const item = result.current.items.find((i) => 'key' in i && i.key === key);
(item as { onClick: () => void }).onClick();
});
const view = result.current.items.find(
(i) => 'key' in i && i.key === 'view-panel',
);
(view as { onClick: () => void }).onClick();
expect(alertSpy).toHaveBeenCalledTimes(2);
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
alertSpy.mockRestore();
});
it('create-alert seeds an alert from this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const createAlert = result.current.items.find(
(i) => 'key' in i && i.key === 'create-alert',
);
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
});

View File

@@ -10,6 +10,7 @@ import {
Trash2,
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import {
type ConfirmableAction,
@@ -23,13 +24,13 @@ import { useAppContext } from 'providers/App/App';
import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useClonePanel } from '../hooks/useClonePanel';
import { useCreateAlertFromPanel } from '../hooks/useCreateAlertFromPanel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import {
type MovePanelArgs,
useMovePanelToSection,
} from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -103,8 +104,8 @@ function buildMoveItems({
interface UsePanelActionItemsArgs {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
panelKind: PanelKind;
/** The panel itself — its query seeds the "Create Alerts" action. */
panel: DashboardtypesPanelDTO;
/** Layout context for move/delete — absent outside editable mode. */
panelActions?: PanelActionsConfig;
}
@@ -128,9 +129,10 @@ export interface PanelActionItems {
*/
export function usePanelActionItems({
panelId,
panelKind,
panel,
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const panelKind = panel.spec.plugin.kind;
const { user } = useAppContext();
const [canEditWidget, canMove, canDelete] = useComponentPermission(
[
@@ -143,6 +145,7 @@ export function usePanelActionItems({
);
const isEditable = useDashboardStore((s) => s.isEditable);
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
// supplies data (`sections`), so no callbacks are threaded through it.
@@ -151,7 +154,7 @@ export function usePanelActionItems({
const deletePanel = useDeletePanel({ sections });
const clonePanel = useClonePanel({ sections });
const kindActions = getPanelDefinition(panelKind)?.actions;
const panelCapabilities = getPanelDefinition(panelKind).actions;
// Delete runs on confirm, not on click — the menu item opens a prompt.
const deleteConfirm = useConfirmableAction(
@@ -170,7 +173,7 @@ export function usePanelActionItems({
const items = useMemo<MenuItem[]>(() => {
const panelGroup: MenuItem[] = [];
if (kindActions?.view) {
if (panelCapabilities.view) {
panelGroup.push({
key: 'view-panel',
label: 'View',
@@ -178,7 +181,7 @@ export function usePanelActionItems({
onClick: (): void => notImplementedYet('View'),
});
}
if (isEditable && canEditWidget && kindActions?.edit) {
if (isEditable && canEditWidget && panelCapabilities.edit) {
panelGroup.push({
key: 'edit-panel',
label: 'Edit panel',
@@ -188,7 +191,7 @@ export function usePanelActionItems({
}
// Clone needs the section context (source spec + dimensions) to place the
// copy, so — unlike Edit — it requires panelActions.
if (isEditable && canEditWidget && panelActions && kindActions?.clone) {
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
panelGroup.push({
key: 'clone-panel',
label: 'Clone',
@@ -202,7 +205,7 @@ export function usePanelActionItems({
}
const dataGroup: MenuItem[] = [];
if (kindActions?.download) {
if (panelCapabilities.download) {
dataGroup.push({
key: 'download-panel',
label: 'Download as CSV',
@@ -210,12 +213,15 @@ export function usePanelActionItems({
onClick: (): void => notImplementedYet('Download'),
});
}
if (isEditable && kindActions?.createAlert) {
// Seeding an alert opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity: available
// on locked dashboards too).
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
label: 'Create Alerts',
icon: <Bell size={14} />,
onClick: (): void => notImplementedYet('Create Alerts'),
onClick: (): void => createAlert(panel, panelId),
});
}
@@ -252,11 +258,13 @@ export function usePanelActionItems({
canEditWidget,
canMove,
canDelete,
kindActions,
panelCapabilities,
panel,
panelActions,
sections,
panelId,
openPanelEditor,
createAlert,
movePanel,
clonePanel,
requestDelete,

View File

@@ -1,7 +1,10 @@
import { useMemo } from 'react';
import { Info, Loader } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
@@ -14,15 +17,12 @@ import {
panelStatusFromWarning,
} from '../PanelStatus/utils';
import styles from './PanelHeader.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface PanelHeaderProps {
name: string;
description?: string;
panelId: string;
/** Full plugin kind — drives kind-gated menu actions. */
panelKind: PanelKind;
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
panel: DashboardtypesPanelDTO;
/** Background refresh in flight — shows a spinner without blinking the chart. */
isFetching: boolean;
/** Latest query error — surfaced as a header error indicator. */
@@ -49,10 +49,8 @@ interface PanelHeaderProps {
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
name,
description,
panelId,
panelKind,
panel,
isFetching,
error,
warning,
@@ -63,6 +61,8 @@ function PanelHeader({
onSearchChange,
hideActions,
}: PanelHeaderProps): JSX.Element {
const name = panel.spec.display.name;
const description = panel.spec.display.description;
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
const warningDetail = useMemo(
@@ -116,7 +116,7 @@ function PanelHeader({
{!hideActions && (
<PanelActionsMenu
panelId={panelId}
panelKind={panelKind}
panel={panel}
panelActions={panelActions}
/>
)}

View File

@@ -1,11 +1,11 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { ReactElement } from 'react';
import type { Warning } from 'types/api';
import PanelHeader from '../PanelHeader/PanelHeader';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// Status indicators use a radix tooltip, which needs a TooltipProvider ancestor
// (supplied globally by AppLayout at runtime).
@@ -22,9 +22,26 @@ jest.mock(
},
);
// The header reads its name/description/kind off the panel itself.
function makePanel(overrides?: {
name?: string;
description?: string;
}): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: {
name: overrides?.name ?? 'My panel',
description: overrides?.description,
},
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
name: 'My panel',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panel: makePanel(),
panelId: 'panel-1',
isFetching: false,
};
@@ -44,7 +61,10 @@ describe('PanelHeader title and description', () => {
it('shows the description info icon when a description is provided', () => {
renderWithProvider(
<PanelHeader {...baseProps} description="What this panel measures" />,
<PanelHeader
{...baseProps}
panel={makePanel({ description: 'What this panel measures' })}
/>,
);
expect(screen.getByTestId('panel-header-info-icon')).toBeInTheDocument();
});

View File

@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { useCreateAlertFromPanel } from '../useCreateAlertFromPanel';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
// The V5→V1 query→URL translation is covered by buildCreateAlertUrl's own tests;
// stub it so this asserts only the hook's side effects (analytics + navigation).
jest.mock('../../utils/buildCreateAlertUrl', () => ({
buildCreateAlertUrl: (): string => '/alerts/new?composite=1',
}));
const mockLogEvent = logEvent as jest.Mock;
const panel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
describe('useCreateAlertFromPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1' });
});
it('opens the seeded alert builder in a new tab', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=1', {
newTab: true,
});
});
it('logs the create-alert action with panel and dashboard context (V1 parity)', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockLogEvent).toHaveBeenCalledWith(
'Dashboard Detail: Panel action',
expect.objectContaining({
action: 'createAlerts',
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: 'dash-1',
widgetId: 'panel-1',
}),
);
});
});

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { buildCreateAlertUrl } from '../utils/buildCreateAlertUrl';
/**
* Returns a callback that opens the alert builder in a new tab, seeded from a
* panel's query, and logs the action — mirroring V1's `useCreateAlerts`
* ('dashboardView' caller). The panel is supplied at call time so the callback
* stays stable across panels (and the dashboard's react-query refetches).
*/
export function useCreateAlertFromPanel(): (
panel: DashboardtypesPanelDTO,
panelId: string,
) => void {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
void logEvent('Dashboard Detail: Panel action', {
action: 'createAlerts',
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
dashboardId,
widgetId: panelId,
queryType: getPanelQueryType(panel),
});
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
},
[dashboardId, safeNavigate],
);
}

View File

@@ -0,0 +1,103 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { buildCreateAlertUrl } from '../buildCreateAlertUrl';
// The V5→V1 translation has its own coverage; stub it so this asserts only the
// URL assembly (params, encoding, unit) buildCreateAlertUrl owns.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({
fromPerses: jest.fn(),
}),
);
const mockFromPerses = fromPerses as jest.Mock;
const translatedQuery: Query = {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
id: 'q1',
};
function makePanel(
overrides?: Partial<{ unit: string; queries: unknown[] }>,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: overrides?.unit ? { formatting: { unit: overrides.unit } } : {},
},
queries: overrides?.queries ?? [{ some: 'query' }],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('buildCreateAlertUrl', () => {
beforeEach(() => {
mockFromPerses.mockReset();
mockFromPerses.mockReturnValue({ ...translatedQuery });
});
function parse(url: string): URLSearchParams {
expect(url.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
return new URLSearchParams(url.slice(url.indexOf('?') + 1));
}
it('translates the panel queries with the mapped panel type', () => {
const panel = makePanel();
buildCreateAlertUrl(panel);
expect(mockFromPerses).toHaveBeenCalledWith(
panel.spec.queries,
PANEL_TYPES.TIME_SERIES,
);
});
it('tags the URL with panel type, v5 version, and the dashboards source', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
expect(params.get(QueryParams.version)).toBe(ENTITY_VERSION_V5);
expect(params.get(QueryParams.source)).toBe('dashboards');
});
it('encodes the translated query as the compositeQuery param', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
const raw = params.get(QueryParams.compositeQuery);
expect(raw).toBeTruthy();
const decoded = JSON.parse(decodeURIComponent(raw as string));
expect(decoded.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(decoded.id).toBe('q1');
});
it('carries the panel formatting unit onto the alert query when set', () => {
const params = parse(buildCreateAlertUrl(makePanel({ unit: 'bytes' })));
const decoded = JSON.parse(
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
);
expect(decoded.unit).toBe('bytes');
});
it('leaves the query unit unset when the panel has no formatting unit', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
const decoded = JSON.parse(
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
);
expect(decoded.unit).toBeUndefined();
});
});

View File

@@ -0,0 +1,54 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
): string | undefined {
switch (plugin.kind) {
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/NumberPanel':
case 'signoz/PieChartPanel':
return plugin.spec.formatting?.unit;
default:
return undefined;
}
}
/**
* Builds the `/alerts/new` URL that seeds the alert builder from a panel's query,
* mirroring V1's `useCreateAlerts`: the panel's V5 queries are translated to the
* V1 `Query` the alert page reads from `compositeQuery`, tagged with the panel
* type, entity version, and a `dashboards` source.
*
* Unlike V1 there is no `/substitute_vars` round-trip — V2 has no query-variable
* plumbing yet, so any dashboard-variable references travel through verbatim.
*/
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
const unit = readPanelUnit(panel.spec.plugin);
if (unit) {
query.unit = unit;
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
params.set(QueryParams.panelTypes, panelType);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
}

View File

@@ -0,0 +1,67 @@
import { renderHook } from '@testing-library/react';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { selectResolvedVariables } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useResolvedVariables } from '../useResolvedVariables';
// A text variable is the simplest envelope (no list plugin); the builder's full
// type/value matrix is covered in buildVariablesPayload.test.ts. The envelope is
// cast at the boundary — its kind discriminant is the literal 'TextVariable'.
function textVariable(name: string, value: string): DashboardtypesVariableDTO {
return {
kind: 'TextVariable',
spec: { name, value, display: { name } },
} as unknown as DashboardtypesVariableDTO;
}
function dashboard(
id: string,
variables: DashboardtypesVariableDTO[],
): DashboardtypesGettableDashboardV2DTO {
return {
id,
spec: { variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
describe('useResolvedVariables', () => {
afterEach(() => {
useDashboardStore.setState({ variableValues: {}, resolvedVariables: {} });
});
it('publishes the resolved V5 payload for the dashboard to the store', () => {
renderHook(() =>
useResolvedVariables(dashboard('d1', [textVariable('env', 'prod')])),
);
expect(
selectResolvedVariables('d1')(useDashboardStore.getState()),
).toStrictEqual({ env: { type: 'text', value: 'prod' } });
});
it('reflects the runtime selection over the configured default', () => {
useDashboardStore
.getState()
.setVariableValues('d2', { env: { value: 'staging', allSelected: false } });
renderHook(() =>
useResolvedVariables(dashboard('d2', [textVariable('env', 'prod')])),
);
expect(
selectResolvedVariables('d2')(useDashboardStore.getState()),
).toStrictEqual({ env: { type: 'text', value: 'staging' } });
});
it('publishes an empty payload when the dashboard has no variables', () => {
renderHook(() => useResolvedVariables(dashboard('d3', [])));
expect(
selectResolvedVariables('d3')(useDashboardStore.getState()),
).toStrictEqual({});
});
});

View File

@@ -17,6 +17,8 @@ import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
@@ -65,8 +67,9 @@ export interface UsePanelQueryResult {
/**
* Fetches query-range data for a V2 panel over the pure-V5 contract: builds the request DTO
* from the panel's perses queries (no V1 `Query` intermediary), reads global time from Redux,
* and posts via `useGetQueryRangeV5`. Variable substitution is deferred until V2 has its own
* variable plumbing. Renderers consume the raw response through the `queryV5` prep utils.
* substitutes the dashboard's resolved variable values (published to the store by
* `useResolvedVariables`), and posts via `useGetQueryRangeV5`. Renderers consume the raw
* response through the `queryV5` prep utils.
*/
export function usePanelQuery({
panel,
@@ -105,6 +108,11 @@ export function usePanelQuery({
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Resolved variable values for this dashboard, published by useResolvedVariables.
// Substituted into the request and keyed into the cache so a selection change refetches.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
const pluginSpec = panel.spec.plugin.spec;
@@ -141,8 +149,19 @@ export function usePanelQuery({
endMs,
fillGaps,
pagination: isPaginated ? { offset, limit: pageSize } : undefined,
variables,
}),
[queries, panelType, startMs, endMs, fillGaps, isPaginated, offset, pageSize],
[
queries,
panelType,
startMs,
endMs,
fillGaps,
isPaginated,
offset,
pageSize,
variables,
],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
@@ -167,6 +186,8 @@ export function usePanelQuery({
// Each page is its own cache entry (0/default for non-paged kinds).
offset,
pageSize,
// Variable selection changes the request, so it must re-key the cache (refetch).
variables,
],
[
panelId,
@@ -182,6 +203,7 @@ export function usePanelQuery({
queries,
offset,
pageSize,
variables,
],
);

View File

@@ -0,0 +1,42 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import { buildVariablesPayload } from '../queryV5/buildVariablesPayload';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* Resolves the dashboard's variable selection into the V5 query payload and
* publishes it to the store, so `usePanelQuery` reads it by dashboardId without
* the spec being threaded through the panel tree (the `setEditContext` pattern).
*
* Definitions come from the spec; values come from the runtime selection (seeded
* by the variable bar). Re-publishes whenever either changes, which re-keys the
* panel queries and triggers a refetch with the new values.
*/
export function useResolvedVariables(
dashboard: DashboardtypesGettableDashboardV2DTO,
): void {
const dashboardId = dashboard.id ?? '';
const definitions = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setResolvedVariables = useDashboardStore((s) => s.setResolvedVariables);
const resolved = useMemo(
() => buildVariablesPayload(definitions, selection),
[definitions, selection],
);
useEffect(() => {
if (!dashboardId) {
return;
}
setResolvedVariables(dashboardId, resolved);
}, [dashboardId, resolved, setResolvedVariables]);
}

View File

@@ -7,6 +7,7 @@ import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
@@ -50,6 +51,10 @@ function DashboardContainer({
setEditContext,
]);
// Resolve the variable selection into the V5 query payload and publish it to
// the store, so each panel's query substitutes the bar's selected values.
useResolvedVariables(dashboard);
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;

View File

@@ -0,0 +1,103 @@
import {
emptyVariableFormModel,
type VariableFormModel,
type VariableType,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../../VariablesBar/selectionTypes';
import { buildVariablesPayload } from '../buildVariablesPayload';
function variable(
name: string,
type: VariableType,
overrides: Partial<VariableFormModel> = {},
): VariableFormModel {
return { ...emptyVariableFormModel(), name, type, ...overrides };
}
describe('buildVariablesPayload', () => {
it('returns an empty map when there are no definitions', () => {
expect(buildVariablesPayload([], {})).toStrictEqual({});
});
it('maps each UI variable type to its V5 wire type', () => {
const definitions = [
variable('q', 'QUERY'),
variable('c', 'CUSTOM'),
variable('t', 'TEXT'),
variable('d', 'DYNAMIC'),
];
const selection: VariableSelectionMap = {
q: { value: 'a', allSelected: false },
c: { value: 'b', allSelected: false },
t: { value: 'c', allSelected: false },
d: { value: 'e', allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
q: { type: 'query', value: 'a' },
c: { type: 'custom', value: 'b' },
t: { type: 'text', value: 'c' },
d: { type: 'dynamic', value: 'e' },
});
});
it('passes a multi-select array value through verbatim', () => {
const definitions = [variable('svc', 'QUERY', { multiSelect: true })];
const selection: VariableSelectionMap = {
svc: { value: ['a', 'b'], allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
svc: { type: 'query', value: ['a', 'b'] },
});
});
it('collapses a multi-select dynamic ALL selection to the __all__ sentinel', () => {
const definitions = [variable('pod', 'DYNAMIC', { multiSelect: true })];
const selection: VariableSelectionMap = {
pod: { value: null, allSelected: true },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
pod: { type: 'dynamic', value: '__all__' },
});
});
it('does NOT collapse a query ALL selection — it sends the full value array', () => {
const definitions = [variable('svc', 'QUERY', { multiSelect: true })];
const selection: VariableSelectionMap = {
svc: { value: ['a', 'b'], allSelected: true },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
svc: { type: 'query', value: ['a', 'b'] },
});
});
it('falls back to a text variable configured value when unselected', () => {
const definitions = [variable('env', 'TEXT', { textValue: 'prod' })];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
env: { type: 'text', value: 'prod' },
});
});
it('falls back to a list variable configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', {
defaultValue: { value: 'us-east' },
} as unknown as Partial<VariableFormModel>),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: 'us-east' },
});
});
it('omits a variable with no selection and no default', () => {
const definitions = [variable('q', 'QUERY')];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({});
});
it('omits an unnamed variable', () => {
const definitions = [variable('', 'QUERY')];
const selection: VariableSelectionMap = {
'': { value: 'x', allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({});
});
});

View File

@@ -6,6 +6,7 @@ import type {
Querybuildertypesv5PromQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5QueryRangeRequestDTOVariables,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
@@ -202,11 +203,13 @@ export interface BuildQueryRangeRequestArgs {
fillGaps?: boolean;
/** Server-side paging for raw/list panels, written onto the builder queries' `offset`/`limit`. */
pagination?: { offset: number; limit: number };
/** Runtime variable values (name → {type,value}) substituted server-side; built by `buildVariablesPayload`. */
variables?: Querybuildertypesv5QueryRangeRequestDTOVariables;
}
/**
* Builds the V5 query-range request DTO directly from the panel's perses queries (no V1 `Query`
* intermediary). Variables are absent (`variables: {}`) until V2 grows its own variable plumbing.
* intermediary). `variables` carries the runtime selection (empty when the dashboard has none).
*/
export function buildQueryRangeRequest({
queries,
@@ -215,6 +218,7 @@ export function buildQueryRangeRequest({
endMs,
fillGaps = false,
pagination,
variables = {},
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (panelType === PANEL_TYPES.BAR) {
@@ -234,7 +238,7 @@ export function buildQueryRangeRequest({
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
fillGaps,
},
variables: {},
variables,
};
}

View File

@@ -0,0 +1,105 @@
import type {
Querybuildertypesv5QueryRangeRequestDTOVariables,
Querybuildertypesv5VariableItemDTOValue,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5VariableTypeDTO } from 'api/generated/services/sigNoz.schemas';
import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from '../VariablesBar/selectionTypes';
/**
* Backend sentinel for "every value selected" on a multi-select dynamic variable.
* V1 parity (`getDashboardVariables`): only dynamic vars collapse to `__all__`;
* query/custom multi-selects send the full value array instead. Lowercase — the
* URL/store `__ALL__` sentinel is a separate serialization concern.
*/
const ALL_VALUES_SENTINEL = '__all__';
/** UI variable grouping → the V5 wire `variables[].type`. */
const VARIABLE_TYPE_TO_DTO: Record<
VariableType,
Querybuildertypesv5VariableTypeDTO
> = {
QUERY: Querybuildertypesv5VariableTypeDTO.query,
CUSTOM: Querybuildertypesv5VariableTypeDTO.custom,
TEXT: Querybuildertypesv5VariableTypeDTO.text,
DYNAMIC: Querybuildertypesv5VariableTypeDTO.dynamic,
};
/** The variable's configured default, used when nothing is selected yet. */
function configuredDefault(
definition: VariableFormModel,
): SelectedVariableValue | undefined {
if (definition.type === 'TEXT') {
return definition.textValue || undefined;
}
return (
definition.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
}
/**
* Resolves the wire value for one variable: the dynamic "ALL" sentinel, else the
* user's selection, else the configured default. Returns `undefined` when there
* is nothing meaningful to send (the variable is then omitted from the payload).
*/
function resolveValue(
definition: VariableFormModel,
selection: VariableSelection | undefined,
): Querybuildertypesv5VariableItemDTOValue | undefined {
if (
definition.type === 'DYNAMIC' &&
definition.multiSelect &&
selection?.allSelected
) {
return ALL_VALUES_SENTINEL;
}
const selected = selection?.value;
const hasSelection =
selected !== null &&
selected !== undefined &&
!(typeof selected === 'string' && selected === '');
if (hasSelection) {
return selected as Querybuildertypesv5VariableItemDTOValue;
}
const fallback = configuredDefault(definition);
return fallback == null
? undefined
: (fallback as Querybuildertypesv5VariableItemDTOValue);
}
/**
* Builds the V5 `variables` map from the dashboard's variable definitions and the
* runtime selection, so a panel query substitutes the values the user picked in
* the variable bar (V1 parity with `getDashboardVariables` + the V5 prep). The
* definition list supplies the wire `type` (the selection map carries only values).
*/
export function buildVariablesPayload(
definitions: VariableFormModel[],
selection: VariableSelectionMap,
): Querybuildertypesv5QueryRangeRequestDTOVariables {
const payload: Querybuildertypesv5QueryRangeRequestDTOVariables = {};
definitions.forEach((definition) => {
if (!definition.name) {
return;
}
const value = resolveValue(definition, selection[definition.name]);
if (value === undefined) {
return;
}
payload[definition.name] = {
type: VARIABLE_TYPE_TO_DTO[definition.type],
value,
};
});
return payload;
}

View File

@@ -1,3 +1,4 @@
import type { Querybuildertypesv5QueryRangeRequestDTOVariables } from 'api/generated/services/sigNoz.schemas';
import type { StateCreator } from 'zustand';
import type {
@@ -12,9 +13,19 @@ import type { DashboardStore } from '../useDashboardStore';
* localStorage (mirrored to the URL by the bar for shareable links); it is
* deliberately NOT part of the dashboard spec, so selecting a value never
* patches the dashboard.
*
* `resolvedVariables` is the same selection resolved into the V5 query payload
* shape (`{ name: { type, value } }`), published by `useResolvedVariables` so
* `usePanelQuery` reads it without threading the dashboard spec down the tree
* (the edit-context publish pattern). Transient — not persisted (it is derived
* from `variableValues` + the spec on every load).
*/
export interface VariableSelectionSlice {
variableValues: Record<string, VariableSelectionMap>;
resolvedVariables: Record<
string,
Querybuildertypesv5QueryRangeRequestDTOVariables
>;
setVariableValue: (
dashboardId: string,
name: string,
@@ -22,6 +33,11 @@ export interface VariableSelectionSlice {
) => void;
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
/** Publish the resolved V5 variables payload for a dashboard. */
setResolvedVariables: (
dashboardId: string,
variables: Querybuildertypesv5QueryRangeRequestDTOVariables,
) => void;
}
export const createVariableSelectionSlice: StateCreator<
@@ -31,6 +47,7 @@ export const createVariableSelectionSlice: StateCreator<
VariableSelectionSlice
> = (set, get) => ({
variableValues: {},
resolvedVariables: {},
setVariableValue: (dashboardId, name, selection): void => {
const { variableValues } = get();
set({
@@ -46,6 +63,12 @@ export const createVariableSelectionSlice: StateCreator<
variableValues: { ...variableValues, [dashboardId]: values },
});
},
setResolvedVariables: (dashboardId, variables): void => {
const { resolvedVariables } = get();
set({
resolvedVariables: { ...resolvedVariables, [dashboardId]: variables },
});
},
});
/**
@@ -60,3 +83,13 @@ export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? EMPTY_SELECTION_MAP;
/** Stable empty payload — same rationale as {@link EMPTY_SELECTION_MAP}. */
const EMPTY_RESOLVED_VARIABLES: Querybuildertypesv5QueryRangeRequestDTOVariables =
{};
/** Selector: the resolved V5 variables payload for a dashboard (empty if none). */
export const selectResolvedVariables =
(dashboardId: string) =>
(state: DashboardStore): Querybuildertypesv5QueryRangeRequestDTOVariables =>
state.resolvedVariables[dashboardId] ?? EMPTY_RESOLVED_VARIABLES;