mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-25 15:30:33 +01:00
Compare commits
5 Commits
b3-search
...
fix/tansta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a63bc71c6b | ||
|
|
056e48a265 | ||
|
|
25cc65ace2 | ||
|
|
38340b5027 | ||
|
|
fba56e9a64 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, "'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,4 +65,5 @@
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
|
||||
.chart-layout__legend-wrapper {
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__legend-wrapper {
|
||||
|
||||
@@ -60,3 +60,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -323,3 +323,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,6 +55,7 @@ export function useCreateExportDashboard({
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
links: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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['']]: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ export function createDefaultPanel(
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
links: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
links: [],
|
||||
},
|
||||
});
|
||||
void logEvent(DashboardListEvents.DashboardCreated, {
|
||||
|
||||
@@ -54,10 +54,12 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
|
||||
},
|
||||
},
|
||||
},
|
||||
Links: []Link{},
|
||||
},
|
||||
},
|
||||
},
|
||||
Layouts: []Layout{},
|
||||
Links: []Link{},
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}}}},
|
||||
}}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
12
pkg/types/dashboardtypes/testdata/perses.json
vendored
12
pkg/types/dashboardtypes/testdata/perses.json
vendored
@@ -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": ""
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"panels": {
|
||||
"b424e23b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
@@ -58,6 +60,7 @@
|
||||
"251df4d5": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
|
||||
@@ -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": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user