Compare commits

..

12 Commits

Author SHA1 Message Date
swapnil-signoz
22833ea8fe Merge branch 'issue_5721' of https://github.com/SigNoz/signoz into issue_5721 2026-07-22 17:30:14 +05:30
swapnil-signoz
8eb0a24492 fix: correct typo and unit in compute engine dashboard 2026-07-22 17:29:22 +05:30
swapnil-signoz
7e0b19800c Merge branch 'main' into issue_5721 2026-07-22 17:13:37 +05:30
Swapnil Nakade
3bc476c2ed Merge branch 'main' into issue_5721 2026-07-22 16:32:23 +05:30
swapnil-signoz
6a834ee944 Merge branch 'main' into issue_5721 2026-07-22 12:57:39 +05:30
swapnil-signoz
ba6a78aea7 refactor: updating dashboard panels to use rate aggregation 2026-07-22 10:38:46 +05:30
swapnil-signoz
ca98231b18 feat: adding compute engine service 2026-07-22 10:10:59 +05:30
swapnil-signoz
1644ecd6fc refactor: updating dashboard panel to use rate function instead of hack 2026-07-22 01:32:13 +05:30
swapnil-signoz
f1e2e9f4f7 refactor: updating cpu utilization panel 2026-07-19 22:25:47 +05:30
swapnil-signoz
a59e372f07 refactor: extending width of uptime gauge panel 2026-07-14 22:59:52 +05:30
swapnil-signoz
ca368a5b38 refactor: updating dashboard title 2026-07-14 22:51:25 +05:30
swapnil-signoz
dab5ceee0d feat: adding gcp memorystore redis service 2026-07-14 22:40:31 +05:30
87 changed files with 3709 additions and 2382 deletions

View File

@@ -1496,6 +1496,7 @@ components:
- redis
- cloudsql_postgres
- memorystore_redis
- computeengine
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -2748,6 +2749,7 @@ components:
links:
items:
$ref: '#/components/schemas/DashboardtypesLink'
nullable: true
type: array
panels:
additionalProperties:
@@ -2764,7 +2766,6 @@ components:
- variables
- panels
- layouts
- links
type: object
DashboardtypesDashboardView:
properties:
@@ -2843,22 +2844,14 @@ components:
required:
- name
type: object
DashboardtypesDynamicVariableSignal:
enum:
- traces
- logs
- metrics
- all
type: string
DashboardtypesDynamicVariableSpec:
properties:
name:
type: string
signal:
$ref: '#/components/schemas/DashboardtypesDynamicVariableSignal'
$ref: '#/components/schemas/TelemetrytypesSignal'
required:
- name
- signal
type: object
DashboardtypesFillMode:
enum:
@@ -3401,6 +3394,7 @@ components:
links:
items:
$ref: '#/components/schemas/DashboardtypesLink'
nullable: true
type: array
plugin:
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
@@ -3412,7 +3406,6 @@ components:
- display
- plugin
- queries
- links
type: object
DashboardtypesPatchOp:
enum:

View File

@@ -2815,6 +2815,7 @@ export enum CloudintegrationtypesServiceIDDTO {
redis = 'redis',
cloudsql_postgres = 'cloudsql_postgres',
memorystore_redis = 'memorystore_redis',
computeengine = 'computeengine',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -4626,9 +4627,9 @@ export interface DashboardtypesQueryDTO {
export interface DashboardtypesPanelSpecDTO {
display: DashboardtypesDisplayDTO;
/**
* @type array
* @type array,null
*/
links: DashboardtypesLinkDTO[];
links?: DashboardtypesLinkDTO[] | null;
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array
@@ -4668,18 +4669,12 @@ 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: DashboardtypesDynamicVariableSignalDTO;
signal?: TelemetrytypesSignalDTO;
}
export interface DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTO {
@@ -4821,9 +4816,9 @@ export interface DashboardtypesDashboardSpecDTO {
*/
layouts: DashboardtypesLayoutDTO[];
/**
* @type array
* @type array,null
*/
links: DashboardtypesLinkDTO[];
links?: DashboardtypesLinkDTO[] | null;
/**
* @type object
*/

View File

@@ -52,8 +52,6 @@ import type { SignalType } from 'types/api/v5/queryRange';
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
getFieldContextPrefix,
getRecentOptions,
renderRecentDeleteButton,
} from './utils';
@@ -224,12 +222,6 @@ function QuerySearch({
QueryKeyDataSuggestionsProps[] | null
>(null);
// dedupe keySuggestions by label/name
const dedupedKeySuggestions = useMemo(
() => dedupeOptionsByLabel(keySuggestions || []),
[keySuggestions],
);
const [showExamples] = useState(false);
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
@@ -254,11 +246,9 @@ function QuerySearch({
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
items.map(({ name, fieldDataType }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
fieldContext,
fieldDataType,
info: '',
details: '',
})),
@@ -317,17 +307,13 @@ function QuerySearch({
if (response.data.data) {
const { keys } = response.data.data;
const options = generateOptions(keys);
// Deduplicate by full variant identity (name + context + data type), NOT by
// label. deduping by label removes varient which is not expected. If we need
// to dedupe by label use dedupedKeySuggestions not dedupe the source itself
const variantId = (opt: QueryKeyDataSuggestionsProps): string =>
`${opt.label}|${opt.fieldContext ?? ''}|${opt.fieldDataType ?? ''}`;
// Use a Map to deduplicate by label and preserve order: new options take precedence
const merged = new Map<string, QueryKeyDataSuggestionsProps>();
options.forEach((opt) => merged.set(variantId(opt), opt));
options.forEach((opt) => merged.set(opt.label, opt));
if (searchText && lastKeyRef.current !== searchText) {
(keySuggestions || []).forEach((opt) => {
if (!merged.has(variantId(opt))) {
merged.set(variantId(opt), opt);
if (!merged.has(opt.label)) {
merged.set(opt.label, opt);
}
});
}
@@ -933,55 +919,8 @@ function QuerySearch({
if (queryContext.isInKey) {
const searchText = word?.text.toLowerCase().trim() ?? '';
const fieldContextMatch = getFieldContextPrefix(searchText);
if (fieldContextMatch) {
const { context: fieldContext, remainder } = fieldContextMatch;
// Fetch the context's page when the prefix is typed exactly eg.("attribute.")
if (remainder === '' && lastFetchedKeyRef.current !== searchText) {
debouncedFetchKeySuggestions(searchText);
}
//suggestions that actually do start with <fieldContext>.
const nameMatches = (keySuggestions || [])
.filter((option) => option.label.toLowerCase().includes(searchText))
.map((option) => ({ ...option, boost: 100 }));
//suggestions which do not start with the prefix but qualifies for suggestion
const contextQualified = (keySuggestions || [])
.filter(
(option) =>
option.fieldContext === fieldContext &&
option.label.toLowerCase().includes(remainder),
)
.map((option) => ({
...option,
label: `${fieldContext}.${option.label}`,
boost: 0,
}));
const contextOptions = dedupeOptionsByLabel([
...nameMatches,
...contextQualified,
]);
// If contextOptions is empty fetch again.
if (
contextOptions.length === 0 &&
lastFetchedKeyRef.current !== searchText
) {
debouncedFetchKeySuggestions(searchText);
}
return {
from: word?.from ?? 0,
to: word?.to ?? cursorPos.ch,
options: addSpaceToOptions(contextOptions),
};
}
options = dedupedKeySuggestions.filter((option) =>
options = (keySuggestions || []).filter((option) =>
option.label.toLowerCase().includes(searchText),
);
@@ -1030,26 +969,9 @@ function QuerySearch({
// If we have a key context, add that info to the operator suggestions
if (keyName) {
const keyContextMatch = getFieldContextPrefix(keyName);
// key-suggestion can contain multiple variants of a single key
// In variants we capture ones that match the label to typed keyName exactly or,
// if it has a prefix fieldContext remove it and then match.
const variants = (keySuggestions || []).filter(
(k) =>
k.label === keyName ||
(keyContextMatch !== null &&
k.fieldContext === keyContextMatch.context &&
k.label === keyContextMatch.remainder),
);
const variantTypes = new Set(
variants
.map((k) =>
k.type === 'keyword' ? QUERY_BUILDER_KEY_TYPES.STRING : k.type,
)
.filter(Boolean),
);
//if there are multi-variant, show all suggestions else just the one
const keyType = variantTypes.size === 1 ? [...variantTypes][0] : '';
// Find the key details from suggestions
const keyDetails = (keySuggestions || []).find((k) => k.label === keyName);
const keyType = keyDetails?.type || '';
// Filter operators based on key type
if (keyType) {
@@ -1292,7 +1214,7 @@ function QuerySearch({
if (curChar === '(') {
// In expression context, suggest keys, functions, or nested parentheses
options = [
...dedupedKeySuggestions,
...(keySuggestions || []),
{ label: '(', type: 'parenthesis', info: 'Open nested group' },
{ label: 'NOT', type: 'operator', info: 'Negate expression' },
...options.filter((opt) => opt.type === 'function'),

View File

@@ -1,120 +0,0 @@
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
getFieldContextPrefix,
getUserExpressionFromCombined,
} from '../utils';
describe('entityLogsExpression', () => {
describe('combineInitialAndUserExpression', () => {
it('returns user when initial is empty', () => {
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
'body contains error',
);
});
it('returns initial when user is empty', () => {
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
'k8s.pod.name = "x"',
);
});
it('wraps user in parentheses with AND', () => {
expect(
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
).toBe('k8s.pod.name = "x" AND (body = "a")');
});
});
describe('getUserExpressionFromCombined', () => {
it('returns empty when combined equals initial', () => {
expect(
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
).toBe('');
});
it('extracts user from wrapped form', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND (body = "a")',
),
).toBe('body = "a"');
});
it('extracts user from legacy AND without parens', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND body = "a"',
),
).toBe('body = "a"');
});
it('returns full combined when initial is empty', () => {
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
'service.name = "a"',
);
});
});
});
describe('getFieldContextPrefix', () => {
it('matches a complete context prefix with a remainder', () => {
expect(getFieldContextPrefix('attribute.status')).toStrictEqual({
context: 'attribute',
remainder: 'status',
});
});
it('matches a bare context prefix with empty remainder', () => {
expect(getFieldContextPrefix('resource.')).toStrictEqual({
context: 'resource',
remainder: '',
});
});
it('matches every backend field context', () => {
['attribute', 'resource', 'span', 'body', 'log', 'metric'].forEach((ctx) => {
expect(getFieldContextPrefix(`${ctx}.x`)).toStrictEqual({
context: ctx,
remainder: 'x',
});
});
});
it('does not match a partial context name', () => {
expect(getFieldContextPrefix('attr')).toBeNull();
expect(getFieldContextPrefix('attribute')).toBeNull();
});
it('does not match a non-context key with dots', () => {
expect(getFieldContextPrefix('status.code')).toBeNull();
});
it('matches context case-insensitively but keeps remainder casing', () => {
expect(getFieldContextPrefix('Attribute.Status')).toStrictEqual({
context: 'attribute',
remainder: 'Status',
});
});
});
describe('dedupeOptionsByLabel', () => {
it('keeps the first occurrence per label, preserving order', () => {
expect(
dedupeOptionsByLabel([
{ label: 'status.code', type: 'keyword' },
{ label: 'status.code', type: 'number' },
{ label: 'duration', type: 'number' },
]),
).toStrictEqual([
{ label: 'status.code', type: 'keyword' },
{ label: 'duration', type: 'number' },
]);
});
it('returns an empty array for empty input', () => {
expect(dedupeOptionsByLabel([])).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,58 @@
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
describe('entityLogsExpression', () => {
describe('combineInitialAndUserExpression', () => {
it('returns user when initial is empty', () => {
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
'body contains error',
);
});
it('returns initial when user is empty', () => {
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
'k8s.pod.name = "x"',
);
});
it('wraps user in parentheses with AND', () => {
expect(
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
).toBe('k8s.pod.name = "x" AND (body = "a")');
});
});
describe('getUserExpressionFromCombined', () => {
it('returns empty when combined equals initial', () => {
expect(
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
).toBe('');
});
it('extracts user from wrapped form', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND (body = "a")',
),
).toBe('body = "a"');
});
it('extracts user from legacy AND without parens', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND body = "a"',
),
).toBe('body = "a"');
});
it('returns full combined when initial is empty', () => {
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
'service.name = "a"',
);
});
});
});

View File

@@ -1,16 +1,6 @@
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
// TODO: move to using TelemetrytypesFieldContextDTO when we migrate getKeySuggestions API (Part of https://github.com/SigNoz/engineering-pod/issues/5289)
export const FIELD_CONTEXTS = [
'attribute',
'resource',
'span',
'body',
'log',
'metric',
] as const;
// Custom CodeMirror Completion.type for recent-query entries. Used to discriminate
// recents from regular autocomplete completions in renderers and event handlers.
export const RECENT_COMPLETION_TYPE = 'recent';

View File

@@ -9,45 +9,11 @@ import type { SignalType } from 'types/api/v5/queryRange';
import 'utils/timeUtils';
import {
FIELD_CONTEXTS,
RECENT_COMPLETION_TYPE,
RECENTS_DISPLAY_CAP,
RECENTS_SECTION,
} from './constants';
export interface FieldContextPrefixMatch {
context: string;
remainder: string;
}
// This function checks if the text(query key) starts with a fieldContext
// This util strictly checks that and returns context and remainder back.
// This helps differentiate if the typed key was prefixed with a context or
// was it an actual queryKey like (attribute.abc)
export function getFieldContextPrefix(
text: string,
): FieldContextPrefixMatch | null {
const lower = text.toLowerCase();
const context = FIELD_CONTEXTS.find((ctx) => lower.startsWith(`${ctx}.`));
return context ? { context, remainder: text.slice(context.length + 1) } : null;
}
// Keeps the first occurrence per label, preserving order. Key suggestions hold
// one entry per (name, fieldContext, fieldDataType) variant; This means query builder
// could show multiple labels and this avoids that.
export function dedupeOptionsByLabel<T extends { label: string }>(
options: T[],
): T[] {
const seen = new Set<string>();
return options.filter((option) => {
if (seen.has(option.label)) {
return false;
}
seen.add(option.label);
return true;
});
}
export function combineInitialAndUserExpression(
initial: string,
user: string,

View File

@@ -1,217 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { rest, server } from 'mocks-server/server';
import { render, userEvent, waitFor } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import QuerySearch from '../QuerySearch/QuerySearch';
import { mockCodeMirrorDomApis } from './codemirrorDomMocks';
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
const CM_OPTION_SELECTOR = '.cm-tooltip-autocomplete .cm-completionLabel';
beforeAll(() => {
mockCodeMirrorDomApis();
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
const handleRunQuery = jest.fn();
return {
__esModule: true,
useQueryBuilder: (): { handleRunQuery: () => void } => ({ handleRunQuery }),
handleRunQuery,
};
});
// Keys fixture: status.code exists as 3 variants (attribute string/number +
// resource string); attribute.a.b.c is a key literally named with the prefix.
const KEYS_FIXTURE = {
'status.code': [
{
name: 'status.code',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'status.code',
fieldContext: 'attribute',
fieldDataType: 'number',
signal: 'logs',
},
{
name: 'status.code',
fieldContext: 'resource',
fieldDataType: 'string',
signal: 'logs',
},
],
'attribute.a.b.c': [
{
name: 'attribute.a.b.c',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
],
'duration.nano': [
{
name: 'duration.nano',
fieldContext: 'span',
fieldDataType: 'number',
signal: 'logs',
},
],
};
const fetchedSearchTexts: string[] = [];
beforeEach(() => {
fetchedSearchTexts.length = 0;
server.use(
rest.get('http://localhost/api/v1/fields/keys', (req, res, ctx) => {
fetchedSearchTexts.push(req.url.searchParams.get('searchText') ?? '');
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: { complete: true, keys: KEYS_FIXTURE },
}),
);
}),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: { values: { stringValues: [], numberValues: [] } },
}),
),
),
);
});
async function renderAndType(text: string): Promise<HTMLElement> {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
await waitFor(() => {
expect(document.querySelector(CM_EDITOR_SELECTOR)).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
await userEvent.type(editor, text);
return editor;
}
// Types a key, waits for its suggestion to render (proving the debounced key
// fetch resolved into state), then types a space to enter operator context.
async function typeKeyThenEnterOperatorContext(
key: string,
expectedKeyLabel: string,
): Promise<void> {
const editor = await renderAndType(key);
await waitFor(() => {
expect(visibleOptionLabels()).toContain(expectedKeyLabel);
});
// skipClick: a click would reset the caret to position 0 under the mocked
// DOM rects; we need the space appended at the end of the typed key.
await userEvent.type(editor, ' ', { skipClick: true });
await waitFor(() => {
expect(visibleOptionLabels()).toContain('=');
});
}
function visibleOptionLabels(): string[] {
return Array.from(document.querySelectorAll(CM_OPTION_SELECTOR)).map(
(el) => el.textContent ?? '',
);
}
describe('QuerySearch context-prefixed key suggestions', () => {
it('shows one deduped suggestion per key name in normal mode', async () => {
await renderAndType('statu');
await waitFor(() => {
expect(visibleOptionLabels()).toContain('status.code');
});
const statusOptions = visibleOptionLabels().filter(
(label) => label === 'status.code',
);
expect(statusOptions).toHaveLength(1);
});
it('shows context-scoped suggestions for a complete context prefix', async () => {
await renderAndType('attribute.');
await waitFor(() => {
expect(visibleOptionLabels()).toContain('attribute.status.code');
});
const labels = visibleOptionLabels();
// Literal key name match ranks first
expect(labels[0]).toBe('attribute.a.b.c');
// Context-qualified form of the literal key is also present
expect(labels).toContain('attribute.attribute.a.b.c');
// span-only key is not suggested under attribute.
expect(labels).not.toContain('attribute.duration.nano');
});
it('fetches the context page when the prefix is typed exactly', async () => {
await renderAndType('attribute.');
await waitFor(() => {
expect(fetchedSearchTexts).toContain('attribute.');
});
});
});
describe('QuerySearch operator suggestions by key type', () => {
it('shows all operators for a key with ambiguous types', async () => {
// status.code is string + number across variants → no type filtering
await typeKeyThenEnterOperatorContext('status.code', 'status.code');
const labels = visibleOptionLabels();
expect(labels).toContain('>');
expect(labels).toContain('LIKE');
});
it('shows numeric operators for a single-type number key', async () => {
await typeKeyThenEnterOperatorContext('duration.nano', 'duration.nano');
const labels = visibleOptionLabels();
expect(labels).toContain('>');
expect(labels).not.toContain('LIKE');
});
it('narrows operators when a context prefix disambiguates the type', async () => {
// status.code is ambiguous globally, but resource.status.code is string-only
await typeKeyThenEnterOperatorContext(
'resource.status.code',
'resource.status.code',
);
const labels = visibleOptionLabels();
expect(labels).toContain('LIKE');
expect(labels).not.toContain('>');
});
});

View File

@@ -8,13 +8,78 @@ import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/ty
import { DataSource } from 'types/common/queryBuilder';
import QuerySearch from '../QuerySearch/QuerySearch';
import { mockCodeMirrorDomApis } from './codemirrorDomMocks';
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
// Mock DOM APIs that CodeMirror needs
beforeAll(() => {
mockCodeMirrorDomApis();
// Mock getClientRects and getBoundingClientRect for Range objects
const mockRect: DOMRect = {
width: 100,
height: 20,
top: 0,
left: 0,
right: 100,
bottom: 20,
x: 0,
y: 0,
toJSON: (): DOMRect => mockRect,
} as DOMRect;
// Create a minimal Range mock with only what CodeMirror actually uses
const createMockRange = (): Range => {
let startContainer: Node = document.createTextNode('');
let endContainer: Node = document.createTextNode('');
let startOffset = 0;
let endOffset = 0;
const mockRange = {
// CodeMirror uses these for text measurement
getClientRects: (): DOMRectList =>
({
length: 1,
item: (index: number): DOMRect | null => (index === 0 ? mockRect : null),
0: mockRect,
*[Symbol.iterator](): Generator<DOMRect> {
yield mockRect;
},
}) as unknown as DOMRectList,
getBoundingClientRect: (): DOMRect => mockRect,
// CodeMirror calls these to set up text ranges
setStart: (node: Node, offset: number): void => {
startContainer = node;
startOffset = offset;
},
setEnd: (node: Node, offset: number): void => {
endContainer = node;
endOffset = offset;
},
// Minimal Range properties (TypeScript requires these)
get startContainer(): Node {
return startContainer;
},
get endContainer(): Node {
return endContainer;
},
get startOffset(): number {
return startOffset;
},
get endOffset(): number {
return endOffset;
},
get collapsed(): boolean {
return startContainer === endContainer && startOffset === endOffset;
},
commonAncestorContainer: document.body,
};
return mockRange as unknown as Range;
};
// Mock document.createRange to return a new Range instance each time
document.createRange = (): Range => createMockRange();
// Mock getBoundingClientRect for elements
Element.prototype.getBoundingClientRect = (): DOMRect => mockRect;
});
jest.mock('hooks/useDarkMode', () => ({

View File

@@ -1,71 +0,0 @@
// Mocks the DOM measurement APIs CodeMirror needs to render in jsdom
// (Range client rects + element bounding rects). Call from a beforeAll in
// specs that render the real CodeMirror editor.
export function mockCodeMirrorDomApis(): void {
const mockRect: DOMRect = {
width: 100,
height: 20,
top: 0,
left: 0,
right: 100,
bottom: 20,
x: 0,
y: 0,
toJSON: (): DOMRect => mockRect,
} as DOMRect;
// Create a minimal Range mock with only what CodeMirror actually uses
const createMockRange = (): Range => {
let startContainer: Node = document.createTextNode('');
let endContainer: Node = document.createTextNode('');
let startOffset = 0;
let endOffset = 0;
const mockRange = {
// CodeMirror uses these for text measurement
getClientRects: (): DOMRectList =>
({
length: 1,
item: (index: number): DOMRect | null => (index === 0 ? mockRect : null),
0: mockRect,
*[Symbol.iterator](): Generator<DOMRect> {
yield mockRect;
},
}) as unknown as DOMRectList,
getBoundingClientRect: (): DOMRect => mockRect,
// CodeMirror calls these to set up text ranges
setStart: (node: Node, offset: number): void => {
startContainer = node;
startOffset = offset;
},
setEnd: (node: Node, offset: number): void => {
endContainer = node;
endOffset = offset;
},
// Minimal Range properties (TypeScript requires these)
get startContainer(): Node {
return startContainer;
},
get endContainer(): Node {
return endContainer;
},
get startOffset(): number {
return startOffset;
},
get endOffset(): number {
return endOffset;
},
get collapsed(): boolean {
return startContainer === endContainer && startOffset === endOffset;
},
commonAncestorContainer: document.body,
};
return mockRange as unknown as Range;
};
// Mock document.createRange to return a new Range instance each time
document.createRange = (): Range => createMockRange();
// Mock getBoundingClientRect for elements
Element.prototype.getBoundingClientRect = (): DOMRect => mockRect;
}

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ 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;
@@ -50,12 +51,22 @@ 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;
onRowClick?.(rowData, itemKey, { isActive });
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData, itemKey);
}
},
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
[
isRowActive,
onRowDeactivate,
onRowClick,
onRowClickNewTab,
rowData,
getRowKeyData,
rowIndex,
],
);
if (itemKind === 'expansion') {
@@ -109,6 +120,7 @@ function areRowCellsPropsEqual<TData>(
prev.columnVisibilityKey === next.columnVisibilityKey &&
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
prev.context?.renderRowActions === next.context?.renderRowActions &&

View File

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

View File

@@ -85,9 +85,7 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
});
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
@@ -119,19 +117,17 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
isActive: false,
});
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
});
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.
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
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,
@@ -156,44 +152,8 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
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,
});
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('does not render renderRowActions before hover', () => {

View File

@@ -611,7 +611,6 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
'1',
{ isActive: false },
);
});
@@ -640,7 +639,6 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
{ id: '1', name: 'Item 1' },
{ isActive: false },
);
});
@@ -661,15 +659,15 @@ describe('TanStackTableView Integration', () => {
expect(row).toHaveClass('tableRowActive');
});
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.
it('calls onRowDeactivate when clicking active row', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowDeactivate,
isRowActive: (row) => row.id === '1',
},
});
@@ -680,11 +678,8 @@ describe('TanStackTableView Integration', () => {
await user.click(screen.getByText('Item 1'));
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
expect.anything(),
{ isActive: true },
);
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('opens in new tab on ctrl+click', async () => {

View File

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

View File

@@ -81,23 +81,15 @@ 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, context: RowClickContext) => void;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
rowKey: string,
@@ -188,9 +180,10 @@ export type TanStackTableProps<TData, TItemKey = string> = {
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,

View File

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

View File

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

View File

@@ -15,12 +15,6 @@ 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.
@@ -48,7 +42,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` fits the longest label, clamped to [150px, min(MAX_RIGHT_LEGEND_WIDTH, 40% width)].
* - `legendWidth` is clamped between 150px and min(MAX_LEGEND_WIDTH, 30% of container width).
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
@@ -86,22 +80,9 @@ export function calculateChartDimensions({
const legendItemCount = seriesLabels.length;
if (legendConfig.position === LegendPosition.RIGHT) {
// 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 maxRightLegendWidth = Math.min(MAX_LEGEND_WIDTH, containerWidth * 0.3);
const rightLegendWidth = Math.min(
Math.max(150, desiredLegendWidth),
Math.max(150, approxLegendItemWidth),
maxRightLegendWidth,
);

View File

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

View File

@@ -216,13 +216,10 @@ function LiveLogsList({
),
}) as CSSProperties
}
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
onRowClick={(log): void => {
handleSetActiveLog(log);
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons

View File

@@ -211,7 +211,9 @@ function LogsExplorerList({
data={logs}
isLoading={isLoading || isFetching}
onEndReached={onEndReached}
isRowActive={(log): boolean => log.id === activeLog?.id}
isRowActive={(log): boolean =>
log.id === activeLog?.id || log.id === activeLogId
}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
@@ -224,13 +226,10 @@ function LogsExplorerList({
),
}) as CSSProperties
}
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
onRowClick={(log): void => {
handleSetActiveLog(log);
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons

View File

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

View File

@@ -1,7 +1,6 @@
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';
@@ -15,16 +14,17 @@ 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: DashboardtypesDynamicVariableSignalDTO;
signal: DynamicSignalOption;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: DashboardtypesDynamicVariableSignalDTO;
dynamicSignal?: DynamicSignalOption;
}) => void;
onPreview: (values: (string | number)[]) => void;
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
@@ -117,9 +117,7 @@ function DynamicVariableFields({
value: s,
}))}
onChange={(value): void =>
onChange({
dynamicSignal: value as DashboardtypesDynamicVariableSignalDTO,
})
onChange({ dynamicSignal: value as DynamicSignalOption })
}
data-testid="variable-signal-select"
/>

View File

@@ -13,7 +13,11 @@ 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';
@@ -65,8 +69,12 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// signal is a required wire field (`all` = every telemetry signal), used as-is.
dynamicSignal: plugin.spec.signal,
// 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,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
@@ -94,7 +102,7 @@ function buildPlugin(
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal,
signal: signalForApi(model.dynamicSignal),
},
};
case 'QUERY':

View File

@@ -1,6 +1,6 @@
import {
DashboardtypesDynamicVariableSignalDTO,
DashboardtypesListVariableSpecSortDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesVariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
@@ -23,6 +23,23 @@ 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
@@ -64,38 +81,25 @@ export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
};
export const DYNAMIC_SIGNALS: DashboardtypesDynamicVariableSignalDTO[] = [
DashboardtypesDynamicVariableSignalDTO.all,
DashboardtypesDynamicVariableSignalDTO.traces,
DashboardtypesDynamicVariableSignalDTO.logs,
DashboardtypesDynamicVariableSignalDTO.metrics,
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
DYNAMIC_SIGNAL_ALL,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
];
export const DYNAMIC_SIGNAL_LABEL: Record<
DashboardtypesDynamicVariableSignalDTO,
string
> = {
[DashboardtypesDynamicVariableSignalDTO.all]: 'All telemetry',
[DashboardtypesDynamicVariableSignalDTO.traces]: 'Traces',
[DashboardtypesDynamicVariableSignalDTO.logs]: 'Logs',
[DashboardtypesDynamicVariableSignalDTO.metrics]: 'Metrics',
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
[TelemetrytypesSignalDTO.traces]: 'Traces',
[TelemetrytypesSignalDTO.logs]: 'Logs',
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
};
/**
* Field-keys/values API param. The `all` signal is omitted (that endpoint only
* accepts a concrete signal), everything else passes through.
*/
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
export function signalForApi(
signal: DashboardtypesDynamicVariableSignalDTO,
):
| Exclude<
DashboardtypesDynamicVariableSignalDTO,
DashboardtypesDynamicVariableSignalDTO.all
>
| undefined {
return signal === DashboardtypesDynamicVariableSignalDTO.all
? undefined
: signal;
signal: DynamicSignalOption,
): TelemetrySignal | undefined {
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
}
type SortableValues = (string | number | boolean)[];
@@ -140,7 +144,7 @@ export interface VariableFormModel {
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: DashboardtypesDynamicVariableSignalDTO; // DYNAMIC — the telemetry signal (`all` = every signal)
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
@@ -162,6 +166,6 @@ export function emptyVariableFormModel(): VariableFormModel {
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: DashboardtypesDynamicVariableSignalDTO.all,
dynamicSignal: DYNAMIC_SIGNAL_ALL,
};
}

View File

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

View File

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

View File

@@ -49,14 +49,6 @@ 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'],
@@ -75,34 +67,16 @@ describe('newPanelRoute', () => {
);
});
it('carries the query as a compositeQuery param the reader can decode', () => {
it('carries the query as a decodable compositeQuery param', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
});
const { params } = parseLink(link);
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);
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
query,
);
});
it('returns null for a panel type with no V2 kind', () => {

View File

@@ -45,11 +45,9 @@ export function parseNewPanelKind(
/**
* New-panel editor link that exports an explorer query into a V2 dashboard. Carries the
* 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.
* 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.
*/
export function buildExportPanelLink({
dashboardId,
@@ -70,7 +68,7 @@ export function buildExportPanelLink({
});
return `${path}${newPanelSearch(kind)}&${
QueryParams.compositeQuery
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
}=${encodeURIComponent(JSON.stringify(query))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ import PanelHeaderSearch from './PanelHeaderSearch';
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
import {
panelStatusFromError,
panelStatusFromMultipleEnabledQueries,
panelStatusFromWarning,
} from '../PanelStatus/utils';
import styles from './PanelHeader.module.scss';
@@ -75,24 +74,11 @@ 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 &&
!multiQueryWarningDetail &&
hideActions
) {
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
return <Fragment />;
}
@@ -138,13 +124,6 @@ function PanelHeader({
{warningDetail && (
<PanelStatusPopover variant="warning" detail={warningDetail} />
)}
{multiQueryWarningDetail && (
<PanelStatusPopover
variant="warning"
detail={multiQueryWarningDetail}
testId="panel-status-config-warning"
/>
)}
{/* Renders nothing when no action survives its gates (kind/role/context). */}
{!hideActions && (
<PanelActionsMenu

View File

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

View File

@@ -1,16 +1,9 @@
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { 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,
panelStatusFromMultipleEnabledQueries,
panelStatusFromWarning,
} from '../utils';
import { panelStatusFromError, 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.
@@ -94,62 +87,3 @@ describe('panelStatusFromWarning', () => {
});
});
});
function panel(
kind: string,
envelopes: { disabled?: boolean }[],
): DashboardtypesPanelDTO {
return {
spec: {
plugin: { kind, spec: {} },
queries: [
{
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: envelopes.map(({ disabled }) => ({
type: 'builder_query',
spec: { disabled },
})),
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('panelStatusFromMultipleEnabledQueries', () => {
it('warns when a Number panel has more than one enabled query', () => {
const detail = panelStatusFromMultipleEnabledQueries(
panel('signoz/NumberPanel', [{}, {}]),
);
expect(detail).not.toBeNull();
expect(detail?.message).toMatch(/single value/i);
expect(detail?.messages).toHaveLength(1);
});
it('counts only enabled queries (a disabled second query is fine)', () => {
expect(
panelStatusFromMultipleEnabledQueries(
panel('signoz/NumberPanel', [{}, { disabled: true }]),
),
).toBeNull();
});
it('does not warn when a Number panel has a single enabled query', () => {
expect(
panelStatusFromMultipleEnabledQueries(panel('signoz/NumberPanel', [{}])),
).toBeNull();
});
it('does not warn for other panel kinds even with multiple enabled queries', () => {
expect(
panelStatusFromMultipleEnabledQueries(
panel('signoz/TimeSeriesPanel', [{}, {}]),
),
).toBeNull();
});
});

View File

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

View File

@@ -41,40 +41,6 @@ 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',
@@ -135,53 +101,6 @@ describe('PanelHeader status indicators', () => {
});
});
describe('PanelHeader multi-query config warning (issue #9512)', () => {
it('warns when a Number panel has more than one enabled query', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 2)}
/>,
);
expect(screen.getByTestId('panel-status-config-warning')).toBeInTheDocument();
});
it('does not warn when only one query is enabled (others disabled)', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 1, 2)}
/>,
);
expect(
screen.queryByTestId('panel-status-config-warning'),
).not.toBeInTheDocument();
});
it('does not warn for non-Number panels with multiple enabled queries', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/TimeSeriesPanel', 3)}
/>,
);
expect(
screen.queryByTestId('panel-status-config-warning'),
).not.toBeInTheDocument();
});
it('shows the warning in the editor preview (hideActions, no title)', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 2)}
hideActions
/>,
);
expect(screen.getByTestId('panel-status-config-warning')).toBeInTheDocument();
});
});
describe('PanelHeader search', () => {
it('renders no search affordance when the panel is not searchable', () => {
renderWithProvider(<PanelHeader {...baseProps} />);

View File

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

View File

@@ -19,7 +19,6 @@ 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';
@@ -164,7 +163,7 @@ export function useDrilldown(
const dashboardVariables = useDrilldownDashboardVariables({
filters: context?.filters ?? EMPTY_FILTERS,
signal: dynamicSignalFromQuerySignal(context?.signal),
signal: context?.signal,
onClose: handleClose,
});
@@ -222,7 +221,7 @@ export function useDrilldown(
context={context}
query={v1Query}
isResolving={isResolving}
links={panel.spec.links}
links={panel.spec.links ?? undefined}
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
onViewLogs={(): void => navigate('view_logs')}
onViewTraces={(): void => navigate('view_traces')}

View File

@@ -1,13 +1,14 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { DashboardtypesDynamicVariableSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { TelemetrytypesSignalDTO } 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';
@@ -22,8 +23,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[];
/** Dynamic-variable signal derived from the clicked query — seeds a created variable's `dynamicSignal`. */
signal?: DashboardtypesDynamicVariableSignalDTO;
/** Clicked query's telemetry signal — seeds a created variable's `dynamicSignal`. */
signal?: TelemetrytypesSignalDTO;
onClose: () => void;
}
@@ -110,7 +111,8 @@ export function useDrilldownDashboardVariables({
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
dynamicSignal: signal ?? DashboardtypesDynamicVariableSignalDTO.all,
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
};
try {
await patchAsync(

View File

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

View File

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

View File

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

View File

@@ -32,12 +32,11 @@ unaryExpression
;
// Primary constructs: grouped expressions, a comparison (key op value),
// a function call, a search() full-text call, or a free-text string
// a function call, or a full-text string
primary
: LPAREN orExpression RPAREN
| comparison
| functionCall
| searchCall
| fullText
| key
| value
@@ -111,18 +110,6 @@ functionCall
: (HASTOKEN | HAS | HASANY | HASALL) LPAREN functionParamList RPAREN
;
/*
* Full-text search call: search('needle')
*
* Uses the shared functionParamList so future scoped forms like
* search(body, 'abc') / search(attribute, 'abc') need no grammar change. Today
* only a single needle is supported. Unlike bare/quoted free text (`fullText`),
* which only targets the body column, search() fans out across every field.
*/
searchCall
: SEARCH LPAREN functionParamList RPAREN
;
// Function parameters can be keys, single scalar values, or arrays
functionParamList
: functionParam ( COMMA functionParam )*
@@ -197,7 +184,6 @@ HASTOKEN : [Hh][Aa][Ss][Tt][Oo][Kk][Ee][Nn];
HAS : [Hh][Aa][Ss] ;
HASANY : [Hh][Aa][Ss][Aa][Nn][Yy] ;
HASALL : [Hh][Aa][Ss][Aa][Ll][Ll] ;
SEARCH : [Ss][Ee][Aa][Rr][Cc][Hh] ;
// Potential boolean constants
BOOL

View File

@@ -101,12 +101,12 @@ func PrepareFilterExpression(labels map[string]string, whereClause string, group
}
// Visit implements the visitor for the query rule.
func (r *WhereClauseRewriter) Visit(tree antlr.ParseTree) any {
func (r *WhereClauseRewriter) Visit(tree antlr.ParseTree) interface{} {
return tree.Accept(r)
}
// VisitQuery visits the query node.
func (r *WhereClauseRewriter) VisitQuery(ctx *parser.QueryContext) any {
func (r *WhereClauseRewriter) VisitQuery(ctx *parser.QueryContext) interface{} {
if ctx.Expression() != nil {
ctx.Expression().Accept(r)
}
@@ -114,7 +114,7 @@ func (r *WhereClauseRewriter) VisitQuery(ctx *parser.QueryContext) any {
}
// VisitExpression visits the expression node.
func (r *WhereClauseRewriter) VisitExpression(ctx *parser.ExpressionContext) any {
func (r *WhereClauseRewriter) VisitExpression(ctx *parser.ExpressionContext) interface{} {
if ctx.OrExpression() != nil {
ctx.OrExpression().Accept(r)
}
@@ -122,7 +122,7 @@ func (r *WhereClauseRewriter) VisitExpression(ctx *parser.ExpressionContext) any
}
// VisitOrExpression visits OR expressions.
func (r *WhereClauseRewriter) VisitOrExpression(ctx *parser.OrExpressionContext) any {
func (r *WhereClauseRewriter) VisitOrExpression(ctx *parser.OrExpressionContext) interface{} {
for i, andExpr := range ctx.AllAndExpression() {
if i > 0 {
r.rewritten.WriteString(" OR ")
@@ -133,7 +133,7 @@ func (r *WhereClauseRewriter) VisitOrExpression(ctx *parser.OrExpressionContext)
}
// VisitAndExpression visits AND expressions.
func (r *WhereClauseRewriter) VisitAndExpression(ctx *parser.AndExpressionContext) any {
func (r *WhereClauseRewriter) VisitAndExpression(ctx *parser.AndExpressionContext) interface{} {
unaryExprs := ctx.AllUnaryExpression()
for i, unaryExpr := range unaryExprs {
if i > 0 {
@@ -151,7 +151,7 @@ func (r *WhereClauseRewriter) VisitAndExpression(ctx *parser.AndExpressionContex
}
// VisitUnaryExpression visits unary expressions (with optional NOT).
func (r *WhereClauseRewriter) VisitUnaryExpression(ctx *parser.UnaryExpressionContext) any {
func (r *WhereClauseRewriter) VisitUnaryExpression(ctx *parser.UnaryExpressionContext) interface{} {
if ctx.NOT() != nil {
r.rewritten.WriteString("NOT ")
}
@@ -162,7 +162,7 @@ func (r *WhereClauseRewriter) VisitUnaryExpression(ctx *parser.UnaryExpressionCo
}
// VisitPrimary visits primary expressions.
func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) any {
func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) interface{} {
if ctx.LPAREN() != nil && ctx.RPAREN() != nil {
r.rewritten.WriteString("(")
if ctx.OrExpression() != nil {
@@ -173,8 +173,6 @@ func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) any {
ctx.Comparison().Accept(r)
} else if ctx.FunctionCall() != nil {
ctx.FunctionCall().Accept(r)
} else if ctx.SearchCall() != nil {
ctx.SearchCall().Accept(r)
} else if ctx.FullText() != nil {
ctx.FullText().Accept(r)
} else if ctx.Key() != nil {
@@ -186,7 +184,7 @@ func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) any {
}
// VisitComparison visits comparison expressions.
func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) any {
func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) interface{} {
if ctx.Key() == nil {
return nil
}
@@ -199,7 +197,7 @@ func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) any
if _, partOfGroup := r.groupBySet[key]; partOfGroup {
// Case 1: Replace with actual value
escapedValue := escapeValueIfNeeded(value)
fmt.Fprintf(&r.rewritten, "%s=%s", key, escapedValue)
r.rewritten.WriteString(fmt.Sprintf("%s=%s", key, escapedValue))
return nil
}
}
@@ -307,7 +305,7 @@ func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) any
}
// VisitInClause visits IN clauses.
func (r *WhereClauseRewriter) VisitInClause(ctx *parser.InClauseContext) any {
func (r *WhereClauseRewriter) VisitInClause(ctx *parser.InClauseContext) interface{} {
r.rewritten.WriteString("IN ")
if ctx.LPAREN() != nil {
r.rewritten.WriteString("(")
@@ -328,7 +326,7 @@ func (r *WhereClauseRewriter) VisitInClause(ctx *parser.InClauseContext) any {
}
// VisitNotInClause visits NOT IN clauses.
func (r *WhereClauseRewriter) VisitNotInClause(ctx *parser.NotInClauseContext) any {
func (r *WhereClauseRewriter) VisitNotInClause(ctx *parser.NotInClauseContext) interface{} {
r.rewritten.WriteString("NOT IN ")
if ctx.LPAREN() != nil {
r.rewritten.WriteString("(")
@@ -349,7 +347,7 @@ func (r *WhereClauseRewriter) VisitNotInClause(ctx *parser.NotInClauseContext) a
}
// VisitValueList visits value lists.
func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) any {
func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) interface{} {
values := ctx.AllValue()
for i, val := range values {
if i > 0 {
@@ -361,20 +359,13 @@ func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) any {
}
// VisitFullText visits full text expressions.
func (r *WhereClauseRewriter) VisitFullText(ctx *parser.FullTextContext) any {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitSearchCall visits search() calls. It has no keys to rewrite, so it is
// preserved verbatim.
func (r *WhereClauseRewriter) VisitSearchCall(ctx *parser.SearchCallContext) any {
func (r *WhereClauseRewriter) VisitFullText(ctx *parser.FullTextContext) interface{} {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitFunctionCall visits function calls.
func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext) any {
func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext) interface{} {
// Write function name
if ctx.HAS() != nil {
r.rewritten.WriteString("has")
@@ -395,7 +386,7 @@ func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext)
}
// VisitFunctionParamList visits function parameter lists.
func (r *WhereClauseRewriter) VisitFunctionParamList(ctx *parser.FunctionParamListContext) any {
func (r *WhereClauseRewriter) VisitFunctionParamList(ctx *parser.FunctionParamListContext) interface{} {
params := ctx.AllFunctionParam()
for i, param := range params {
if i > 0 {
@@ -407,7 +398,7 @@ func (r *WhereClauseRewriter) VisitFunctionParamList(ctx *parser.FunctionParamLi
}
// VisitFunctionParam visits function parameters.
func (r *WhereClauseRewriter) VisitFunctionParam(ctx *parser.FunctionParamContext) any {
func (r *WhereClauseRewriter) VisitFunctionParam(ctx *parser.FunctionParamContext) interface{} {
if ctx.Key() != nil {
ctx.Key().Accept(r)
} else if ctx.Value() != nil {
@@ -419,7 +410,7 @@ func (r *WhereClauseRewriter) VisitFunctionParam(ctx *parser.FunctionParamContex
}
// VisitArray visits array expressions.
func (r *WhereClauseRewriter) VisitArray(ctx *parser.ArrayContext) any {
func (r *WhereClauseRewriter) VisitArray(ctx *parser.ArrayContext) interface{} {
r.rewritten.WriteString("[")
if ctx.ValueList() != nil {
ctx.ValueList().Accept(r)
@@ -429,13 +420,13 @@ func (r *WhereClauseRewriter) VisitArray(ctx *parser.ArrayContext) any {
}
// VisitValue visits value expressions.
func (r *WhereClauseRewriter) VisitValue(ctx *parser.ValueContext) any {
func (r *WhereClauseRewriter) VisitValue(ctx *parser.ValueContext) interface{} {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitKey visits key expressions.
func (r *WhereClauseRewriter) VisitKey(ctx *parser.KeyContext) any {
func (r *WhereClauseRewriter) VisitKey(ctx *parser.KeyContext) interface{} {
r.keysSeen[ctx.GetText()] = struct{}{}
r.rewritten.WriteString(ctx.GetText())
return nil

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_ComputeEngine_Color</title><g data-name="Product Icons"><rect class="cls-1" x="9" y="9" width="6" height="6"/><rect class="cls-2" x="11" y="2" width="2" height="4"/><rect class="cls-2" x="7" y="2" width="2" height="4"/><rect class="cls-2" x="15" y="2" width="2" height="4"/><rect class="cls-3" x="11" y="18" width="2" height="4"/><rect class="cls-3" x="7" y="18" width="2" height="4"/><rect class="cls-3" x="15" y="18" width="2" height="4"/><rect class="cls-3" x="19" y="10" width="2" height="4" transform="translate(8 32) rotate(-90)"/><rect class="cls-3" x="19" y="14" width="2" height="4" transform="translate(4 36) rotate(-90)"/><rect class="cls-3" x="19" y="6" width="2" height="4" transform="translate(12 28) rotate(-90)"/><rect class="cls-2" x="3" y="10" width="2" height="4" transform="translate(-8 16) rotate(-90)"/><rect class="cls-2" x="3" y="14" width="2" height="4" transform="translate(-12 20) rotate(-90)"/><rect class="cls-2" x="3" y="6" width="2" height="4" transform="translate(-4 12) rotate(-90)"/><path class="cls-1" d="M5,5V19H19V5ZM17,17H7V7H17Z"/><polygon class="cls-2" points="9 15 15 15 12 12 9 15"/><polygon class="cls-3" points="12 12 15 15 15 9 12 12"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,124 @@
{
"id": "computeengine",
"title": "GCP Compute Engine",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "compute.googleapis.com/instance/uptime_total",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/instance/cpu/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/guest/memory/bytes_used",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/guest/memory/percent_used",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "agent.googleapis.com/memory/percent_used",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/average_io_latency",
"unit": "Microseconds",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/read_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/write_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/read_ops_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/write_ops_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/performance_status",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/instance/network/received_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "compute.googleapis.com/instance/network/sent_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "compute.googleapis.com/instance/network/received_packets_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "compute.googleapis.com/instance/network/sent_packets_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "compute.googleapis.com/instance/firewall/dropped_packets_count",
"unit": "Count",
"type": "Sum",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Compute Engine Overview",
"description": "Overview of GCP Compute Engine metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor GCP Compute Engine with SigNoz
Collect key GCP Compute Engine metrics and view them with an out of the box dashboard.

View File

@@ -238,7 +238,7 @@
}
},
"tags": [],
"title": "Signoz GCP Memorystore Redis overview",
"title": "Signoz GCP Memorystory Redis overview",
"uploadedGrafana": false,
"uuid": "019f85e9-3846-7dc9-9bce-b626b8a3eaff",
"variables": {
@@ -262,7 +262,7 @@
"type": "DYNAMIC"
},
"dabcf41a-4121-4a03-8738-927576cc90bf": {
"allSelected": false,
"allSelected": true,
"customValue": "",
"defaultValue": "",
"description": "GCP Project ID",
@@ -390,7 +390,7 @@
"disabled": false,
"legend": "",
"name": "A",
"query": ""
"query": "rate({\"redis.googleapis.com/server/uptime\", project_id=\"$project_id\"}[5m])"
}
],
"queryType": "builder",

View File

@@ -45,17 +45,17 @@ func (m *module) createMany(ctx context.Context, orgID valuer.UUID, kind coretyp
return []*tagtypes.Tag{}, nil
}
ordered, toCreate, err := m.resolve(ctx, orgID, kind, postable)
toCreate, matched, err := m.resolve(ctx, orgID, kind, postable)
if err != nil {
return nil, err
}
// CreateOrGet fills new rows' IDs in place; ordered shares those pointers.
if _, err := m.store.CreateOrGet(ctx, toCreate); err != nil {
created, err := m.store.CreateOrGet(ctx, toCreate)
if err != nil {
return nil, err
}
return ordered, nil
return append(matched, created...), nil
}
func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) error {

View File

@@ -13,9 +13,10 @@ import (
// the existing tags for an org. Lookup is case-insensitive on both key and
// value (matching the storage uniqueness rule); when an existing row matches,
// its display casing is reused. Inputs are deduped on (LOWER(key), LOWER(value));
// the first input's casing wins on collisions. Returns the resolved tags in
// request order (deduped) plus the new subset to insert; the new tags share
// pointers with the ordered slice, so their IDs populate in place on insert.
// the first input's casing wins on collisions. Returns:
// - toCreate: new Tag rows the caller should insert (with pre-generated IDs)
// - matched: existing rows the caller's input already pointed to. They
// already carry authoritative IDs from the store.
func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, []*tagtypes.Tag, error) {
if len(postable) == 0 {
return nil, nil, nil
@@ -33,8 +34,8 @@ func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.
}
seenInRequestAlready := make(map[string]struct{}, len(postable)) // postable can have the same tag multiple times
ordered := make([]*tagtypes.Tag, 0, len(postable))
toCreate := make([]*tagtypes.Tag, 0)
matched := make([]*tagtypes.Tag, 0)
for _, p := range postable {
key, value, err := tagtypes.ValidatePostableTag(p)
@@ -48,13 +49,11 @@ func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.
seenInRequestAlready[lookup] = struct{}{}
if existingTag, ok := lowercaseTagsMap[lookup]; ok {
ordered = append(ordered, existingTag)
matched = append(matched, existingTag)
continue
}
newTag := tagtypes.NewTag(orgID, kind, key, value)
ordered = append(ordered, newTag)
toCreate = append(toCreate, newTag)
toCreate = append(toCreate, tagtypes.NewTag(orgID, kind, key, value))
}
return ordered, toCreate, nil
return toCreate, matched, nil
}

View File

@@ -20,14 +20,14 @@ func TestModule_Resolve(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
ordered, toCreate, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
toCreate, matched, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
require.NoError(t, err)
assert.Empty(t, ordered)
assert.Empty(t, toCreate)
assert.Empty(t, matched)
assert.Zero(t, store.ListCallCount, "should not hit store when input is empty")
})
t.Run("creates missing pairs and reuses existing, in request order", func(t *testing.T) {
t.Run("creates missing pairs and reuses existing", func(t *testing.T) {
orgID := valuer.GenerateUUID()
dbTag := tagtypes.NewTag(orgID, testKind, "team", "Pulse")
dbTag2 := tagtypes.NewTag(orgID, testKind, "Database", "redis")
@@ -35,28 +35,22 @@ func TestModule_Resolve(t *testing.T) {
store.Tags = []*tagtypes.Tag{dbTag, dbTag2}
m := &module{store: store}
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "team", Value: "events"}, // new
{Key: "DATABASE", Value: "REDIS"}, // case-only conflict
{Key: "Brand", Value: "New"}, // new
})
require.NoError(t, err)
// ordered mirrors the request: new, existing (reused pointer), new.
require.Len(t, ordered, 3)
assert.Equal(t, "team", ordered[0].Key)
assert.Equal(t, "events", ordered[0].Value)
assert.Same(t, dbTag2, ordered[1], "case-only conflict reuses the existing pointer with its authoritative ID")
assert.Equal(t, "Brand", ordered[2].Key)
assert.Equal(t, "New", ordered[2].Value)
createdLowerKVs := []string{}
for _, tg := range toCreate {
createdLowerKVs = append(createdLowerKVs, strings.ToLower(tg.Key)+"\x00"+strings.ToLower(tg.Value))
}
assert.ElementsMatch(t, []string{"team\x00events", "brand\x00new"}, createdLowerKVs,
"only the two missing pairs should be returned for insertion")
assert.Same(t, ordered[0], toCreate[0], "toCreate shares pointers with ordered so inserted IDs propagate")
require.Len(t, matched, 1, "DATABASE:REDIS should hit the existing 'Database:redis' tag")
assert.Same(t, dbTag2, matched[0], "matched should return the existing pointer with its authoritative ID")
})
t.Run("dedupes inputs that map to the same lower(key)+lower(value)", func(t *testing.T) {
@@ -64,14 +58,14 @@ func TestModule_Resolve(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "Foo", Value: "Bar"},
{Key: "foo", Value: "bar"},
{Key: "FOO", Value: "BAR"},
})
require.NoError(t, err)
require.Len(t, ordered, 1, "duplicate inputs must collapse into a single tag")
require.Empty(t, matched)
require.Len(t, toCreate, 1, "duplicate inputs must collapse into a single insert")
assert.Equal(t, "Foo", toCreate[0].Key, "first input's casing wins")
assert.Equal(t, "Bar", toCreate[0].Value, "first input's casing wins")
@@ -84,15 +78,15 @@ func TestModule_Resolve(t *testing.T) {
store.Tags = []*tagtypes.Tag{dbTag}
m := &module{store: store}
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "team", Value: "PULSE"},
})
require.NoError(t, err)
assert.Empty(t, toCreate)
require.Len(t, ordered, 1)
assert.Equal(t, "Team", ordered[0].Key)
assert.Equal(t, "Pulse", ordered[0].Value)
require.Len(t, matched, 1)
assert.Equal(t, "Team", matched[0].Key)
assert.Equal(t, "Pulse", matched[0].Value)
})
t.Run("propagates validation error from any input", func(t *testing.T) {

View File

@@ -44,7 +44,6 @@ func (s *store) ListByResource(ctx context.Context, orgID valuer.UUID, kind core
Where("tr.kind = ?", kind).
Where("tr.resource_id = ?", resourceID).
Where("tag.org_id = ?", orgID).
OrderExpr("tr.rank ASC").
Scan(ctx)
if err != nil {
return nil, err
@@ -72,7 +71,6 @@ func (s *store) ListByResources(ctx context.Context, orgID valuer.UUID, kind cor
Where("tr.kind = ?", kind).
Where("tr.resource_id IN (?)", bun.In(resourceIDs)).
Where("tag.org_id = ?", orgID).
OrderExpr("tr.rank ASC").
Scan(ctx)
if err != nil {
return nil, err
@@ -114,14 +112,11 @@ func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRe
if len(relations) == 0 {
return nil
}
// On re-link (same tag, same resource) overwrite rank with the incoming
// position, so a reordered tag set persists its new order.
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&relations).
On("CONFLICT (kind, resource_id, tag_id) DO UPDATE").
Set("rank = EXCLUDED.rank").
On("CONFLICT (kind, resource_id, tag_id) DO NOTHING").
Exec(ctx)
return err
}

File diff suppressed because one or more lines are too long

View File

@@ -24,13 +24,12 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
'('=1
')'=2
'['=3

File diff suppressed because one or more lines are too long

View File

@@ -24,13 +24,12 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
'('=1
')'=2
'['=3

View File

@@ -1,4 +1,4 @@
// Code generated from FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
// Code generated from grammar/FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // FilterQuery
@@ -93,12 +93,6 @@ func (s *BaseFilterQueryListener) EnterFunctionCall(ctx *FunctionCallContext) {}
// ExitFunctionCall is called when production functionCall is exited.
func (s *BaseFilterQueryListener) ExitFunctionCall(ctx *FunctionCallContext) {}
// EnterSearchCall is called when production searchCall is entered.
func (s *BaseFilterQueryListener) EnterSearchCall(ctx *SearchCallContext) {}
// ExitSearchCall is called when production searchCall is exited.
func (s *BaseFilterQueryListener) ExitSearchCall(ctx *SearchCallContext) {}
// EnterFunctionParamList is called when production functionParamList is entered.
func (s *BaseFilterQueryListener) EnterFunctionParamList(ctx *FunctionParamListContext) {}

View File

@@ -1,4 +1,4 @@
// Code generated from FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
// Code generated from grammar/FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // FilterQuery
@@ -56,10 +56,6 @@ func (v *BaseFilterQueryVisitor) VisitFunctionCall(ctx *FunctionCallContext) int
return v.VisitChildren(ctx)
}
func (v *BaseFilterQueryVisitor) VisitSearchCall(ctx *SearchCallContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseFilterQueryVisitor) VisitFunctionParamList(ctx *FunctionParamListContext) interface{} {
return v.VisitChildren(ctx)
}

View File

@@ -1,12 +1,13 @@
// Code generated from FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
// Code generated from grammar/FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser
import (
"fmt"
"github.com/antlr4-go/antlr/v4"
"sync"
"unicode"
"github.com/antlr4-go/antlr/v4"
)
// Suppress unused import error
@@ -50,174 +51,170 @@ func filterquerylexerLexerInit() {
"", "LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS",
"REGEXP", "CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY",
"HASALL", "SEARCH", "BOOL", "NUMBER", "QUOTED_TEXT", "KEY", "WS", "FREETEXT",
"HASALL", "BOOL", "NUMBER", "QUOTED_TEXT", "KEY", "WS", "FREETEXT",
}
staticData.RuleNames = []string{
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS",
"REGEXP", "CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY",
"HASALL", "SEARCH", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT",
"EMPTY_BRACKS", "OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
"HASALL", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS",
"OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
}
staticData.PredictionContextCache = antlr.NewPredictionContextCache()
staticData.serializedATN = []int32{
4, 0, 33, 329, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2,
4, 0, 32, 320, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2,
4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2,
10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15,
7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7,
20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25,
2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2,
31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36,
7, 36, 2, 37, 7, 37, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1,
4, 1, 4, 1, 5, 1, 5, 1, 5, 3, 5, 91, 8, 5, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7,
1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11,
1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1,
13, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15,
1, 15, 1, 15, 1, 15, 1, 15, 3, 15, 134, 8, 15, 1, 16, 1, 16, 1, 16, 1,
16, 1, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17,
1, 17, 3, 17, 151, 8, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 19, 1,
19, 1, 20, 1, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 22, 1, 22, 1, 22,
1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 1, 23, 1,
24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25, 1, 25, 1, 25,
1, 25, 1, 25, 1, 25, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1,
27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 3, 27, 210,
8, 27, 1, 28, 1, 28, 1, 29, 3, 29, 215, 8, 29, 1, 29, 4, 29, 218, 8, 29,
11, 29, 12, 29, 219, 1, 29, 1, 29, 5, 29, 224, 8, 29, 10, 29, 12, 29, 227,
9, 29, 3, 29, 229, 8, 29, 1, 29, 1, 29, 3, 29, 233, 8, 29, 1, 29, 4, 29,
236, 8, 29, 11, 29, 12, 29, 237, 3, 29, 240, 8, 29, 1, 29, 3, 29, 243,
8, 29, 1, 29, 1, 29, 4, 29, 247, 8, 29, 11, 29, 12, 29, 248, 1, 29, 1,
29, 3, 29, 253, 8, 29, 1, 29, 4, 29, 256, 8, 29, 11, 29, 12, 29, 257, 3,
29, 260, 8, 29, 3, 29, 262, 8, 29, 1, 30, 1, 30, 1, 30, 1, 30, 5, 30, 268,
8, 30, 10, 30, 12, 30, 271, 9, 30, 1, 30, 1, 30, 1, 30, 1, 30, 1, 30, 5,
30, 278, 8, 30, 10, 30, 12, 30, 281, 9, 30, 1, 30, 3, 30, 284, 8, 30, 1,
31, 1, 31, 5, 31, 288, 8, 31, 10, 31, 12, 31, 291, 9, 31, 1, 32, 1, 32,
1, 32, 1, 33, 1, 33, 1, 33, 1, 33, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1,
34, 1, 34, 4, 34, 307, 8, 34, 11, 34, 12, 34, 308, 5, 34, 311, 8, 34, 10,
34, 12, 34, 314, 9, 34, 1, 35, 4, 35, 317, 8, 35, 11, 35, 12, 35, 318,
1, 35, 1, 35, 1, 36, 1, 36, 1, 37, 4, 37, 326, 8, 37, 11, 37, 12, 37, 327,
0, 0, 38, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19,
7, 36, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5,
1, 5, 1, 5, 3, 5, 89, 8, 5, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 8, 1,
8, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11, 1, 12, 1, 12, 1,
12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 14, 1, 14,
1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15, 1, 15, 1, 15, 1,
15, 1, 15, 3, 15, 132, 8, 15, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16,
1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 3, 17, 149,
8, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 19, 1, 19, 1, 20, 1, 20, 1,
20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22,
1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 1, 23, 1, 24, 1, 24, 1, 24, 1,
24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25,
1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 3, 26, 201,
8, 26, 1, 27, 1, 27, 1, 28, 3, 28, 206, 8, 28, 1, 28, 4, 28, 209, 8, 28,
11, 28, 12, 28, 210, 1, 28, 1, 28, 5, 28, 215, 8, 28, 10, 28, 12, 28, 218,
9, 28, 3, 28, 220, 8, 28, 1, 28, 1, 28, 3, 28, 224, 8, 28, 1, 28, 4, 28,
227, 8, 28, 11, 28, 12, 28, 228, 3, 28, 231, 8, 28, 1, 28, 3, 28, 234,
8, 28, 1, 28, 1, 28, 4, 28, 238, 8, 28, 11, 28, 12, 28, 239, 1, 28, 1,
28, 3, 28, 244, 8, 28, 1, 28, 4, 28, 247, 8, 28, 11, 28, 12, 28, 248, 3,
28, 251, 8, 28, 3, 28, 253, 8, 28, 1, 29, 1, 29, 1, 29, 1, 29, 5, 29, 259,
8, 29, 10, 29, 12, 29, 262, 9, 29, 1, 29, 1, 29, 1, 29, 1, 29, 1, 29, 5,
29, 269, 8, 29, 10, 29, 12, 29, 272, 9, 29, 1, 29, 3, 29, 275, 8, 29, 1,
30, 1, 30, 5, 30, 279, 8, 30, 10, 30, 12, 30, 282, 9, 30, 1, 31, 1, 31,
1, 31, 1, 32, 1, 32, 1, 32, 1, 32, 1, 33, 1, 33, 1, 33, 1, 33, 1, 33, 1,
33, 1, 33, 4, 33, 298, 8, 33, 11, 33, 12, 33, 299, 5, 33, 302, 8, 33, 10,
33, 12, 33, 305, 9, 33, 1, 34, 4, 34, 308, 8, 34, 11, 34, 12, 34, 309,
1, 34, 1, 34, 1, 35, 1, 35, 1, 36, 4, 36, 317, 8, 36, 11, 36, 12, 36, 318,
0, 0, 37, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19,
10, 21, 11, 23, 12, 25, 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37,
19, 39, 20, 41, 21, 43, 22, 45, 23, 47, 24, 49, 25, 51, 26, 53, 27, 55,
28, 57, 0, 59, 29, 61, 30, 63, 0, 65, 0, 67, 0, 69, 31, 71, 32, 73, 0,
75, 33, 1, 0, 29, 2, 0, 76, 76, 108, 108, 2, 0, 73, 73, 105, 105, 2, 0,
75, 75, 107, 107, 2, 0, 69, 69, 101, 101, 2, 0, 66, 66, 98, 98, 2, 0, 84,
84, 116, 116, 2, 0, 87, 87, 119, 119, 2, 0, 78, 78, 110, 110, 2, 0, 88,
88, 120, 120, 2, 0, 83, 83, 115, 115, 2, 0, 82, 82, 114, 114, 2, 0, 71,
71, 103, 103, 2, 0, 80, 80, 112, 112, 2, 0, 67, 67, 99, 99, 2, 0, 79, 79,
111, 111, 2, 0, 65, 65, 97, 97, 2, 0, 68, 68, 100, 100, 2, 0, 72, 72, 104,
104, 2, 0, 89, 89, 121, 121, 2, 0, 85, 85, 117, 117, 2, 0, 70, 70, 102,
102, 2, 0, 43, 43, 45, 45, 2, 0, 34, 34, 92, 92, 2, 0, 39, 39, 92, 92,
4, 0, 35, 36, 64, 90, 95, 95, 97, 123, 7, 0, 35, 36, 45, 45, 47, 58, 64,
90, 95, 95, 97, 123, 125, 125, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48, 57,
8, 0, 9, 10, 13, 13, 32, 34, 39, 41, 44, 44, 60, 62, 91, 91, 93, 93, 353,
0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0,
0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0,
0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0,
0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1,
0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39,
1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0,
47, 1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0,
0, 55, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 0, 61, 1, 0, 0, 0, 0, 69, 1, 0, 0,
0, 0, 71, 1, 0, 0, 0, 0, 75, 1, 0, 0, 0, 1, 77, 1, 0, 0, 0, 3, 79, 1, 0,
0, 0, 5, 81, 1, 0, 0, 0, 7, 83, 1, 0, 0, 0, 9, 85, 1, 0, 0, 0, 11, 90,
1, 0, 0, 0, 13, 92, 1, 0, 0, 0, 15, 95, 1, 0, 0, 0, 17, 98, 1, 0, 0, 0,
19, 100, 1, 0, 0, 0, 21, 103, 1, 0, 0, 0, 23, 105, 1, 0, 0, 0, 25, 108,
1, 0, 0, 0, 27, 113, 1, 0, 0, 0, 29, 119, 1, 0, 0, 0, 31, 127, 1, 0, 0,
0, 33, 135, 1, 0, 0, 0, 35, 142, 1, 0, 0, 0, 37, 152, 1, 0, 0, 0, 39, 155,
1, 0, 0, 0, 41, 159, 1, 0, 0, 0, 43, 163, 1, 0, 0, 0, 45, 166, 1, 0, 0,
0, 47, 175, 1, 0, 0, 0, 49, 179, 1, 0, 0, 0, 51, 186, 1, 0, 0, 0, 53, 193,
1, 0, 0, 0, 55, 209, 1, 0, 0, 0, 57, 211, 1, 0, 0, 0, 59, 261, 1, 0, 0,
0, 61, 283, 1, 0, 0, 0, 63, 285, 1, 0, 0, 0, 65, 292, 1, 0, 0, 0, 67, 295,
1, 0, 0, 0, 69, 299, 1, 0, 0, 0, 71, 316, 1, 0, 0, 0, 73, 322, 1, 0, 0,
0, 75, 325, 1, 0, 0, 0, 77, 78, 5, 40, 0, 0, 78, 2, 1, 0, 0, 0, 79, 80,
5, 41, 0, 0, 80, 4, 1, 0, 0, 0, 81, 82, 5, 91, 0, 0, 82, 6, 1, 0, 0, 0,
83, 84, 5, 93, 0, 0, 84, 8, 1, 0, 0, 0, 85, 86, 5, 44, 0, 0, 86, 10, 1,
0, 0, 0, 87, 91, 5, 61, 0, 0, 88, 89, 5, 61, 0, 0, 89, 91, 5, 61, 0, 0,
90, 87, 1, 0, 0, 0, 90, 88, 1, 0, 0, 0, 91, 12, 1, 0, 0, 0, 92, 93, 5,
33, 0, 0, 93, 94, 5, 61, 0, 0, 94, 14, 1, 0, 0, 0, 95, 96, 5, 60, 0, 0,
96, 97, 5, 62, 0, 0, 97, 16, 1, 0, 0, 0, 98, 99, 5, 60, 0, 0, 99, 18, 1,
0, 0, 0, 100, 101, 5, 60, 0, 0, 101, 102, 5, 61, 0, 0, 102, 20, 1, 0, 0,
0, 103, 104, 5, 62, 0, 0, 104, 22, 1, 0, 0, 0, 105, 106, 5, 62, 0, 0, 106,
107, 5, 61, 0, 0, 107, 24, 1, 0, 0, 0, 108, 109, 7, 0, 0, 0, 109, 110,
7, 1, 0, 0, 110, 111, 7, 2, 0, 0, 111, 112, 7, 3, 0, 0, 112, 26, 1, 0,
0, 0, 113, 114, 7, 1, 0, 0, 114, 115, 7, 0, 0, 0, 115, 116, 7, 1, 0, 0,
116, 117, 7, 2, 0, 0, 117, 118, 7, 3, 0, 0, 118, 28, 1, 0, 0, 0, 119, 120,
7, 4, 0, 0, 120, 121, 7, 3, 0, 0, 121, 122, 7, 5, 0, 0, 122, 123, 7, 6,
0, 0, 123, 124, 7, 3, 0, 0, 124, 125, 7, 3, 0, 0, 125, 126, 7, 7, 0, 0,
126, 30, 1, 0, 0, 0, 127, 128, 7, 3, 0, 0, 128, 129, 7, 8, 0, 0, 129, 130,
7, 1, 0, 0, 130, 131, 7, 9, 0, 0, 131, 133, 7, 5, 0, 0, 132, 134, 7, 9,
0, 0, 133, 132, 1, 0, 0, 0, 133, 134, 1, 0, 0, 0, 134, 32, 1, 0, 0, 0,
135, 136, 7, 10, 0, 0, 136, 137, 7, 3, 0, 0, 137, 138, 7, 11, 0, 0, 138,
139, 7, 3, 0, 0, 139, 140, 7, 8, 0, 0, 140, 141, 7, 12, 0, 0, 141, 34,
1, 0, 0, 0, 142, 143, 7, 13, 0, 0, 143, 144, 7, 14, 0, 0, 144, 145, 7,
7, 0, 0, 145, 146, 7, 5, 0, 0, 146, 147, 7, 15, 0, 0, 147, 148, 7, 1, 0,
0, 148, 150, 7, 7, 0, 0, 149, 151, 7, 9, 0, 0, 150, 149, 1, 0, 0, 0, 150,
151, 1, 0, 0, 0, 151, 36, 1, 0, 0, 0, 152, 153, 7, 1, 0, 0, 153, 154, 7,
7, 0, 0, 154, 38, 1, 0, 0, 0, 155, 156, 7, 7, 0, 0, 156, 157, 7, 14, 0,
0, 157, 158, 7, 5, 0, 0, 158, 40, 1, 0, 0, 0, 159, 160, 7, 15, 0, 0, 160,
161, 7, 7, 0, 0, 161, 162, 7, 16, 0, 0, 162, 42, 1, 0, 0, 0, 163, 164,
7, 14, 0, 0, 164, 165, 7, 10, 0, 0, 165, 44, 1, 0, 0, 0, 166, 167, 7, 17,
0, 0, 167, 168, 7, 15, 0, 0, 168, 169, 7, 9, 0, 0, 169, 170, 7, 5, 0, 0,
170, 171, 7, 14, 0, 0, 171, 172, 7, 2, 0, 0, 172, 173, 7, 3, 0, 0, 173,
174, 7, 7, 0, 0, 174, 46, 1, 0, 0, 0, 175, 176, 7, 17, 0, 0, 176, 177,
7, 15, 0, 0, 177, 178, 7, 9, 0, 0, 178, 48, 1, 0, 0, 0, 179, 180, 7, 17,
0, 0, 180, 181, 7, 15, 0, 0, 181, 182, 7, 9, 0, 0, 182, 183, 7, 15, 0,
0, 183, 184, 7, 7, 0, 0, 184, 185, 7, 18, 0, 0, 185, 50, 1, 0, 0, 0, 186,
187, 7, 17, 0, 0, 187, 188, 7, 15, 0, 0, 188, 189, 7, 9, 0, 0, 189, 190,
7, 15, 0, 0, 190, 191, 7, 0, 0, 0, 191, 192, 7, 0, 0, 0, 192, 52, 1, 0,
0, 0, 193, 194, 7, 9, 0, 0, 194, 195, 7, 3, 0, 0, 195, 196, 7, 15, 0, 0,
196, 197, 7, 10, 0, 0, 197, 198, 7, 13, 0, 0, 198, 199, 7, 17, 0, 0, 199,
54, 1, 0, 0, 0, 200, 201, 7, 5, 0, 0, 201, 202, 7, 10, 0, 0, 202, 203,
7, 19, 0, 0, 203, 210, 7, 3, 0, 0, 204, 205, 7, 20, 0, 0, 205, 206, 7,
15, 0, 0, 206, 207, 7, 0, 0, 0, 207, 208, 7, 9, 0, 0, 208, 210, 7, 3, 0,
0, 209, 200, 1, 0, 0, 0, 209, 204, 1, 0, 0, 0, 210, 56, 1, 0, 0, 0, 211,
212, 7, 21, 0, 0, 212, 58, 1, 0, 0, 0, 213, 215, 3, 57, 28, 0, 214, 213,
1, 0, 0, 0, 214, 215, 1, 0, 0, 0, 215, 217, 1, 0, 0, 0, 216, 218, 3, 73,
36, 0, 217, 216, 1, 0, 0, 0, 218, 219, 1, 0, 0, 0, 219, 217, 1, 0, 0, 0,
219, 220, 1, 0, 0, 0, 220, 228, 1, 0, 0, 0, 221, 225, 5, 46, 0, 0, 222,
224, 3, 73, 36, 0, 223, 222, 1, 0, 0, 0, 224, 227, 1, 0, 0, 0, 225, 223,
1, 0, 0, 0, 225, 226, 1, 0, 0, 0, 226, 229, 1, 0, 0, 0, 227, 225, 1, 0,
0, 0, 228, 221, 1, 0, 0, 0, 228, 229, 1, 0, 0, 0, 229, 239, 1, 0, 0, 0,
230, 232, 7, 3, 0, 0, 231, 233, 3, 57, 28, 0, 232, 231, 1, 0, 0, 0, 232,
233, 1, 0, 0, 0, 233, 235, 1, 0, 0, 0, 234, 236, 3, 73, 36, 0, 235, 234,
1, 0, 0, 0, 236, 237, 1, 0, 0, 0, 237, 235, 1, 0, 0, 0, 237, 238, 1, 0,
0, 0, 238, 240, 1, 0, 0, 0, 239, 230, 1, 0, 0, 0, 239, 240, 1, 0, 0, 0,
240, 262, 1, 0, 0, 0, 241, 243, 3, 57, 28, 0, 242, 241, 1, 0, 0, 0, 242,
243, 1, 0, 0, 0, 243, 244, 1, 0, 0, 0, 244, 246, 5, 46, 0, 0, 245, 247,
3, 73, 36, 0, 246, 245, 1, 0, 0, 0, 247, 248, 1, 0, 0, 0, 248, 246, 1,
0, 0, 0, 248, 249, 1, 0, 0, 0, 249, 259, 1, 0, 0, 0, 250, 252, 7, 3, 0,
0, 251, 253, 3, 57, 28, 0, 252, 251, 1, 0, 0, 0, 252, 253, 1, 0, 0, 0,
253, 255, 1, 0, 0, 0, 254, 256, 3, 73, 36, 0, 255, 254, 1, 0, 0, 0, 256,
257, 1, 0, 0, 0, 257, 255, 1, 0, 0, 0, 257, 258, 1, 0, 0, 0, 258, 260,
1, 0, 0, 0, 259, 250, 1, 0, 0, 0, 259, 260, 1, 0, 0, 0, 260, 262, 1, 0,
0, 0, 261, 214, 1, 0, 0, 0, 261, 242, 1, 0, 0, 0, 262, 60, 1, 0, 0, 0,
263, 269, 5, 34, 0, 0, 264, 268, 8, 22, 0, 0, 265, 266, 5, 92, 0, 0, 266,
268, 9, 0, 0, 0, 267, 264, 1, 0, 0, 0, 267, 265, 1, 0, 0, 0, 268, 271,
1, 0, 0, 0, 269, 267, 1, 0, 0, 0, 269, 270, 1, 0, 0, 0, 270, 272, 1, 0,
0, 0, 271, 269, 1, 0, 0, 0, 272, 284, 5, 34, 0, 0, 273, 279, 5, 39, 0,
0, 274, 278, 8, 23, 0, 0, 275, 276, 5, 92, 0, 0, 276, 278, 9, 0, 0, 0,
277, 274, 1, 0, 0, 0, 277, 275, 1, 0, 0, 0, 278, 281, 1, 0, 0, 0, 279,
277, 1, 0, 0, 0, 279, 280, 1, 0, 0, 0, 280, 282, 1, 0, 0, 0, 281, 279,
1, 0, 0, 0, 282, 284, 5, 39, 0, 0, 283, 263, 1, 0, 0, 0, 283, 273, 1, 0,
0, 0, 284, 62, 1, 0, 0, 0, 285, 289, 7, 24, 0, 0, 286, 288, 7, 25, 0, 0,
287, 286, 1, 0, 0, 0, 288, 291, 1, 0, 0, 0, 289, 287, 1, 0, 0, 0, 289,
290, 1, 0, 0, 0, 290, 64, 1, 0, 0, 0, 291, 289, 1, 0, 0, 0, 292, 293, 5,
91, 0, 0, 293, 294, 5, 93, 0, 0, 294, 66, 1, 0, 0, 0, 295, 296, 5, 91,
0, 0, 296, 297, 5, 42, 0, 0, 297, 298, 5, 93, 0, 0, 298, 68, 1, 0, 0, 0,
299, 312, 3, 63, 31, 0, 300, 301, 5, 46, 0, 0, 301, 311, 3, 63, 31, 0,
302, 311, 3, 65, 32, 0, 303, 311, 3, 67, 33, 0, 304, 306, 5, 46, 0, 0,
305, 307, 3, 73, 36, 0, 306, 305, 1, 0, 0, 0, 307, 308, 1, 0, 0, 0, 308,
306, 1, 0, 0, 0, 308, 309, 1, 0, 0, 0, 309, 311, 1, 0, 0, 0, 310, 300,
1, 0, 0, 0, 310, 302, 1, 0, 0, 0, 310, 303, 1, 0, 0, 0, 310, 304, 1, 0,
0, 0, 311, 314, 1, 0, 0, 0, 312, 310, 1, 0, 0, 0, 312, 313, 1, 0, 0, 0,
313, 70, 1, 0, 0, 0, 314, 312, 1, 0, 0, 0, 315, 317, 7, 26, 0, 0, 316,
315, 1, 0, 0, 0, 317, 318, 1, 0, 0, 0, 318, 316, 1, 0, 0, 0, 318, 319,
1, 0, 0, 0, 319, 320, 1, 0, 0, 0, 320, 321, 6, 35, 0, 0, 321, 72, 1, 0,
0, 0, 322, 323, 7, 27, 0, 0, 323, 74, 1, 0, 0, 0, 324, 326, 8, 28, 0, 0,
325, 324, 1, 0, 0, 0, 326, 327, 1, 0, 0, 0, 327, 325, 1, 0, 0, 0, 327,
328, 1, 0, 0, 0, 328, 76, 1, 0, 0, 0, 29, 0, 90, 133, 150, 209, 214, 219,
225, 228, 232, 237, 239, 242, 248, 252, 257, 259, 261, 267, 269, 277, 279,
283, 289, 308, 310, 312, 318, 327, 1, 6, 0, 0,
0, 57, 28, 59, 29, 61, 0, 63, 0, 65, 0, 67, 30, 69, 31, 71, 0, 73, 32,
1, 0, 29, 2, 0, 76, 76, 108, 108, 2, 0, 73, 73, 105, 105, 2, 0, 75, 75,
107, 107, 2, 0, 69, 69, 101, 101, 2, 0, 66, 66, 98, 98, 2, 0, 84, 84, 116,
116, 2, 0, 87, 87, 119, 119, 2, 0, 78, 78, 110, 110, 2, 0, 88, 88, 120,
120, 2, 0, 83, 83, 115, 115, 2, 0, 82, 82, 114, 114, 2, 0, 71, 71, 103,
103, 2, 0, 80, 80, 112, 112, 2, 0, 67, 67, 99, 99, 2, 0, 79, 79, 111, 111,
2, 0, 65, 65, 97, 97, 2, 0, 68, 68, 100, 100, 2, 0, 72, 72, 104, 104, 2,
0, 89, 89, 121, 121, 2, 0, 85, 85, 117, 117, 2, 0, 70, 70, 102, 102, 2,
0, 43, 43, 45, 45, 2, 0, 34, 34, 92, 92, 2, 0, 39, 39, 92, 92, 4, 0, 35,
36, 64, 90, 95, 95, 97, 123, 7, 0, 35, 36, 45, 45, 47, 58, 64, 90, 95,
95, 97, 123, 125, 125, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48, 57, 8, 0,
9, 10, 13, 13, 32, 34, 39, 41, 44, 44, 60, 62, 91, 91, 93, 93, 344, 0,
1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0,
9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0,
0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0,
0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1, 0,
0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39, 1,
0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0, 47,
1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0, 0,
57, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 0, 67, 1, 0, 0, 0, 0, 69, 1, 0, 0, 0,
0, 73, 1, 0, 0, 0, 1, 75, 1, 0, 0, 0, 3, 77, 1, 0, 0, 0, 5, 79, 1, 0, 0,
0, 7, 81, 1, 0, 0, 0, 9, 83, 1, 0, 0, 0, 11, 88, 1, 0, 0, 0, 13, 90, 1,
0, 0, 0, 15, 93, 1, 0, 0, 0, 17, 96, 1, 0, 0, 0, 19, 98, 1, 0, 0, 0, 21,
101, 1, 0, 0, 0, 23, 103, 1, 0, 0, 0, 25, 106, 1, 0, 0, 0, 27, 111, 1,
0, 0, 0, 29, 117, 1, 0, 0, 0, 31, 125, 1, 0, 0, 0, 33, 133, 1, 0, 0, 0,
35, 140, 1, 0, 0, 0, 37, 150, 1, 0, 0, 0, 39, 153, 1, 0, 0, 0, 41, 157,
1, 0, 0, 0, 43, 161, 1, 0, 0, 0, 45, 164, 1, 0, 0, 0, 47, 173, 1, 0, 0,
0, 49, 177, 1, 0, 0, 0, 51, 184, 1, 0, 0, 0, 53, 200, 1, 0, 0, 0, 55, 202,
1, 0, 0, 0, 57, 252, 1, 0, 0, 0, 59, 274, 1, 0, 0, 0, 61, 276, 1, 0, 0,
0, 63, 283, 1, 0, 0, 0, 65, 286, 1, 0, 0, 0, 67, 290, 1, 0, 0, 0, 69, 307,
1, 0, 0, 0, 71, 313, 1, 0, 0, 0, 73, 316, 1, 0, 0, 0, 75, 76, 5, 40, 0,
0, 76, 2, 1, 0, 0, 0, 77, 78, 5, 41, 0, 0, 78, 4, 1, 0, 0, 0, 79, 80, 5,
91, 0, 0, 80, 6, 1, 0, 0, 0, 81, 82, 5, 93, 0, 0, 82, 8, 1, 0, 0, 0, 83,
84, 5, 44, 0, 0, 84, 10, 1, 0, 0, 0, 85, 89, 5, 61, 0, 0, 86, 87, 5, 61,
0, 0, 87, 89, 5, 61, 0, 0, 88, 85, 1, 0, 0, 0, 88, 86, 1, 0, 0, 0, 89,
12, 1, 0, 0, 0, 90, 91, 5, 33, 0, 0, 91, 92, 5, 61, 0, 0, 92, 14, 1, 0,
0, 0, 93, 94, 5, 60, 0, 0, 94, 95, 5, 62, 0, 0, 95, 16, 1, 0, 0, 0, 96,
97, 5, 60, 0, 0, 97, 18, 1, 0, 0, 0, 98, 99, 5, 60, 0, 0, 99, 100, 5, 61,
0, 0, 100, 20, 1, 0, 0, 0, 101, 102, 5, 62, 0, 0, 102, 22, 1, 0, 0, 0,
103, 104, 5, 62, 0, 0, 104, 105, 5, 61, 0, 0, 105, 24, 1, 0, 0, 0, 106,
107, 7, 0, 0, 0, 107, 108, 7, 1, 0, 0, 108, 109, 7, 2, 0, 0, 109, 110,
7, 3, 0, 0, 110, 26, 1, 0, 0, 0, 111, 112, 7, 1, 0, 0, 112, 113, 7, 0,
0, 0, 113, 114, 7, 1, 0, 0, 114, 115, 7, 2, 0, 0, 115, 116, 7, 3, 0, 0,
116, 28, 1, 0, 0, 0, 117, 118, 7, 4, 0, 0, 118, 119, 7, 3, 0, 0, 119, 120,
7, 5, 0, 0, 120, 121, 7, 6, 0, 0, 121, 122, 7, 3, 0, 0, 122, 123, 7, 3,
0, 0, 123, 124, 7, 7, 0, 0, 124, 30, 1, 0, 0, 0, 125, 126, 7, 3, 0, 0,
126, 127, 7, 8, 0, 0, 127, 128, 7, 1, 0, 0, 128, 129, 7, 9, 0, 0, 129,
131, 7, 5, 0, 0, 130, 132, 7, 9, 0, 0, 131, 130, 1, 0, 0, 0, 131, 132,
1, 0, 0, 0, 132, 32, 1, 0, 0, 0, 133, 134, 7, 10, 0, 0, 134, 135, 7, 3,
0, 0, 135, 136, 7, 11, 0, 0, 136, 137, 7, 3, 0, 0, 137, 138, 7, 8, 0, 0,
138, 139, 7, 12, 0, 0, 139, 34, 1, 0, 0, 0, 140, 141, 7, 13, 0, 0, 141,
142, 7, 14, 0, 0, 142, 143, 7, 7, 0, 0, 143, 144, 7, 5, 0, 0, 144, 145,
7, 15, 0, 0, 145, 146, 7, 1, 0, 0, 146, 148, 7, 7, 0, 0, 147, 149, 7, 9,
0, 0, 148, 147, 1, 0, 0, 0, 148, 149, 1, 0, 0, 0, 149, 36, 1, 0, 0, 0,
150, 151, 7, 1, 0, 0, 151, 152, 7, 7, 0, 0, 152, 38, 1, 0, 0, 0, 153, 154,
7, 7, 0, 0, 154, 155, 7, 14, 0, 0, 155, 156, 7, 5, 0, 0, 156, 40, 1, 0,
0, 0, 157, 158, 7, 15, 0, 0, 158, 159, 7, 7, 0, 0, 159, 160, 7, 16, 0,
0, 160, 42, 1, 0, 0, 0, 161, 162, 7, 14, 0, 0, 162, 163, 7, 10, 0, 0, 163,
44, 1, 0, 0, 0, 164, 165, 7, 17, 0, 0, 165, 166, 7, 15, 0, 0, 166, 167,
7, 9, 0, 0, 167, 168, 7, 5, 0, 0, 168, 169, 7, 14, 0, 0, 169, 170, 7, 2,
0, 0, 170, 171, 7, 3, 0, 0, 171, 172, 7, 7, 0, 0, 172, 46, 1, 0, 0, 0,
173, 174, 7, 17, 0, 0, 174, 175, 7, 15, 0, 0, 175, 176, 7, 9, 0, 0, 176,
48, 1, 0, 0, 0, 177, 178, 7, 17, 0, 0, 178, 179, 7, 15, 0, 0, 179, 180,
7, 9, 0, 0, 180, 181, 7, 15, 0, 0, 181, 182, 7, 7, 0, 0, 182, 183, 7, 18,
0, 0, 183, 50, 1, 0, 0, 0, 184, 185, 7, 17, 0, 0, 185, 186, 7, 15, 0, 0,
186, 187, 7, 9, 0, 0, 187, 188, 7, 15, 0, 0, 188, 189, 7, 0, 0, 0, 189,
190, 7, 0, 0, 0, 190, 52, 1, 0, 0, 0, 191, 192, 7, 5, 0, 0, 192, 193, 7,
10, 0, 0, 193, 194, 7, 19, 0, 0, 194, 201, 7, 3, 0, 0, 195, 196, 7, 20,
0, 0, 196, 197, 7, 15, 0, 0, 197, 198, 7, 0, 0, 0, 198, 199, 7, 9, 0, 0,
199, 201, 7, 3, 0, 0, 200, 191, 1, 0, 0, 0, 200, 195, 1, 0, 0, 0, 201,
54, 1, 0, 0, 0, 202, 203, 7, 21, 0, 0, 203, 56, 1, 0, 0, 0, 204, 206, 3,
55, 27, 0, 205, 204, 1, 0, 0, 0, 205, 206, 1, 0, 0, 0, 206, 208, 1, 0,
0, 0, 207, 209, 3, 71, 35, 0, 208, 207, 1, 0, 0, 0, 209, 210, 1, 0, 0,
0, 210, 208, 1, 0, 0, 0, 210, 211, 1, 0, 0, 0, 211, 219, 1, 0, 0, 0, 212,
216, 5, 46, 0, 0, 213, 215, 3, 71, 35, 0, 214, 213, 1, 0, 0, 0, 215, 218,
1, 0, 0, 0, 216, 214, 1, 0, 0, 0, 216, 217, 1, 0, 0, 0, 217, 220, 1, 0,
0, 0, 218, 216, 1, 0, 0, 0, 219, 212, 1, 0, 0, 0, 219, 220, 1, 0, 0, 0,
220, 230, 1, 0, 0, 0, 221, 223, 7, 3, 0, 0, 222, 224, 3, 55, 27, 0, 223,
222, 1, 0, 0, 0, 223, 224, 1, 0, 0, 0, 224, 226, 1, 0, 0, 0, 225, 227,
3, 71, 35, 0, 226, 225, 1, 0, 0, 0, 227, 228, 1, 0, 0, 0, 228, 226, 1,
0, 0, 0, 228, 229, 1, 0, 0, 0, 229, 231, 1, 0, 0, 0, 230, 221, 1, 0, 0,
0, 230, 231, 1, 0, 0, 0, 231, 253, 1, 0, 0, 0, 232, 234, 3, 55, 27, 0,
233, 232, 1, 0, 0, 0, 233, 234, 1, 0, 0, 0, 234, 235, 1, 0, 0, 0, 235,
237, 5, 46, 0, 0, 236, 238, 3, 71, 35, 0, 237, 236, 1, 0, 0, 0, 238, 239,
1, 0, 0, 0, 239, 237, 1, 0, 0, 0, 239, 240, 1, 0, 0, 0, 240, 250, 1, 0,
0, 0, 241, 243, 7, 3, 0, 0, 242, 244, 3, 55, 27, 0, 243, 242, 1, 0, 0,
0, 243, 244, 1, 0, 0, 0, 244, 246, 1, 0, 0, 0, 245, 247, 3, 71, 35, 0,
246, 245, 1, 0, 0, 0, 247, 248, 1, 0, 0, 0, 248, 246, 1, 0, 0, 0, 248,
249, 1, 0, 0, 0, 249, 251, 1, 0, 0, 0, 250, 241, 1, 0, 0, 0, 250, 251,
1, 0, 0, 0, 251, 253, 1, 0, 0, 0, 252, 205, 1, 0, 0, 0, 252, 233, 1, 0,
0, 0, 253, 58, 1, 0, 0, 0, 254, 260, 5, 34, 0, 0, 255, 259, 8, 22, 0, 0,
256, 257, 5, 92, 0, 0, 257, 259, 9, 0, 0, 0, 258, 255, 1, 0, 0, 0, 258,
256, 1, 0, 0, 0, 259, 262, 1, 0, 0, 0, 260, 258, 1, 0, 0, 0, 260, 261,
1, 0, 0, 0, 261, 263, 1, 0, 0, 0, 262, 260, 1, 0, 0, 0, 263, 275, 5, 34,
0, 0, 264, 270, 5, 39, 0, 0, 265, 269, 8, 23, 0, 0, 266, 267, 5, 92, 0,
0, 267, 269, 9, 0, 0, 0, 268, 265, 1, 0, 0, 0, 268, 266, 1, 0, 0, 0, 269,
272, 1, 0, 0, 0, 270, 268, 1, 0, 0, 0, 270, 271, 1, 0, 0, 0, 271, 273,
1, 0, 0, 0, 272, 270, 1, 0, 0, 0, 273, 275, 5, 39, 0, 0, 274, 254, 1, 0,
0, 0, 274, 264, 1, 0, 0, 0, 275, 60, 1, 0, 0, 0, 276, 280, 7, 24, 0, 0,
277, 279, 7, 25, 0, 0, 278, 277, 1, 0, 0, 0, 279, 282, 1, 0, 0, 0, 280,
278, 1, 0, 0, 0, 280, 281, 1, 0, 0, 0, 281, 62, 1, 0, 0, 0, 282, 280, 1,
0, 0, 0, 283, 284, 5, 91, 0, 0, 284, 285, 5, 93, 0, 0, 285, 64, 1, 0, 0,
0, 286, 287, 5, 91, 0, 0, 287, 288, 5, 42, 0, 0, 288, 289, 5, 93, 0, 0,
289, 66, 1, 0, 0, 0, 290, 303, 3, 61, 30, 0, 291, 292, 5, 46, 0, 0, 292,
302, 3, 61, 30, 0, 293, 302, 3, 63, 31, 0, 294, 302, 3, 65, 32, 0, 295,
297, 5, 46, 0, 0, 296, 298, 3, 71, 35, 0, 297, 296, 1, 0, 0, 0, 298, 299,
1, 0, 0, 0, 299, 297, 1, 0, 0, 0, 299, 300, 1, 0, 0, 0, 300, 302, 1, 0,
0, 0, 301, 291, 1, 0, 0, 0, 301, 293, 1, 0, 0, 0, 301, 294, 1, 0, 0, 0,
301, 295, 1, 0, 0, 0, 302, 305, 1, 0, 0, 0, 303, 301, 1, 0, 0, 0, 303,
304, 1, 0, 0, 0, 304, 68, 1, 0, 0, 0, 305, 303, 1, 0, 0, 0, 306, 308, 7,
26, 0, 0, 307, 306, 1, 0, 0, 0, 308, 309, 1, 0, 0, 0, 309, 307, 1, 0, 0,
0, 309, 310, 1, 0, 0, 0, 310, 311, 1, 0, 0, 0, 311, 312, 6, 34, 0, 0, 312,
70, 1, 0, 0, 0, 313, 314, 7, 27, 0, 0, 314, 72, 1, 0, 0, 0, 315, 317, 8,
28, 0, 0, 316, 315, 1, 0, 0, 0, 317, 318, 1, 0, 0, 0, 318, 316, 1, 0, 0,
0, 318, 319, 1, 0, 0, 0, 319, 74, 1, 0, 0, 0, 29, 0, 88, 131, 148, 200,
205, 210, 216, 219, 223, 228, 230, 233, 239, 243, 248, 250, 252, 258, 260,
268, 270, 274, 280, 299, 301, 303, 309, 318, 1, 6, 0, 0,
}
deserializer := antlr.NewATNDeserializer(nil)
staticData.atn = deserializer.Deserialize(staticData.serializedATN)
@@ -284,11 +281,10 @@ const (
FilterQueryLexerHAS = 24
FilterQueryLexerHASANY = 25
FilterQueryLexerHASALL = 26
FilterQueryLexerSEARCH = 27
FilterQueryLexerBOOL = 28
FilterQueryLexerNUMBER = 29
FilterQueryLexerQUOTED_TEXT = 30
FilterQueryLexerKEY = 31
FilterQueryLexerWS = 32
FilterQueryLexerFREETEXT = 33
FilterQueryLexerBOOL = 27
FilterQueryLexerNUMBER = 28
FilterQueryLexerQUOTED_TEXT = 29
FilterQueryLexerKEY = 30
FilterQueryLexerWS = 31
FilterQueryLexerFREETEXT = 32
)

View File

@@ -1,4 +1,4 @@
// Code generated from FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
// Code generated from grammar/FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // FilterQuery
@@ -44,9 +44,6 @@ type FilterQueryListener interface {
// EnterFunctionCall is called when entering the functionCall production.
EnterFunctionCall(c *FunctionCallContext)
// EnterSearchCall is called when entering the searchCall production.
EnterSearchCall(c *SearchCallContext)
// EnterFunctionParamList is called when entering the functionParamList production.
EnterFunctionParamList(c *FunctionParamListContext)
@@ -98,9 +95,6 @@ type FilterQueryListener interface {
// ExitFunctionCall is called when exiting the functionCall production.
ExitFunctionCall(c *FunctionCallContext)
// ExitSearchCall is called when exiting the searchCall production.
ExitSearchCall(c *SearchCallContext)
// ExitFunctionParamList is called when exiting the functionParamList production.
ExitFunctionParamList(c *FunctionParamListContext)

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
// Code generated from FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
// Code generated from grammar/FilterQuery.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // FilterQuery
@@ -44,9 +44,6 @@ type FilterQueryVisitor interface {
// Visit a parse tree produced by FilterQueryParser#functionCall.
VisitFunctionCall(ctx *FunctionCallContext) interface{}
// Visit a parse tree produced by FilterQueryParser#searchCall.
VisitSearchCall(ctx *SearchCallContext) interface{}
// Visit a parse tree produced by FilterQueryParser#functionParamList.
VisitFunctionParamList(ctx *FunctionParamListContext) interface{}

View File

@@ -32,7 +32,7 @@ var friendly = map[string]string{
"BETWEEN": "BETWEEN", "IN": "IN", "EXISTS": "EXISTS",
"REGEXP": "REGEXP", "CONTAINS": "CONTAINS",
"HAS": "has()", "HASANY": "hasAny()", "HASALL": "hasAll()",
"HASTOKEN": "hasToken()", "SEARCH": "search()",
"HASTOKEN": "hasToken()",
// literals / identifiers
"NUMBER": "number",

View File

@@ -15,8 +15,8 @@ import (
type FieldConstraint struct {
Field string
Operator qbtypes.FilterOperator
Value any
Values []any // For IN, NOT IN operations
Value interface{}
Values []interface{} // For IN, NOT IN operations
}
// ConstraintSet represents a set of constraints that must all be true (AND).
@@ -103,7 +103,7 @@ func (d *LogicalContradictionDetector) popNotContext() {
}
// Visit dispatches to the appropriate visit method.
func (d *LogicalContradictionDetector) Visit(tree antlr.ParseTree) any {
func (d *LogicalContradictionDetector) Visit(tree antlr.ParseTree) interface{} {
if tree == nil {
return nil
}
@@ -111,7 +111,7 @@ func (d *LogicalContradictionDetector) Visit(tree antlr.ParseTree) any {
}
// VisitQuery is the entry point.
func (d *LogicalContradictionDetector) VisitQuery(ctx *grammar.QueryContext) any {
func (d *LogicalContradictionDetector) VisitQuery(ctx *grammar.QueryContext) interface{} {
d.Visit(ctx.Expression())
// Check final constraints
d.checkContradictions(d.currentConstraints())
@@ -119,12 +119,12 @@ func (d *LogicalContradictionDetector) VisitQuery(ctx *grammar.QueryContext) any
}
// VisitExpression just passes through to OrExpression.
func (d *LogicalContradictionDetector) VisitExpression(ctx *grammar.ExpressionContext) any {
func (d *LogicalContradictionDetector) VisitExpression(ctx *grammar.ExpressionContext) interface{} {
return d.Visit(ctx.OrExpression())
}
// VisitOrExpression handles OR logic.
func (d *LogicalContradictionDetector) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
func (d *LogicalContradictionDetector) VisitOrExpression(ctx *grammar.OrExpressionContext) interface{} {
andExpressions := ctx.AllAndExpression()
if len(andExpressions) == 1 {
@@ -149,7 +149,7 @@ func (d *LogicalContradictionDetector) VisitOrExpression(ctx *grammar.OrExpressi
}
// VisitAndExpression handles AND logic (including implicit AND).
func (d *LogicalContradictionDetector) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
func (d *LogicalContradictionDetector) VisitAndExpression(ctx *grammar.AndExpressionContext) interface{} {
unaryExpressions := ctx.AllUnaryExpression()
// Visit each unary expression, accumulating constraints
@@ -161,7 +161,7 @@ func (d *LogicalContradictionDetector) VisitAndExpression(ctx *grammar.AndExpres
}
// VisitUnaryExpression handles NOT operator.
func (d *LogicalContradictionDetector) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
func (d *LogicalContradictionDetector) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) interface{} {
hasNot := ctx.NOT() != nil
if hasNot {
@@ -180,7 +180,7 @@ func (d *LogicalContradictionDetector) VisitUnaryExpression(ctx *grammar.UnaryEx
}
// VisitPrimary handles different primary expressions.
func (d *LogicalContradictionDetector) VisitPrimary(ctx *grammar.PrimaryContext) any {
func (d *LogicalContradictionDetector) VisitPrimary(ctx *grammar.PrimaryContext) interface{} {
if ctx.OrExpression() != nil {
// Parenthesized expression
// If we're in an AND context, we continue with the same constraint set
@@ -191,9 +191,6 @@ func (d *LogicalContradictionDetector) VisitPrimary(ctx *grammar.PrimaryContext)
} else if ctx.FunctionCall() != nil {
// Handle function calls if needed
return nil
} else if ctx.SearchCall() != nil {
// search() spans all fields; it can never be a logical contradiction
return nil
} else if ctx.FullText() != nil {
// Handle full text search if needed
return nil
@@ -203,7 +200,7 @@ func (d *LogicalContradictionDetector) VisitPrimary(ctx *grammar.PrimaryContext)
}
// VisitComparison extracts constraints from comparisons.
func (d *LogicalContradictionDetector) VisitComparison(ctx *grammar.ComparisonContext) any {
func (d *LogicalContradictionDetector) VisitComparison(ctx *grammar.ComparisonContext) interface{} {
if ctx.Key() == nil {
return nil
}
@@ -277,7 +274,7 @@ func (d *LogicalContradictionDetector) VisitComparison(ctx *grammar.ComparisonCo
constraint := FieldConstraint{
Field: field,
Operator: operator,
Values: []any{val1, val2},
Values: []interface{}{val1, val2},
}
d.addConstraint(constraint)
}
@@ -346,7 +343,7 @@ func (d *LogicalContradictionDetector) VisitComparison(ctx *grammar.ComparisonCo
}
// extractValue extracts the actual value from a ValueContext.
func (d *LogicalContradictionDetector) extractValue(ctx grammar.IValueContext) any {
func (d *LogicalContradictionDetector) extractValue(ctx grammar.IValueContext) interface{} {
if ctx.QUOTED_TEXT() != nil {
text := ctx.QUOTED_TEXT().GetText()
// Remove quotes
@@ -365,12 +362,12 @@ func (d *LogicalContradictionDetector) extractValue(ctx grammar.IValueContext) a
}
// extractValueList extracts values from a ValueListContext.
func (d *LogicalContradictionDetector) extractValueList(ctx grammar.IValueListContext) []any {
func (d *LogicalContradictionDetector) extractValueList(ctx grammar.IValueListContext) []interface{} {
if ctx == nil {
return nil
}
values := []any{}
values := []interface{}{}
for _, val := range ctx.AllValue() {
values = append(values, d.extractValue(val))
}
@@ -766,7 +763,7 @@ func (d *LogicalContradictionDetector) checkRangeContradictions(constraints []Fi
}
// valuesSatisfyRanges checks if a value satisfies all range constraints.
func (d *LogicalContradictionDetector) valuesSatisfyRanges(value any, constraints []FieldConstraint) bool {
func (d *LogicalContradictionDetector) valuesSatisfyRanges(value interface{}, constraints []FieldConstraint) bool {
val, err := parseNumericValue(value)
if err != nil {
return true // If not numeric, we can't check
@@ -802,7 +799,7 @@ func (d *LogicalContradictionDetector) valuesSatisfyRanges(value any, constraint
}
// valueSatisfiesBetween checks if a value is within a BETWEEN range.
func (d *LogicalContradictionDetector) valueSatisfiesBetween(value any, between FieldConstraint) bool {
func (d *LogicalContradictionDetector) valueSatisfiesBetween(value interface{}, between FieldConstraint) bool {
if len(between.Values) != 2 {
return false
}
@@ -851,7 +848,7 @@ func (d *LogicalContradictionDetector) cloneConstraintSet(set *ConstraintSet) *C
}
// parseNumericValue attempts to parse a value as a number.
func parseNumericValue(value any) (float64, error) {
func parseNumericValue(value interface{}) (float64, error) {
switch v := value.(type) {
case float64:
return v, nil

View File

@@ -203,8 +203,6 @@ func (v *filterExpressionVisitor) Visit(tree antlr.ParseTree) any {
return v.VisitValueList(t)
case *grammar.FullTextContext:
return v.VisitFullText(t)
case *grammar.SearchCallContext:
return v.VisitSearchCall(t)
case *grammar.FunctionCallContext:
return v.VisitFunctionCall(t)
case *grammar.FunctionParamListContext:
@@ -319,8 +317,6 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
return v.Visit(ctx.Comparison())
} else if ctx.FunctionCall() != nil {
return v.Visit(ctx.FunctionCall())
} else if ctx.SearchCall() != nil {
return v.Visit(ctx.SearchCall())
} else if ctx.FullText() != nil {
return v.Visit(ctx.FullText())
}
@@ -776,13 +772,6 @@ func normalizeFunctionValue(operator qbtypes.FilterOperator, functionName string
return valueParams, nil
}
// VisitSearchCall handles search('needle'). The search() function is parsed but
// not yet implemented; reject it with a clear invalid-input error.
func (v *filterExpressionVisitor) VisitSearchCall(ctx *grammar.SearchCallContext) any {
v.errors = append(v.errors, "function `search` is not yet supported")
return ErrorConditionLiteral
}
// VisitFunctionParamList handles the parameter list for function calls.
func (v *filterExpressionVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {
params := ctx.AllFunctionParam()

View File

@@ -221,7 +221,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
sqlmigration.NewAddTagRelationRankFactory(sqlstore, sqlschema),
)
}

View File

@@ -1,72 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTagRelationRank struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagRelationRankFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tag_relation_rank"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTagRelationRank{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTagRelationRank) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTagRelationRank) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("tag_relation"))
if err != nil {
return err
}
rankColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("rank"),
DataType: sqlschema.DataTypeInteger,
Nullable: false,
}
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, rankColumn, 0)
// AddColumn recreates the table for a NOT NULL column on SQLite, which drops
// standalone indices. GetTable only reports inline table constraints, so
// restore the standalone unique index from migration 082.
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: sqlschema.TableName("tag_relation"),
ColumnNames: []sqlschema.ColumnName{"kind", "resource_id", "tag_id"},
})...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTagRelationRank) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -115,7 +115,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Single word",
query: "<script>alert('xss')</script>",
shouldPass: false,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '<'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got '<'",
},
// Single word searches with spaces
@@ -181,7 +181,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "[tracing]",
shouldPass: false,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '['",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got '['",
},
{
category: "Special characters",
@@ -211,7 +211,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "ERROR: cannot execute update() in a read-only context",
shouldPass: false,
expectedErrorContains: "expecting one of {(, AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got ')'",
expectedErrorContains: "expecting one of {(, AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got ')'",
},
{
category: "Special characters",
@@ -633,7 +633,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'and'",
expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'and'",
},
{
category: "Keyword conflict",
@@ -641,7 +641,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'or'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'or'",
},
{
category: "Keyword conflict",
@@ -649,7 +649,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got EOF",
expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got EOF",
},
{
category: "Keyword conflict",
@@ -657,7 +657,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'like'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'like'",
},
{
category: "Keyword conflict",
@@ -665,7 +665,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'between'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'between'",
},
{
category: "Keyword conflict",
@@ -673,7 +673,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'in'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'in'",
},
{
category: "Keyword conflict",
@@ -681,7 +681,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'exists'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'exists'",
},
{
category: "Keyword conflict",
@@ -689,7 +689,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'regexp'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'regexp'",
},
{
category: "Keyword conflict",
@@ -697,7 +697,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'contains'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'contains'",
},
{
category: "Keyword conflict",
@@ -2052,9 +2052,9 @@ func TestFilterExprLogs(t *testing.T) {
expectedErrorContains: "",
},
{category: "Only keywords", query: "AND", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'AND'"},
{category: "Only keywords", query: "OR", shouldPass: false, expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'OR'"},
{category: "Only keywords", query: "NOT", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got EOF"},
{category: "Only keywords", query: "AND", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'AND'"},
{category: "Only keywords", query: "OR", shouldPass: false, expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'OR'"},
{category: "Only keywords", query: "NOT", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got EOF"},
{category: "Only functions", query: "has", shouldPass: false, expectedErrorContains: "expecting one of {(, )} but got EOF"},
{category: "Only functions", query: "hasAny", shouldPass: false, expectedErrorContains: "expecting one of {(, )} but got EOF"},
@@ -2196,7 +2196,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'and'",
expectedErrorContains: "line 1:0 expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'and'",
},
{
category: "Operator keywords as keys",
@@ -2204,7 +2204,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'or'",
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'or'",
},
{
category: "Operator keywords as keys",
@@ -2212,7 +2212,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:3 expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '='",
expectedErrorContains: "line 1:3 expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got '='",
},
{
category: "Operator keywords as keys",
@@ -2220,7 +2220,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'between'",
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'between'",
},
{
category: "Operator keywords as keys",
@@ -2228,7 +2228,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'in'",
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'in'",
},
// Using function keywords as keys

View File

@@ -43,6 +43,7 @@ var (
// GCP services.
GCPServiceCloudSQLPostgres = ServiceID{valuer.NewString("cloudsql_postgres")}
GCPServiceMemorystoreRedis = ServiceID{valuer.NewString("memorystore_redis")}
GCPServiceComputeEngine = ServiceID{valuer.NewString("computeengine")}
)
func (ServiceID) Enum() []any {
@@ -76,6 +77,7 @@ func (ServiceID) Enum() []any {
AzureServiceRedis,
GCPServiceCloudSQLPostgres,
GCPServiceMemorystoreRedis,
GCPServiceComputeEngine,
}
}
@@ -115,6 +117,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeGCP: {
GCPServiceCloudSQLPostgres,
GCPServiceMemorystoreRedis,
GCPServiceComputeEngine,
},
}

View File

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

View File

@@ -26,7 +26,7 @@ type DashboardSpec struct {
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval"`
Links []Link `json:"links" required:"true" nullable:"false"`
Links []Link `json:"links,omitzero"`
}
// ══════════════════════════════════════════════
@@ -45,16 +45,6 @@ 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
// ══════════════════════════════════════════════
@@ -63,9 +53,6 @@ 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
}
@@ -117,9 +104,6 @@ func (d *DashboardSpec) validatePanels() error {
if err := panel.Spec.Display.Validate("panel", path+".spec.display.name"); err != nil {
return err
}
if err := panel.Spec.validateLinks(path); err != nil {
return err
}
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) != 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)

View File

@@ -23,7 +23,6 @@ const basePostableJSON = `{
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
"spec": {
"display": {"name": "Service overview"},
"links": [],
"variables": [
{
"kind": "ListVariable",
@@ -42,7 +41,6 @@ const basePostableJSON = `{
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -66,7 +64,6 @@ const basePostableJSON = `{
"p2": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
@@ -185,7 +182,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -220,7 +216,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -332,7 +327,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": {"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/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/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
]`).Apply(base)
require.NoError(t, err)
@@ -351,7 +346,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -502,7 +496,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {"links": [], "plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
@@ -518,7 +512,6 @@ 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"}}}}]
}
@@ -544,7 +537,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {

View File

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

View File

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

View File

@@ -78,17 +78,7 @@ 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" 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
Links []Link `json:"links,omitzero"`
}
// Link replicates dashboard.Link (Perses) so its zero-valued fields survive the

View File

@@ -32,49 +32,7 @@ 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 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)
}
Signal telemetrytypes.Signal `json:"signal"`
}
type QueryVariableSpec struct {

View File

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

View File

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

View File

@@ -16,27 +16,23 @@ type TagRelation struct {
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
ResourceID valuer.UUID `json:"resourceId" required:"true" bun:"resource_id,type:text,notnull"`
TagID valuer.UUID `json:"tagId" required:"true" bun:"tag_id,type:text,notnull"`
// Rank is the tag's position within its resource; reads order by it.
Rank int `json:"rank" bun:"rank,notnull"`
CreatedAt time.Time `json:"createdAt" bun:"created_at,notnull"`
CreatedAt time.Time `json:"createdAt" bun:"created_at,notnull"`
}
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID, rank int) *TagRelation {
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID) *TagRelation {
return &TagRelation{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Kind: kind,
ResourceID: resourceID,
TagID: tagID,
Rank: rank,
CreatedAt: time.Now(),
}
}
// NewTagRelations ranks each tag by its position so reads preserve order.
func NewTagRelations(kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) []*TagRelation {
relations := make([]*TagRelation, 0, len(tagIDs))
for rank, tagID := range tagIDs {
relations = append(relations, NewTagRelation(kind, resourceID, tagID, rank))
for _, tagID := range tagIDs {
relations = append(relations, NewTagRelation(kind, resourceID, tagID))
}
return relations
}

View File

@@ -109,8 +109,6 @@ func (v *variableReplacementVisitor) Visit(tree antlr.ParseTree) any {
return v.VisitValueList(t)
case *grammar.FullTextContext:
return v.VisitFullText(t)
case *grammar.SearchCallContext:
return v.VisitSearchCall(t)
case *grammar.FunctionCallContext:
return v.VisitFunctionCall(t)
case *grammar.FunctionParamListContext:
@@ -205,8 +203,6 @@ func (v *variableReplacementVisitor) VisitPrimary(ctx *grammar.PrimaryContext) a
return v.Visit(ctx.Comparison())
} else if ctx.FunctionCall() != nil {
return v.Visit(ctx.FunctionCall())
} else if ctx.SearchCall() != nil {
return v.Visit(ctx.SearchCall())
} else if ctx.FullText() != nil {
return v.Visit(ctx.FullText())
}
@@ -411,13 +407,6 @@ func (v *variableReplacementVisitor) VisitFunctionCall(ctx *grammar.FunctionCall
return functionName + "(" + params + ")"
}
func (v *variableReplacementVisitor) VisitSearchCall(ctx *grammar.SearchCallContext) any {
if ctx.FunctionParamList() == nil {
return "search()"
}
return "search(" + v.Visit(ctx.FunctionParamList()).(string) + ")"
}
func (v *variableReplacementVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {
params := ctx.AllFunctionParam()
parts := make([]string, 0, len(params))

View File

@@ -113,7 +113,7 @@ def test_create_rejects_unknown_field(
json={
"schemaVersion": "v6",
"name": "rejects-unknown",
"spec": {"display": {"name": "Rejects Unknown"}, "links": []},
"spec": {"display": {"name": "Rejects Unknown"}},
"tags": [],
"unknownfield": "boom",
},
@@ -209,7 +209,6 @@ def test_create_rejects_invalid_grid_layout(
"kind": "Panel",
"spec": {
"display": {"name": name},
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [
{
@@ -250,7 +249,6 @@ def test_create_rejects_invalid_grid_layout(
},
}
],
"links": [],
},
"tags": [],
},
@@ -282,7 +280,6 @@ def test_create_rejects_invalid_grid_layout(
},
}
],
"links": [],
},
"tags": [],
},
@@ -308,7 +305,6 @@ def test_create_rejects_invalid_grid_layout(
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
}
],
"links": [],
},
"tags": [],
},
@@ -414,7 +410,7 @@ def test_update_missing_dashboard_returns_not_found(
json={
"schemaVersion": "v6",
"name": "missing-dashboard",
"spec": {"display": {"name": "Missing Dashboard"}, "links": []},
"spec": {"display": {"name": "Missing Dashboard"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -539,7 +535,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}, "links": []},
"spec": {"display": {"name": display}},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
@@ -574,11 +570,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert alpha["schemaVersion"] == "v6"
assert alpha["source"] == "user"
assert alpha["locked"] is False
# tags round-trip in the order sent — no reordering, no drift
assert alpha["tags"] == [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
]
assert {"key": "team", "value": "pulse"} in alpha["tags"]
# ── stage 3: list everything ─────────────────────────────────────────────
response = requests.get(
@@ -598,13 +590,6 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Epsilon Metrics",
"Zeta Overview",
}
# per-dashboard tags also round-trip in the order sent
delta = next(d for d in body["data"]["dashboards"] if d["spec"]["display"]["name"] == "Delta Storage")
assert delta["tags"] == [
{"key": "team", "value": "storage"},
{"key": "env", "value": "dev"},
{"key": "tier", "value": "critical"},
]
# top-level tags = org-wide distinct tag set, sorted case-insensitively
# by (key, value). Asserting the exact list (not a set) locks in the sort.
assert body["data"]["tags"] == [
@@ -901,7 +886,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"}, "links": []},
"spec": {"display": {"name": "Alpha Overview"}},
"tags": [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
@@ -945,7 +930,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"}, "links": []},
"spec": {"display": {"name": "Beta Overview"}},
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
}
response = requests.put(
@@ -1028,99 +1013,6 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert response.json()["data"]["id"] == clone["id"]
def test_dashboard_v2_tag_order_round_trips(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Tags must round-trip in the order the client sent them across every write
path — create, update, patch — so GitOps/Terraform state never drifts."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def tags_of(response: requests.Response) -> list[dict]:
return response.json()["data"]["tags"]
# ── create with tags in a deliberate order; "env" repeats with two values ─
created_order = [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
{"key": "env", "value": "staging"},
{"key": "tier", "value": "critical"},
]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
dashboard_id = response.json()["data"]["id"]
assert tags_of(response) == created_order
# ── get echoes the same order ────────────────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == created_order
# ── update reordered; the two "env" values land at non-adjacent positions ─
reordered = [
{"key": "tier", "value": "critical"},
{"key": "env", "value": "staging"},
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
]
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"}, "links": []}, "tags": reordered},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == reordered
# ── patch appends a new tag (a third "env" value); it lands at the end ────
response = requests.patch(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json=[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "dev"}}],
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == reordered + [{"key": "env", "value": "dev"}]
# ── update reorders everything and adds another new tag → all in the new order ─
# "env" now carries three interleaved values
new_order = [
{"key": "env", "value": "prod"},
{"key": "team", "value": "pulse"},
{"key": "env", "value": "dev"},
{"key": "tier", "value": "critical"},
{"key": "env", "value": "staging"},
{"key": "team", "value": "storage"},
]
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"}, "links": []}, "tags": new_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == new_order
# ── and the final order persists on a fresh read ─────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == new_order
def test_dashboard_v2_pin_limit(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -1141,7 +1033,7 @@ def test_dashboard_v2_pin_limit(
json={
"schemaVersion": "v6",
"name": f"pl-{i}",
"spec": {"display": {"name": f"Pin Limit {i}"}, "links": []},
"spec": {"display": {"name": f"Pin Limit {i}"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1239,7 +1131,7 @@ def test_dashboard_v2_like_escaping(
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}, "links": []},
"spec": {"display": {"name": display}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1330,13 +1222,11 @@ 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": [
{
@@ -1381,13 +1271,11 @@ 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": [
{
@@ -1409,7 +1297,6 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": "D2 promql target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1446,13 +1333,11 @@ 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": [
{
@@ -1491,13 +1376,11 @@ 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": [
{
@@ -1526,7 +1409,6 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} clickhouse"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1548,7 +1430,6 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} promql"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1622,13 +1503,11 @@ 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": [
{
@@ -1780,7 +1659,6 @@ 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"}]},
@@ -1803,7 +1681,6 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "promql"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1823,7 +1700,6 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "clickhouse"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1919,9 +1795,11 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
for description, spec, key in absent_cases:
assert key not in spec, description
# 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 []"
# 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"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
@@ -1973,7 +1851,6 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
},
}
],
"links": [],
},
},
"num": {
@@ -1995,11 +1872,9 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
},
}
],
"links": [],
},
},
},
"links": [],
},
}

View File

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