Compare commits

...

5 Commits

Author SHA1 Message Date
Gaurav Tewari
a63bc71c6b chore: remove comments 2026-07-25 13:45:52 +05:30
Gaurav Tewari
056e48a265 feat(logs): highlight the URL-linked (activeLogId) row in table format (#12271)
Restores the linked-row highlight that the TanStack migration (#10946)
dropped from the table view, without coupling it to click routing.

Uses the table's existing `getRowClassName` hook (styling-only) — no new
shared-table prop:
- LogsExplorerList / LiveLogsList set getRowClassName to 'logs-linked-row'
  when `log.id === activeLogId`.
- A global, lowest-specificity rule paints `.logs-linked-row td` using the
  per-row `--row-active-bg` var (already set via getRowStyle). The table's own
  :hover / .tableRowActive cell rules are higher-specificity !important, so
  active/hover correctly win when a linked row is also open or hovered.

`isRowActive` stays `activeLog?.id` only, so the highlighted row still opens on
first click (relies on the routing fix in the base PR).

Color reuses --row-active-bg as a placeholder; a distinct "linked" token is a
follow-up pending design.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-25 12:59:32 +05:30
Gaurav Tewari
25cc65ace2 fix(logs): open log details in table (Column) format when activeId is set
Opening a log link (?activeLogId=...) and switching to Column/table format
left the highlighted row un-openable — clicking it did nothing.

Root cause: the shared TanStackTable decided open vs. close internally from
`isRowActive` + `onRowDeactivate`. LogsExplorerList had overloaded `isRowActive`
with `|| log.id === activeLogId`, so a URL-highlighted-but-closed row looked
"active" and the first click ran the deactivate (close) path instead of opening.

Fix (PR 1 of 2): move click routing to the consumer.
- Remove `onRowDeactivate` from TanStackTable; it took no args and only fired
  when re-clicking the same active row (clicking A→B never deactivated A).
- `onRowClick` now receives a third `{ isActive }` context arg (additive; the
  ~14 existing consumers are unaffected).
- LogsExplorerList and LiveLogsList own the toggle:
  `isActive ? handleCloseLogDetail() : handleSetActiveLog(log)`, and
  `isRowActive` tracks only `activeLog?.id`.

The linked-row visual highlight (`isHighlighted`) is deferred to PR 2 (blocked
on a design decision for the highlight color); see
frontend/docs/tdd-tanstack-row-click-routing.md.
2026-07-24 22:22:04 +05:30
Ashwin Bhatkal
38340b5027 fix(dashboards-v2): dedicated "all" dynamic-variable signal + required non-nullable links (#12201)
* fix(dashboards-v2): make panel/dashboard links required and non-nullable

* fix: validate links on read from db as well

* fix: allow all as value for signal

* fix: dont allow empty string for signal

* fix(dashboards-v2): dedicated `all` dynamic-variable signal (frontend + client)

* test: add empty links to new payloads in integration tests

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-07-23 10:34:18 +00:00
Abhi kumar
fba56e9a64 fix(dashboard): filter-quote escaping, panel export encoding, right-legend width, number-panel query warning (#12246)
* fix(qb): stop doubling escaped quotes on filter round-trip

formatSingleValue escapes ' -> \' when quoting a filter value, but its inverse
(unquote, in formatSingleValueForFilter) stripped the quotes without unescaping.
Each expression <-> filters round-trip therefore added a backslash. "Create
alert from panel" re-reads the query through the URL twice, so a filter like
name = 'it\'s a ribbon' arrived at the alert page as name = 'it\\'s a ribbon'
and failed the filter parser. Unescape \' -> ' so the round-trip is lossless.

* fix(dashboard): double-encode compositeQuery in panel export link

useGetCompositeQueryParam decodes the compositeQuery param twice, so a single
encode left a bare %/+ from a filter expression (e.g. ILIKE 'Inf%') that made
the reader's second decode throw and drop the whole query. Encode twice in
buildExportPanelLink so the query survives the round-trip.

* fix(dashboard): size right legend column to the longest label

A right-positioned legend is a single vertical column, so give it its own width
budget and size it to fit the longest series label, clamped to
[150px, min(320, 40% of container width)], instead of the shared bottom-legend
cap. Long service names no longer ellipsize in the pie/donut legend.

* feat(dashboard): warn when a number panel has more than one query enabled

A Number panel renders only the first query's value, so leaving extra
queries enabled (e.g. a formula plus its inputs) silently shows the wrong
value with no feedback. Surface a warning in the panel editor and on the
rendered panel when a Number panel has more than one enabled query.

Refs #9512
2026-07-23 08:47:25 +00:00
51 changed files with 867 additions and 181 deletions

View File

@@ -2748,7 +2748,6 @@ components:
links:
items:
$ref: '#/components/schemas/DashboardtypesLink'
nullable: true
type: array
panels:
additionalProperties:
@@ -2765,6 +2764,7 @@ components:
- variables
- panels
- layouts
- links
type: object
DashboardtypesDashboardView:
properties:
@@ -2843,14 +2843,22 @@ components:
required:
- name
type: object
DashboardtypesDynamicVariableSignal:
enum:
- traces
- logs
- metrics
- all
type: string
DashboardtypesDynamicVariableSpec:
properties:
name:
type: string
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
$ref: '#/components/schemas/DashboardtypesDynamicVariableSignal'
required:
- name
- signal
type: object
DashboardtypesFillMode:
enum:
@@ -3393,7 +3401,6 @@ components:
links:
items:
$ref: '#/components/schemas/DashboardtypesLink'
nullable: true
type: array
plugin:
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
@@ -3405,6 +3412,7 @@ components:
- display
- plugin
- queries
- links
type: object
DashboardtypesPatchOp:
enum:

View File

@@ -4626,9 +4626,9 @@ export interface DashboardtypesQueryDTO {
export interface DashboardtypesPanelSpecDTO {
display: DashboardtypesDisplayDTO;
/**
* @type array,null
* @type array
*/
links?: DashboardtypesLinkDTO[] | null;
links: DashboardtypesLinkDTO[];
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array
@@ -4668,12 +4668,18 @@ export type DashboardtypesVariableDefaultValueDTO = string | string[];
export enum DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind {
'signoz/DynamicVariable' = 'signoz/DynamicVariable',
}
export enum DashboardtypesDynamicVariableSignalDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
all = 'all',
}
export interface DashboardtypesDynamicVariableSpecDTO {
/**
* @type string
*/
name: string;
signal?: TelemetrytypesSignalDTO;
signal: DashboardtypesDynamicVariableSignalDTO;
}
export interface DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTO {
@@ -4815,9 +4821,9 @@ export interface DashboardtypesDashboardSpecDTO {
*/
layouts: DashboardtypesLayoutDTO[];
/**
* @type array,null
* @type array
*/
links?: DashboardtypesLinkDTO[] | null;
links: DashboardtypesLinkDTO[];
/**
* @type object
*/

View File

@@ -9,6 +9,7 @@ import { extractQueryPairs } from 'utils/queryContextUtils';
import {
convertAggregationToExpression,
convertExpressionToFilters,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
formatValueForExpression,
@@ -1598,4 +1599,36 @@ describe('formatValueForExpression', () => {
expect(formatValueForExpression([123] as any)).toBe('[123]');
});
});
// Regression: an escaped single quote must not gain a backslash on each
// expression <-> filters round-trip (broke Create Alert from a panel).
describe('escaped quote round-trip', () => {
const EXPR = "name = 'it\\'s a ribbon'"; // name = 'it\'s a ribbon' (single backslash)
it('stores the unescaped value in the filter item', () => {
const items = convertExpressionToFilters(EXPR);
expect(items).toHaveLength(1);
expect(items[0].value).toBe("it's a ribbon");
});
it('is lossless: expression -> filters -> expression', () => {
const items = convertExpressionToFilters(EXPR);
expect(convertFiltersToExpression({ items, op: 'AND' }).expression).toBe(
EXPR,
);
});
it('is idempotent across repeated existing-query conversions', () => {
const pass1 = convertFiltersToExpressionWithExistingQuery(
{ items: [], op: 'AND' },
EXPR,
);
expect(pass1.filter.expression).toBe(EXPR);
const pass2 = convertFiltersToExpressionWithExistingQuery(
pass1.filters,
pass1.filter.expression,
);
expect(pass2.filter.expression).toBe(EXPR);
});
});
});

View File

@@ -176,7 +176,9 @@ function formatSingleValueForFilter(
}
if (isQuoted(value)) {
return unquote(value);
// Unescape `\'` → `'` (inverse of formatSingleValue) so the round-trip doesn't
// double the backslash each pass.
return unquote(value).replace(/\\'/g, "'");
}
}

View File

@@ -33,7 +33,6 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
// Stable references via destructuring, keep them as is
const onRowClick = context?.onRowClick;
const onRowClickNewTab = context?.onRowClickNewTab;
const onRowDeactivate = context?.onRowDeactivate;
const isRowActive = context?.isRowActive;
const getRowKeyData = context?.getRowKeyData;
const rowIndex = row.index;
@@ -51,22 +50,12 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
return;
}
// The table does not decide open vs. close — it reports the click and
// the row's active state, and the consumer owns the routing.
const isActive = isRowActive?.(rowData) ?? false;
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData, itemKey);
}
onRowClick?.(rowData, itemKey, { isActive });
},
[
isRowActive,
onRowDeactivate,
onRowClick,
onRowClickNewTab,
rowData,
getRowKeyData,
rowIndex,
],
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
);
if (itemKind === 'expansion') {
@@ -120,7 +109,6 @@ function areRowCellsPropsEqual<TData>(
prev.columnVisibilityKey === next.columnVisibilityKey &&
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
prev.context?.renderRowActions === next.context?.renderRowActions &&

View File

@@ -96,7 +96,6 @@ function TanStackTableInner<TData, TItemKey = string>(
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
@@ -382,7 +381,6 @@ function TanStackTableInner<TData, TItemKey = string>(
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
colCount: visibleColumnsCount,
@@ -400,7 +398,6 @@ function TanStackTableInner<TData, TItemKey = string>(
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
visibleColumnsCount,

View File

@@ -85,7 +85,9 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
});
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
@@ -117,17 +119,19 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
isActive: false,
});
});
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
it('calls onRowClick with isActive: true when the row is active', async () => {
// The table no longer owns open/close — it reports the active state and the
// consumer routes the click. An active row must still fire onRowClick.
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowDeactivate,
isRowActive: () => true,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
@@ -152,8 +156,44 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: true,
});
});
it('calls onRowClick with isActive: false when the row is not active', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
isRowActive: () => false,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
});
it('does not render renderRowActions before hover', () => {

View File

@@ -611,6 +611,7 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
'1',
{ isActive: false },
);
});
@@ -639,6 +640,7 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
{ id: '1', name: 'Item 1' },
{ isActive: false },
);
});
@@ -659,15 +661,15 @@ describe('TanStackTableView Integration', () => {
expect(row).toHaveClass('tableRowActive');
});
it('calls onRowDeactivate when clicking active row', async () => {
it('calls onRowClick with isActive: true when clicking the active row', async () => {
// The consumer owns open/close routing — the table just reports the
// active state via the click context.
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowDeactivate,
isRowActive: (row) => row.id === '1',
},
});
@@ -678,8 +680,11 @@ describe('TanStackTableView Integration', () => {
await user.click(screen.getByText('Item 1'));
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
expect.anything(),
{ isActive: true },
);
});
it('opens in new tab on ctrl+click', async () => {

View File

@@ -116,9 +116,11 @@ export * from './useTableParams';
* getItemKey={(row) => row.id}
* isRowActive={(row) => row.id === selectedId}
* activeRowIndex={selectedIndex}
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
* // The table reports the click + the row's active state; the consumer owns open/close.
* onRowClick={(row, itemKey, { isActive }) =>
* setSelectedId(isActive ? undefined : itemKey)
* }
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
* onRowDeactivate={() => setSelectedId(undefined)}
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
* renderRowActions={(row) => <Button size="small">Open</Button>}

View File

@@ -81,15 +81,23 @@ export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
/**
* State of a row at click time, passed as the third argument to `onRowClick`.
* The consumer owns the open/close decision — e.g. `isActive ? close() : open()`.
*/
export type RowClickContext = {
/** Whether the clicked row is currently active (per `isRowActive`). */
isActive: boolean;
};
export type TableRowContext<TData, TItemKey = string> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
rowKey: string,
@@ -180,10 +188,9 @@ export type TanStackTableProps<TData, TItemKey = string> = {
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,

View File

@@ -65,4 +65,5 @@
min-width: 0;
padding-left: 12px;
padding-bottom: 12px;
padding-top: 8px;
}

View File

@@ -25,20 +25,52 @@ describe('calculateChartDimensions', () => {
});
});
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
it('RIGHT: reserves a side column capped at 320px / 40% of the width and keeps full height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
expect(dims.legendWidth).toBe(240);
expect(dims.width).toBe(760);
expect(dims.legendWidth).toBe(320);
expect(dims.width).toBe(680);
expect(dims.height).toBe(400);
expect(dims.legendHeight).toBe(400);
});
it('RIGHT: sizes the column to the longest label when it fits under the cap', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(5, 20),
});
expect(dims.legendWidth).toBe(216);
expect(dims.width).toBe(784);
});
it('RIGHT: never shrinks the column below the 150px floor', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(3, 3),
});
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(850);
});
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
const dims = calculateChartDimensions({
containerWidth: 300,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(120);
expect(dims.width).toBe(180);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,

View File

@@ -15,6 +15,12 @@ const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 28;
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
const MAX_RIGHT_LEGEND_WIDTH = 320;
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
// Column padding + copy button, not covered by the text-length estimate.
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
/**
* Calculates the average width of the legend items based on the labels of the series.
* @param legends - The labels of the series.
@@ -42,7 +48,7 @@ export function calculateAverageLegendWidth(legends: string[]): number {
* Implementation details (high level):
* - Approximates legend item width from label text length, using a fixed average char width.
* - RIGHT legend:
* - `legendWidth` is clamped between 150px and min(MAX_LEGEND_WIDTH, 30% of container width).
* - `legendWidth` fits the longest label, clamped to [150px, min(MAX_RIGHT_LEGEND_WIDTH, 40% width)].
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
@@ -80,9 +86,22 @@ export function calculateChartDimensions({
const legendItemCount = seriesLabels.length;
if (legendConfig.position === LegendPosition.RIGHT) {
const maxRightLegendWidth = Math.min(MAX_LEGEND_WIDTH, containerWidth * 0.3);
// Size the column to the longest name (up to the cap) so it doesn't ellipsize.
const longestLabelLength = seriesLabels.reduce(
(max, label) => Math.max(max, label.length),
0,
);
const desiredLegendWidth =
BASE_LEGEND_WIDTH +
longestLabelLength * AVG_CHAR_WIDTH +
RIGHT_LEGEND_RESERVED_WIDTH;
const maxRightLegendWidth = Math.min(
MAX_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
);
const rightLegendWidth = Math.min(
Math.max(150, approxLegendItemWidth),
Math.max(150, desiredLegendWidth),
maxRightLegendWidth,
);

View File

@@ -15,6 +15,10 @@
&--legend-right {
flex-direction: row;
.chart-layout__legend-wrapper {
padding-top: 8px;
}
}
&__legend-wrapper {

View File

@@ -60,3 +60,7 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

@@ -204,6 +204,9 @@ function LiveLogsList({
data={formattedLogs}
isLoading={false}
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowClassName={(log): string =>
log.id === activeLogId ? 'logs-linked-row' : ''
}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
@@ -216,10 +219,13 @@ function LiveLogsList({
),
}) as CSSProperties
}
onRowClick={(log): void => {
handleSetActiveLog(log);
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons

View File

@@ -323,3 +323,7 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

@@ -211,8 +211,9 @@ function LogsExplorerList({
data={logs}
isLoading={isLoading || isFetching}
onEndReached={onEndReached}
isRowActive={(log): boolean =>
log.id === activeLog?.id || log.id === activeLogId
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowClassName={(log): string =>
log.id === activeLogId ? 'logs-linked-row' : ''
}
getRowStyle={(log): CSSProperties =>
({
@@ -226,10 +227,13 @@ function LogsExplorerList({
),
}) as CSSProperties
}
onRowClick={(log): void => {
handleSetActiveLog(log);
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
@@ -282,6 +286,7 @@ function LogsExplorerList({
options.maxLines,
options.fontSize,
activeLogIndex,
activeLogId,
logs,
onEndReached,
getItemContent,

View File

@@ -55,6 +55,7 @@ export function useCreateExportDashboard({
layouts: [],
panels: {},
variables: [],
links: [],
},
}),
{

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesDynamicVariableSignalDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
import { Select } from 'antd';
@@ -14,17 +15,16 @@ import { isRetryableError } from 'utils/errorUtils';
import {
DYNAMIC_SIGNAL_LABEL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
signalForApi,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: DynamicSignalOption;
signal: DashboardtypesDynamicVariableSignalDTO;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: DynamicSignalOption;
dynamicSignal?: DashboardtypesDynamicVariableSignalDTO;
}) => void;
onPreview: (values: (string | number)[]) => void;
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
@@ -117,7 +117,9 @@ function DynamicVariableFields({
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
onChange({
dynamicSignal: value as DashboardtypesDynamicVariableSignalDTO,
})
}
data-testid="variable-signal-select"
/>

View File

@@ -13,11 +13,7 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
DYNAMIC_SIGNAL_ALL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
emptyVariableFormModel,
signalForApi,
VARIABLE_SORT_DISABLED,
type VariableFormModel,
} from './variableFormModel';
@@ -69,12 +65,8 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// Unrecognized/empty signal → "all telemetry", so the source always shows.
dynamicSignal: DYNAMIC_SIGNALS.includes(
plugin.spec.signal as DynamicSignalOption,
)
? (plugin.spec.signal as DynamicSignalOption)
: DYNAMIC_SIGNAL_ALL,
// signal is a required wire field (`all` = every telemetry signal), used as-is.
dynamicSignal: plugin.spec.signal,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
@@ -102,7 +94,7 @@ function buildPlugin(
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: signalForApi(model.dynamicSignal),
signal: model.dynamicSignal,
},
};
case 'QUERY':

View File

@@ -1,6 +1,6 @@
import {
DashboardtypesDynamicVariableSignalDTO,
DashboardtypesListVariableSpecSortDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesVariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
@@ -23,23 +23,6 @@ export const VARIABLE_TYPE_EVENT_LABEL: Record<VariableType, string> = {
DYNAMIC: 'dynamic',
};
/** Telemetry signal — the generated enum (traces / logs / metrics). */
// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO
// also carries the empty "any" value used on field keys, which is not a valid
// query/variable signal, so exclude it here.
export type TelemetrySignal =
| TelemetrytypesSignalDTO.logs
| TelemetrytypesSignalDTO.traces
| TelemetrytypesSignalDTO.metrics;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
*/
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
/**
* Sort order for list-variable values, keyed by the generated wire enum so the
* form model and the DTO `sort` field share one source of truth. The friendly
@@ -81,25 +64,38 @@ export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
};
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
DYNAMIC_SIGNAL_ALL,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
export const DYNAMIC_SIGNALS: DashboardtypesDynamicVariableSignalDTO[] = [
DashboardtypesDynamicVariableSignalDTO.all,
DashboardtypesDynamicVariableSignalDTO.traces,
DashboardtypesDynamicVariableSignalDTO.logs,
DashboardtypesDynamicVariableSignalDTO.metrics,
];
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
[TelemetrytypesSignalDTO.traces]: 'Traces',
[TelemetrytypesSignalDTO.logs]: 'Logs',
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
export const DYNAMIC_SIGNAL_LABEL: Record<
DashboardtypesDynamicVariableSignalDTO,
string
> = {
[DashboardtypesDynamicVariableSignalDTO.all]: 'All telemetry',
[DashboardtypesDynamicVariableSignalDTO.traces]: 'Traces',
[DashboardtypesDynamicVariableSignalDTO.logs]: 'Logs',
[DashboardtypesDynamicVariableSignalDTO.metrics]: 'Metrics',
};
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
/**
* Field-keys/values API param. The `all` signal is omitted (that endpoint only
* accepts a concrete signal), everything else passes through.
*/
export function signalForApi(
signal: DynamicSignalOption,
): TelemetrySignal | undefined {
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
signal: DashboardtypesDynamicVariableSignalDTO,
):
| Exclude<
DashboardtypesDynamicVariableSignalDTO,
DashboardtypesDynamicVariableSignalDTO.all
>
| undefined {
return signal === DashboardtypesDynamicVariableSignalDTO.all
? undefined
: signal;
}
type SortableValues = (string | number | boolean)[];
@@ -144,7 +140,7 @@ export interface VariableFormModel {
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
dynamicSignal: DashboardtypesDynamicVariableSignalDTO; // DYNAMIC — the telemetry signal (`all` = every signal)
/**
* Runtime-selected default, not editable in the management tab yet; carried
@@ -166,6 +162,6 @@ export function emptyVariableFormModel(): VariableFormModel {
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: DYNAMIC_SIGNAL_ALL,
dynamicSignal: DashboardtypesDynamicVariableSignalDTO.all,
};
}

View File

@@ -17,8 +17,6 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
[TelemetrytypesSignalDTO.metrics]: 'metrics',
// The empty "any" signal only appears on field keys, never on a panel query;
// mapped for exhaustiveness.
[TelemetrytypesSignalDTO['']]: '',
};

View File

@@ -120,7 +120,7 @@ export const SECTION_REGISTRY: {
[SectionKind.ContextLinks]: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
get: (spec): DashboardtypesLinkDTO[] | undefined => spec.links ?? undefined,
get: (spec): DashboardtypesLinkDTO[] => spec.links,
update: (spec, links): PanelSpec => ({ ...spec, links }),
},
// One editor for every threshold variant (label / comparison / table); the kind's

View File

@@ -49,6 +49,14 @@ describe('newPanelRoute', () => {
return { path, params: new URLSearchParams(search) };
};
// Mirrors useGetCompositeQueryParam: it decodes the param twice.
const readCompositeQuery = (params: URLSearchParams): unknown =>
JSON.parse(
decodeURIComponent(
(params.get('compositeQuery') as string).replace(/\+/g, ' '),
),
);
it.each([
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
[PANEL_TYPES.TABLE, 'signoz/TablePanel'],
@@ -67,16 +75,34 @@ describe('newPanelRoute', () => {
);
});
it('carries the query as a decodable compositeQuery param', () => {
it('carries the query as a compositeQuery param the reader can decode', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
});
const { params } = parseLink(link);
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
query,
);
expect(readCompositeQuery(params)).toStrictEqual(query);
});
// Regression: a bare `%`/`+` must survive the reader's double-decode.
it('round-trips a filter expression containing % and + literals', () => {
const queryWithLiterals = {
id: 'q1',
queryType: 'builder',
builder: {
queryData: [
{ filter: { expression: "severity_text ILIKE 'Inf%' AND path = 'a+b'" } },
],
},
} as unknown as Query;
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.LIST,
query: queryWithLiterals,
});
const { params } = parseLink(link);
expect(readCompositeQuery(params)).toStrictEqual(queryWithLiterals);
});
it('returns null for a panel type with no V2 kind', () => {

View File

@@ -45,9 +45,11 @@ export function parseNewPanelKind(
/**
* New-panel editor link that exports an explorer query into a V2 dashboard. Carries the
* raw `Query` as `compositeQuery` encoded as the V1 link so `useGetCompositeQueryParam`
* reads it identically (conversion happens in the editor). `null` when the panel type has
* no V2 kind, so the caller skips the export instead of landing on an unrelated kind.
* raw `Query` as `compositeQuery` (conversion happens in the editor). `null` when the panel
* type has no V2 kind, so the caller skips the export instead of landing on an unrelated kind.
*
* Double-encoded on purpose: `useGetCompositeQueryParam` decodes twice, so a single encode
* would let a bare `%`/`+` (e.g. `ILIKE 'Inf%'`) break its second decode and drop the query.
*/
export function buildExportPanelLink({
dashboardId,
@@ -68,7 +70,7 @@ export function buildExportPanelLink({
});
return `${path}${newPanelSearch(kind)}&${
QueryParams.compositeQuery
}=${encodeURIComponent(JSON.stringify(query))}`;
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */

View File

@@ -0,0 +1,80 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { countEnabledQueries } from '../countEnabledQueries';
function compositeQuery(
envelopes: { type: string; disabled?: boolean }[],
): DashboardtypesQueryDTO {
return {
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: envelopes.map(({ type, disabled }) => ({
type,
spec: { disabled },
})),
},
},
},
} as unknown as DashboardtypesQueryDTO;
}
function bareBuilderQuery(disabled?: boolean): DashboardtypesQueryDTO {
return {
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs', disabled } },
},
} as unknown as DashboardtypesQueryDTO;
}
describe('countEnabledQueries', () => {
it('returns 0 when there are no queries', () => {
expect(countEnabledQueries([])).toBe(0);
});
it('counts every enabled envelope inside the composite wrapper', () => {
expect(
countEnabledQueries([
compositeQuery([{ type: 'builder_query' }, { type: 'builder_query' }]),
]),
).toBe(2);
});
it('does not count disabled envelopes', () => {
expect(
countEnabledQueries([
compositeQuery([
{ type: 'builder_query' },
{ type: 'builder_query', disabled: true },
]),
]),
).toBe(1);
});
it('counts formula envelopes alongside builder queries', () => {
expect(
countEnabledQueries([
compositeQuery([
{ type: 'builder_query' },
{ type: 'builder_query' },
{ type: 'builder_formula' },
]),
]),
).toBe(3);
});
it('treats an absent disabled flag as enabled', () => {
expect(
countEnabledQueries([compositeQuery([{ type: 'builder_query' }])]),
).toBe(1);
});
it('unwraps a bare builder query (List panel shape)', () => {
expect(countEnabledQueries([bareBuilderQuery()])).toBe(1);
});
it('does not count a disabled bare builder query', () => {
expect(countEnabledQueries([bareBuilderQuery(true)])).toBe(0);
});
});

View File

@@ -0,0 +1,8 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { toQueryEnvelopes } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
/** Counts enabled queries, unwrapping the single `signoz/CompositeQuery` wrapper. */
export function countEnabledQueries(queries: DashboardtypesQueryDTO[]): number {
return toQueryEnvelopes(queries).filter((envelope) => !envelope.spec?.disabled)
.length;
}

View File

@@ -1,4 +1,7 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesDynamicVariableSignalDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
@@ -17,3 +20,23 @@ export function resolveDrilldownSignal(
return TelemetrytypesSignalDTO.metrics;
}
}
/**
* Maps a clicked query's telemetry signal to a dynamic-variable signal (used
* when a drilldown seeds a new variable). Concrete signals map 1:1; an unset
* signal (no active drilldown) falls back to `all`.
*/
export function dynamicSignalFromQuerySignal(
signal?: TelemetrytypesSignalDTO,
): DashboardtypesDynamicVariableSignalDTO {
switch (signal) {
case TelemetrytypesSignalDTO.traces:
return DashboardtypesDynamicVariableSignalDTO.traces;
case TelemetrytypesSignalDTO.logs:
return DashboardtypesDynamicVariableSignalDTO.logs;
case TelemetrytypesSignalDTO.metrics:
return DashboardtypesDynamicVariableSignalDTO.metrics;
default:
return DashboardtypesDynamicVariableSignalDTO.all;
}
}

View File

@@ -15,6 +15,7 @@ import PanelHeaderSearch from './PanelHeaderSearch';
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
import {
panelStatusFromError,
panelStatusFromMultipleEnabledQueries,
panelStatusFromWarning,
} from '../PanelStatus/utils';
import styles from './PanelHeader.module.scss';
@@ -74,11 +75,24 @@ function PanelHeader({
[warning],
);
// Client-derived: warn a Number panel that has more than one enabled query (#9512).
const multiQueryWarningDetail = useMemo(
() => panelStatusFromMultipleEnabledQueries(panel),
[panel],
);
/**
* Hide the entire header when there's no title, description, or status to show,
* and the actions menu is suppressed (editor preview).
*/
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
if (
!name &&
!description &&
!errorDetail &&
!warningDetail &&
!multiQueryWarningDetail &&
hideActions
) {
return <Fragment />;
}
@@ -124,6 +138,13 @@ function PanelHeader({
{warningDetail && (
<PanelStatusPopover variant="warning" detail={warningDetail} />
)}
{multiQueryWarningDetail && (
<PanelStatusPopover
variant="warning"
detail={multiQueryWarningDetail}
testId="panel-status-config-warning"
/>
)}
{/* Renders nothing when no action survives its gates (kind/role/context). */}
{!hideActions && (
<PanelActionsMenu

View File

@@ -17,6 +17,8 @@ const VARIANT_CONFIG: Record<
interface PanelStatusPopoverProps {
variant: PanelStatusVariant;
detail: PanelStatusDetail;
/** Overrides the trigger's test id; defaults to `panel-status-<variant>`. */
testId?: string;
}
/**
@@ -26,6 +28,7 @@ interface PanelStatusPopoverProps {
function PanelStatusPopover({
variant,
detail,
testId,
}: PanelStatusPopoverProps): JSX.Element {
const { color, ariaLabel } = VARIANT_CONFIG[variant];
const Icon = variant === 'error' ? CircleX : TriangleAlert;
@@ -41,7 +44,7 @@ function PanelStatusPopover({
<span
className={styles.trigger}
aria-label={ariaLabel}
data-testid={`panel-status-${variant}`}
data-testid={testId ?? `panel-status-${variant}`}
>
<Icon size={16} color={color} />
</span>

View File

@@ -1,9 +1,16 @@
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { StatusCodes } from 'http-status-codes';
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
import {
panelStatusFromError,
panelStatusFromMultipleEnabledQueries,
panelStatusFromWarning,
} from '../utils';
// The query layer rejects with the raw AxiosError from the generated client
// (it is not pre-converted to APIError), so the tests mirror that wire shape.
@@ -87,3 +94,62 @@ describe('panelStatusFromWarning', () => {
});
});
});
function panel(
kind: string,
envelopes: { disabled?: boolean }[],
): DashboardtypesPanelDTO {
return {
spec: {
plugin: { kind, spec: {} },
queries: [
{
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: envelopes.map(({ disabled }) => ({
type: 'builder_query',
spec: { disabled },
})),
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('panelStatusFromMultipleEnabledQueries', () => {
it('warns when a Number panel has more than one enabled query', () => {
const detail = panelStatusFromMultipleEnabledQueries(
panel('signoz/NumberPanel', [{}, {}]),
);
expect(detail).not.toBeNull();
expect(detail?.message).toMatch(/single value/i);
expect(detail?.messages).toHaveLength(1);
});
it('counts only enabled queries (a disabled second query is fine)', () => {
expect(
panelStatusFromMultipleEnabledQueries(
panel('signoz/NumberPanel', [{}, { disabled: true }]),
),
).toBeNull();
});
it('does not warn when a Number panel has a single enabled query', () => {
expect(
panelStatusFromMultipleEnabledQueries(panel('signoz/NumberPanel', [{}])),
).toBeNull();
});
it('does not warn for other panel kinds even with multiple enabled queries', () => {
expect(
panelStatusFromMultipleEnabledQueries(
panel('signoz/TimeSeriesPanel', [{}, {}]),
),
).toBeNull();
});
});

View File

@@ -1,7 +1,11 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { countEnabledQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/countEnabledQueries';
import type { PanelStatusDetail } from './types';
@@ -52,3 +56,25 @@ export function panelStatusFromWarning(
.filter((message): message is string => Boolean(message)),
};
}
/**
* A Number panel renders only the first query's value, so more than one enabled
* query silently hides the rest. Warns for that case; null otherwise.
*/
export function panelStatusFromMultipleEnabledQueries(
panel: DashboardtypesPanelDTO,
): PanelStatusDetail | null {
if (panel.spec.plugin.kind !== 'signoz/NumberPanel') {
return null;
}
if (countEnabledQueries(panel.spec.queries) <= 1) {
return null;
}
return {
message:
'This panel shows a single value, but more than one query is enabled.',
messages: [
"Disable the queries you don't want to display, keeping only the one whose value you want to show.",
],
};
}

View File

@@ -41,6 +41,40 @@ function makePanel(overrides?: {
} as unknown as DashboardtypesPanelDTO;
}
// Blank name so the editor-preview guard test relies on the warning alone.
function makePanelWithQueries(
kind: string,
enabled: number,
disabled = 0,
): DashboardtypesPanelDTO {
const envelope = (isDisabled: boolean): unknown => ({
type: 'builder_query',
spec: { disabled: isDisabled },
});
return {
kind: 'Panel',
spec: {
display: { name: '' },
plugin: { kind, spec: {} },
queries: [
{
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
...Array.from({ length: enabled }, () => envelope(false)),
...Array.from({ length: disabled }, () => envelope(true)),
],
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
panel: makePanel(),
panelId: 'panel-1',
@@ -101,6 +135,53 @@ describe('PanelHeader status indicators', () => {
});
});
describe('PanelHeader multi-query config warning (issue #9512)', () => {
it('warns when a Number panel has more than one enabled query', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 2)}
/>,
);
expect(screen.getByTestId('panel-status-config-warning')).toBeInTheDocument();
});
it('does not warn when only one query is enabled (others disabled)', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 1, 2)}
/>,
);
expect(
screen.queryByTestId('panel-status-config-warning'),
).not.toBeInTheDocument();
});
it('does not warn for non-Number panels with multiple enabled queries', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/TimeSeriesPanel', 3)}
/>,
);
expect(
screen.queryByTestId('panel-status-config-warning'),
).not.toBeInTheDocument();
});
it('shows the warning in the editor preview (hideActions, no title)', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 2)}
hideActions
/>,
);
expect(screen.getByTestId('panel-status-config-warning')).toBeInTheDocument();
});
});
describe('PanelHeader search', () => {
it('renders no search affordance when the panel is not searchable', () => {
renderWithProvider(<PanelHeader {...baseProps} />);

View File

@@ -1,6 +1,6 @@
import { render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesDynamicVariableSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
@@ -63,7 +63,6 @@ jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel',
() => ({
emptyVariableFormModel: (): unknown => ({}),
DYNAMIC_SIGNAL_ALL: 'all',
}),
);
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
@@ -84,7 +83,7 @@ function renderItems(): void {
const { result } = renderHook(() =>
useDrilldownDashboardVariables({
filters,
signal: TelemetrytypesSignalDTO.metrics,
signal: DashboardtypesDynamicVariableSignalDTO.metrics,
onClose: jest.fn(),
}),
);

View File

@@ -19,6 +19,7 @@ import { buildAggregateData } from 'pages/DashboardPageV2/DashboardContainer/Pan
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { dynamicSignalFromQuerySignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/signal';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { EQueryType } from 'types/common/dashboard';
@@ -163,7 +164,7 @@ export function useDrilldown(
const dashboardVariables = useDrilldownDashboardVariables({
filters: context?.filters ?? EMPTY_FILTERS,
signal: context?.signal,
signal: dynamicSignalFromQuerySignal(context?.signal),
onClose: handleClose,
});
@@ -221,7 +222,7 @@ export function useDrilldown(
context={context}
query={v1Query}
isResolving={isResolving}
links={panel.spec.links ?? undefined}
links={panel.spec.links}
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
onViewLogs={(): void => navigate('view_logs')}
onViewTraces={(): void => navigate('view_traces')}

View File

@@ -1,14 +1,13 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesDynamicVariableSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import {
dtoToFormModel,
formModelToDto,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
emptyVariableFormModel,
type VariableFormModel,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel';
@@ -23,8 +22,8 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
interface UseDrilldownDashboardVariablesArgs {
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
filters: FilterData[];
/** Clicked query's telemetry signal — seeds a created variable's `dynamicSignal`. */
signal?: TelemetrytypesSignalDTO;
/** Dynamic-variable signal derived from the clicked query — seeds a created variable's `dynamicSignal`. */
signal?: DashboardtypesDynamicVariableSignalDTO;
onClose: () => void;
}
@@ -111,8 +110,7 @@ export function useDrilldownDashboardVariables({
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
dynamicSignal: signal ?? DashboardtypesDynamicVariableSignalDTO.all,
};
try {
await patchAsync(

View File

@@ -1,5 +1,8 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesDynamicVariableSignalDTO,
type DashboardtypesGettableDashboardV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import type {
IDashboardVariable,
@@ -8,7 +11,6 @@ import type {
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
type VariableFormModel,
type VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
@@ -35,7 +37,7 @@ function toV1Variable(model: VariableFormModel): IDashboardVariable {
showALLOption: model.showAllOption,
dynamicVariablesAttribute: model.dynamicAttribute,
dynamicVariablesSource:
model.dynamicSignal === DYNAMIC_SIGNAL_ALL
model.dynamicSignal === DashboardtypesDynamicVariableSignalDTO.all
? 'all sources'
: model.dynamicSignal,
};

View File

@@ -49,6 +49,7 @@ export function createDefaultPanel(
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
links: [],
},
};
}

View File

@@ -62,6 +62,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
layouts: [],
panels: {},
variables: [],
links: [],
},
});
void logEvent(DashboardListEvents.DashboardCreated, {

View File

@@ -54,10 +54,12 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
},
},
},
Links: []Link{},
},
},
},
Layouts: []Layout{},
Links: []Link{},
}
return &DashboardV2{

View File

@@ -26,7 +26,7 @@ type DashboardSpec struct {
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval"`
Links []Link `json:"links,omitzero"`
Links []Link `json:"links" required:"true" nullable:"false"`
}
// ══════════════════════════════════════════════
@@ -45,6 +45,16 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
return d.Validate()
}
// validateLinks rejects a missing/null spec.links value: a typed client must
// send [] rather than omitting links, so its value round-trips faithfully.
// Panel links are the panel spec's concern, validated in validatePanels.
func (d *DashboardSpec) validateLinks() error {
if d.Links == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.links is required; send [] when there are no links")
}
return nil
}
// ══════════════════════════════════════════════
// Cross-field validation
// ══════════════════════════════════════════════
@@ -53,6 +63,9 @@ func (d *DashboardSpec) Validate() error {
if err := d.Display.Validate("dashboard", "spec.display.name"); err != nil {
return err
}
if err := d.validateLinks(); err != nil {
return err
}
if err := d.validateVariables(); err != nil {
return err
}
@@ -104,6 +117,9 @@ func (d *DashboardSpec) validatePanels() error {
if err := panel.Spec.Display.Validate("panel", path+".spec.display.name"); err != nil {
return err
}
if err := panel.Spec.validateLinks(path); err != nil {
return err
}
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) != 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)

View File

@@ -23,6 +23,7 @@ const basePostableJSON = `{
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
"spec": {
"display": {"name": "Service overview"},
"links": [],
"variables": [
{
"kind": "ListVariable",
@@ -41,6 +42,7 @@ const basePostableJSON = `{
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -64,6 +66,7 @@ const basePostableJSON = `{
"p2": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
@@ -182,6 +185,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -216,6 +220,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -327,7 +332,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
// Appending needs a not-yet-placed panel, so add one in the same patch;
// re-placing p1 or p2 would be a duplicate reference.
out, err := decode(t, `[
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"links": [], "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
]`).Apply(base)
require.NoError(t, err)
@@ -346,6 +351,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -496,7 +502,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
"spec": {"links": [], "plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
@@ -512,6 +518,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
@@ -537,6 +544,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {

View File

@@ -51,10 +51,12 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"links": [],
"layouts": []
}`)
@@ -76,7 +78,7 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
data := []byte(`{"links": []}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
}
@@ -107,6 +109,7 @@ func TestValidateOnlyVariables(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -133,6 +136,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -157,6 +161,7 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
}
@@ -186,6 +191,7 @@ func TestInvalidatePanelKey(t *testing.T) {
"bad key!": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -196,6 +202,7 @@ func TestInvalidatePanelKey(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -219,6 +226,7 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
}
@@ -282,6 +290,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
cases := map[string][]byte{
"text variable": []byte(`{
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
"links": [],
"layouts": []
}`),
"list variable": []byte(`{
@@ -294,6 +303,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
}
}],
"links": [],
"layouts": []
}`),
}
@@ -319,10 +329,12 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "NonExistentPanel",
@@ -338,6 +350,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown panel kind",
@@ -349,6 +362,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -359,6 +373,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "FakeQueryPlugin",
@@ -370,6 +385,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
@@ -380,6 +396,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown request type",
@@ -391,6 +408,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "",
@@ -401,6 +419,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown request type",
@@ -417,6 +436,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"plugin": {"kind": "FakeVariable", "spec": {}}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "FakeVariable",
@@ -430,6 +450,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"plugin": {"kind": "FakeDatasource", "spec": {}}
}
},
"links": [],
"layouts": []
}`,
wantContain: "FakeDatasource",
@@ -450,13 +471,14 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
"panels": {
"good": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
"spec": {"links": [],"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
},
"bad": {
"kind": "Panel",
"spec": {"plugin": {"kind": "FakePanel", "spec": {}}}
"spec": {"links": [],"plugin": {"kind": "FakePanel", "spec": {}}}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -469,6 +491,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -480,7 +503,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
}
}`
layout := func(items string) []byte {
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
return []byte(`{` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
}
tests := []struct {
@@ -536,6 +559,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"bogusField": true}
@@ -543,6 +567,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "bogusField",
@@ -554,6 +579,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -567,6 +593,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknownThing",
@@ -586,6 +613,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "extraField",
@@ -614,6 +642,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"visualization": {"fillSpans": "notabool"}}
@@ -621,6 +650,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "fillSpans",
@@ -632,6 +662,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -645,6 +676,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "",
@@ -664,6 +696,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "",
@@ -694,6 +727,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -710,6 +744,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "signal",
@@ -721,6 +756,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"lineInterpolation": "cubic"}}
@@ -728,6 +764,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "line interpolation",
@@ -739,6 +776,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"lineStyle": "dotted"}}
@@ -746,6 +784,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "line style",
@@ -757,6 +796,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"fillMode": "striped"}}
@@ -764,6 +804,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "fill mode",
@@ -775,6 +816,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"spanGaps": {"fillOnlyBelow": true, "fillLessThan": "notaduration"}}}
@@ -782,6 +824,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "duration",
@@ -793,6 +836,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"visualization": {"timePreference": "last2Hr"}}
@@ -800,6 +844,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "timePreference",
@@ -811,6 +856,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"position": "top"}}
@@ -818,6 +864,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "legend position",
@@ -829,6 +876,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"mode": "grid"}}
@@ -836,6 +884,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "legend mode",
@@ -847,6 +896,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": "above", "color": "Red", "format": "Color"}]}
@@ -854,6 +904,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "threshold format",
@@ -865,6 +916,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": "!=", "color": "Red", "format": "text"}]}
@@ -872,6 +924,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "comparison operator",
@@ -883,6 +936,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"formatting": {"decimalPrecision": "9"}}
@@ -890,6 +944,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "precision",
@@ -921,11 +976,13 @@ func TestThresholdLabelOptional(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -943,9 +1000,10 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
"panels": {
"p1": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
"spec": {"links": [],"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -959,11 +1017,13 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": []
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -979,6 +1039,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}}},
@@ -987,6 +1048,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -1006,6 +1068,7 @@ func TestValidateRequiredFields(t *testing.T) {
"plugin": {"kind": "` + pluginKind + `", "spec": ` + pluginSpec + `}
}
}],
"links": [],
"layouts": []
}`
}
@@ -1015,10 +1078,12 @@ func TestValidateRequiredFields(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": ` + panelSpec + `}
}
}
},
"links": [],
"layouts": []
}`
}
@@ -1083,6 +1148,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -1091,6 +1157,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -1133,6 +1200,7 @@ func TestNumberPanelDefaults(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
@@ -1141,6 +1209,7 @@ func TestNumberPanelDefaults(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -1194,6 +1263,7 @@ func TestStorageRoundTrip(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -1204,6 +1274,7 @@ func TestStorageRoundTrip(t *testing.T) {
"p2": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
@@ -1212,6 +1283,7 @@ func TestStorageRoundTrip(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
@@ -1272,7 +1344,7 @@ func TestStorageRoundTrip(t *testing.T) {
}
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
const validSpec = `"spec": {"panels": {}, "layouts": []}`
const validSpec = `"spec": {"panels": {}, "layouts": [], "links": []}`
tests := []struct {
scenario string
@@ -1284,13 +1356,13 @@ func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
}{
{
scenario: "flag true with display.name derives name on conversion",
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[],"links":[]}}`,
wantName: "",
wantDisplay: "My Dashboard!",
},
{
scenario: "flag true with non-empty name is rejected",
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[],"links":[]}}`,
wantErr: true,
wantErrMatch: "name must be empty when generateName is true",
},
@@ -1450,20 +1522,24 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "` + requestKind(panelKind) + `", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
}}},
"links": [],
"layouts": []
}`)
}
mkComposite := func(panelKind, subType, subSpec string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "` + requestKind(panelKind) + `", "spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {
"queries": [{"type": "` + subType + `", "spec": ` + subSpec + `}]
}}}}]
}}},
"links": [],
"layouts": []
}`)
}
@@ -1507,11 +1583,13 @@ func TestCommaSeparatedAggregationRejectedOnWrite(t *testing.T) {
buildDashboardWithLogsAggregation := func(aggregationsJSON string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "logs", "aggregations": ` + aggregationsJSON + `
}}}}]
}}},
"links": [],
"layouts": []
}`)
}
@@ -1625,9 +1703,10 @@ func TestValidateGridItemLimit(t *testing.T) {
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
@@ -1644,8 +1723,9 @@ func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
@@ -1675,31 +1755,31 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
}{
{
scenario: "dashboard display name",
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "links": [], "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [],"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
dashboardJSON: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
@@ -1719,7 +1799,7 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
// A display name at exactly the limit is accepted.
func TestValidateDisplayNameAtMaxLength(t *testing.T) {
atLimit := strings.Repeat("x", MaxDisplayNameLen)
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "layouts": []}`))
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
assert.NoError(t, err)
}

View File

@@ -228,7 +228,7 @@ func TestRedactVariableQueries(t *testing.T) {
t.Run("leaves non-query variables untouched", func(t *testing.T) {
spec := DashboardSpec{Variables: []Variable{
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "signal", Plugin: VariablePlugin{Kind: VariableKindDynamic, Spec: &DynamicVariableSpec{Name: "service.name", Signal: telemetrytypes.SignalTraces}}}},
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "signal", Plugin: VariablePlugin{Kind: VariableKindDynamic, Spec: &DynamicVariableSpec{Name: "service.name", Signal: DynamicVariableSignalTraces}}}},
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "env", Plugin: VariablePlugin{Kind: VariableKindCustom, Spec: &CustomVariableSpec{CustomValue: "prod,staging"}}}},
}}

View File

@@ -78,7 +78,17 @@ type PanelSpec struct {
Display Display `json:"display" required:"true"`
Plugin PanelPlugin `json:"plugin" required:"true"`
Queries []Query `json:"queries" required:"true" nullable:"false"`
Links []Link `json:"links,omitzero"`
Links []Link `json:"links" required:"true" nullable:"false"`
}
// validateLinks rejects a missing/null links field, where path is the panel's
// location (e.g. "spec.panels.<key>"). A typed client must send [] rather than
// omitting links, so its value round-trips faithfully.
func (s *PanelSpec) validateLinks(path string) error {
if s.Links == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.links is required; send [] when there are no links", path)
}
return nil
}
// Link replicates dashboard.Link (Perses) so its zero-valued fields survive the

View File

@@ -32,7 +32,49 @@ type DynamicVariableSpec struct {
// Name is the name of the attribute being fetched dynamically from the
// signal. This could be extended to a richer selector in the future.
Name string `json:"name" validate:"required" required:"true"`
Signal telemetrytypes.Signal `json:"signal"`
Signal DynamicVariableSignal `json:"signal" required:"true" nullable:"false"`
}
// DynamicVariableSignal is the telemetry signal a dynamic variable draws its
// values from. Separate from telemetrytypes.Signal because it carries "all"
// (values span every signal) rather than "" for an unpinned query signal.
type DynamicVariableSignal struct{ valuer.String }
var (
DynamicVariableSignalTraces = DynamicVariableSignal{valuer.NewString("traces")}
DynamicVariableSignalLogs = DynamicVariableSignal{valuer.NewString("logs")}
DynamicVariableSignalMetrics = DynamicVariableSignal{valuer.NewString("metrics")}
DynamicVariableSignalAll = DynamicVariableSignal{valuer.NewString("all")} // default
)
func (DynamicVariableSignal) Enum() []any {
return []any{DynamicVariableSignalTraces, DynamicVariableSignalLogs, DynamicVariableSignalMetrics, DynamicVariableSignalAll}
}
func (s DynamicVariableSignal) ValueOrDefault() string {
if s.IsZero() {
return DynamicVariableSignalAll.StringValue()
}
return s.StringValue()
}
func (s DynamicVariableSignal) MarshalJSON() ([]byte, error) {
return json.Marshal(s.ValueOrDefault())
}
func (s *DynamicVariableSignal) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid signal: must be a string, one of `traces`, `logs`, `metrics`, or `all`")
}
sig := DynamicVariableSignal{valuer.NewString(v)}
switch sig {
case DynamicVariableSignalTraces, DynamicVariableSignalLogs, DynamicVariableSignalMetrics, DynamicVariableSignalAll:
*s = sig
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid signal %q: must be `traces`, `logs`, `metrics`, or `all`", v)
}
}
type QueryVariableSpec struct {

View File

@@ -80,6 +80,7 @@
}
}
],
"links": [],
"panels": {
"24e2697b": {
"kind": "Panel",
@@ -167,6 +168,7 @@
"ff2f72f1": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "fraction of calls",
"description": ""
@@ -253,6 +255,7 @@
"011605e7": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "total resp size"
},
@@ -304,6 +307,7 @@
"e23516fc": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num traces for service"
},
@@ -359,6 +363,7 @@
"130c8d6b": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num logs for service"
},
@@ -398,6 +403,7 @@
"246f7c6d": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num traces for service per resp code"
},
@@ -451,6 +457,7 @@
"21f7d4d0": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "average latency per service"
},
@@ -493,6 +500,7 @@
"ad5fd556": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "logs from service"
},
@@ -557,6 +565,7 @@
"f07b59ee": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "response size buckets"
},
@@ -596,6 +605,7 @@
"e1a41831": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "trace operator",
"description": ""
@@ -672,6 +682,7 @@
"f0d70491": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "no results in this promql",
"description": ""
@@ -713,6 +724,7 @@
"0e6eb4ca": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "no results in this promql",
"description": ""

View File

@@ -12,10 +12,12 @@
}
}
},
"links": [],
"panels": {
"b424e23b": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": ""
},
@@ -58,6 +60,7 @@
"251df4d5": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": ""
},

View File

@@ -113,7 +113,7 @@ def test_create_rejects_unknown_field(
json={
"schemaVersion": "v6",
"name": "rejects-unknown",
"spec": {"display": {"name": "Rejects Unknown"}},
"spec": {"display": {"name": "Rejects Unknown"}, "links": []},
"tags": [],
"unknownfield": "boom",
},
@@ -209,6 +209,7 @@ def test_create_rejects_invalid_grid_layout(
"kind": "Panel",
"spec": {
"display": {"name": name},
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [
{
@@ -249,6 +250,7 @@ def test_create_rejects_invalid_grid_layout(
},
}
],
"links": [],
},
"tags": [],
},
@@ -280,6 +282,7 @@ def test_create_rejects_invalid_grid_layout(
},
}
],
"links": [],
},
"tags": [],
},
@@ -305,6 +308,7 @@ def test_create_rejects_invalid_grid_layout(
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
}
],
"links": [],
},
"tags": [],
},
@@ -410,7 +414,7 @@ def test_update_missing_dashboard_returns_not_found(
json={
"schemaVersion": "v6",
"name": "missing-dashboard",
"spec": {"display": {"name": "Missing Dashboard"}},
"spec": {"display": {"name": "Missing Dashboard"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -535,7 +539,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"spec": {"display": {"name": display}, "links": []},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
@@ -897,7 +901,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
"spec": {"display": {"name": "Alpha Overview"}},
"spec": {"display": {"name": "Alpha Overview"}, "links": []},
"tags": [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
@@ -941,7 +945,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
beta_body = {
"schemaVersion": "v6",
"name": "lc-beta",
"spec": {"display": {"name": "Beta Overview"}},
"spec": {"display": {"name": "Beta Overview"}, "links": []},
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
}
response = requests.put(
@@ -1045,7 +1049,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": created_order},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1071,7 +1075,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": reordered},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1100,7 +1104,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": new_order},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1137,7 +1141,7 @@ def test_dashboard_v2_pin_limit(
json={
"schemaVersion": "v6",
"name": f"pl-{i}",
"spec": {"display": {"name": f"Pin Limit {i}"}},
"spec": {"display": {"name": f"Pin Limit {i}"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1235,7 +1239,7 @@ def test_dashboard_v2_like_escaping(
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"spec": {"display": {"name": display}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1326,11 +1330,13 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-builder",
"spec": {
"display": {"name": "by-metric-builder"},
"links": [],
"panels": {
"p-builder": {
"kind": "Panel",
"spec": {
"display": {"name": "D1 builder target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1375,11 +1381,13 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-ch-promql",
"spec": {
"display": {"name": "by-metric-ch-promql"},
"links": [],
"panels": {
"p-ch": {
"kind": "Panel",
"spec": {
"display": {"name": "D2 clickhouse target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1401,6 +1409,7 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": "D2 promql target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1437,11 +1446,13 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-promql",
"spec": {
"display": {"name": "by-metric-promql"},
"links": [],
"panels": {
"p-promql": {
"kind": "Panel",
"spec": {
"display": {"name": "D3 promql target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1480,11 +1491,13 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-false-positive",
"spec": {
"display": {"name": "by-metric-false-positive"},
"links": [],
"panels": {
"p-builder": {
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} builder"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1513,6 +1526,7 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} clickhouse"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1534,6 +1548,7 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} promql"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1607,11 +1622,13 @@ def test_dashboard_v2_rejects_comma_separated_aggregation(
"tags": [],
"spec": {
"display": {"name": "Aggregation"},
"links": [],
"panels": {
"p-agg": {
"kind": "Panel",
"spec": {
"display": {"name": "agg"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1763,6 +1780,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "timeseries"},
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"thresholds": [{"value": 0, "color": "#c2780b"}]},
@@ -1785,6 +1803,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "promql"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1804,6 +1823,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "clickhouse"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1899,11 +1919,9 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
for description, spec, key in absent_cases:
assert key not in spec, description
# A panel with no links comes back with no links value (null or absent);
# either is fine for a typed client (an unset optional attribute stays
# unset), so this is not a drift. The explicit [] case above is what the
# fix guarantees round-trips.
assert panels["timeseries"]["spec"].get("links") is None, "unset panel links stays unset"
# links is a required, non-nullable field: an explicit [] round-trips as [],
# so a typed client always reads a concrete array (never null or absent).
assert panels["timeseries"]["spec"]["links"] == [], "panel links round-trip as []"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
@@ -1955,6 +1973,7 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
},
}
],
"links": [],
},
},
"num": {
@@ -1976,9 +1995,11 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
},
}
],
"links": [],
},
},
},
"links": [],
},
}

View File

@@ -59,6 +59,7 @@ def test_public_dashboard_v2(
"spec": {
"display": {"name": "Sample Dashboard", "description": "Used for integration tests"},
"duration": "1h",
"links": [],
"variables": [
{
"kind": "ListVariable",
@@ -77,6 +78,7 @@ def test_public_dashboard_v2(
"kind": "Panel",
"spec": {
"display": {"name": "total"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"visualization": {"fillSpans": True}}},
"queries": [
{