mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-03 23:50:33 +01:00
Compare commits
6 Commits
infraM/v2_
...
fix/query-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906cb15010 | ||
|
|
c5288fc1ea | ||
|
|
86e71151d7 | ||
|
|
4fce33e2b3 | ||
|
|
a0ae4dfd05 | ||
|
|
a0b14e0835 |
@@ -432,7 +432,7 @@ cloudintegration:
|
||||
version: v0.0.8
|
||||
|
||||
##################### Trace Detail #####################
|
||||
tracedetail:
|
||||
traces:
|
||||
waterfall:
|
||||
# Number of spans returned per request when the trace is too large to show all at once.
|
||||
span_page_size: 500
|
||||
|
||||
@@ -721,6 +721,53 @@ export const removeKeysFromExpression = (
|
||||
return result?.text ?? '';
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
export const createVariablePlaceholderRegExp = (
|
||||
variableName: string,
|
||||
): RegExp => {
|
||||
const escapedName = escapeRegExp(variableName);
|
||||
// (?![\w.]) prevents $env from matching inside $environment or $env.attr
|
||||
return new RegExp(
|
||||
`(\\$${escapedName}(?![\\w.])|\\{\\{\\s*\\.?${escapedName}\\s*\\}\\}|\\[\\[\\s*${escapedName}\\s*\\]\\])`,
|
||||
'g',
|
||||
);
|
||||
};
|
||||
|
||||
const matchesVariablePlaceholder = (
|
||||
text: string,
|
||||
variableName: string,
|
||||
): boolean => createVariablePlaceholderRegExp(variableName).test(text);
|
||||
|
||||
export const removeVariableFromExpression = (
|
||||
expression: string | undefined,
|
||||
variableName: string,
|
||||
): string => {
|
||||
if (!expression) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryPairs = extractQueryPairs(expression);
|
||||
|
||||
const keysToRemove = queryPairs
|
||||
.filter((pair) => {
|
||||
const singleValue = pair.value?.toString() ?? '';
|
||||
const listValues = (pair.valueList ?? []).join(' ');
|
||||
return (
|
||||
matchesVariablePlaceholder(singleValue, variableName) ||
|
||||
matchesVariablePlaceholder(listValues, variableName)
|
||||
);
|
||||
})
|
||||
.map((pair) => pair.key);
|
||||
|
||||
if (keysToRemove.length === 0) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old having format to new having format
|
||||
* @param having - Array of old having objects with columnName, op, and value
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMPTY_BUILDER = {
|
||||
queryData: [] as any,
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
};
|
||||
|
||||
const BASE_WIDGET = {
|
||||
opacity: '1',
|
||||
nullZeroValues: 'null',
|
||||
timePreferance: 'GLOBAL_TIME' as const,
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: null,
|
||||
selectedTracesFields: null,
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_DATA = {
|
||||
queryName: 'q1',
|
||||
// In QB v5, expression holds the query label (A/B/C), not a filter expression
|
||||
expression: 'A',
|
||||
dataSource: DataSource.METRICS,
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
filters: { items: [] as any[], op: 'AND' as const },
|
||||
legend: '',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
selectColumns: [],
|
||||
source: '' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a dashboard with a single builder widget.
|
||||
* Only supply the fields your test actually cares about.
|
||||
*/
|
||||
const buildBuilderDashboard = (
|
||||
filterExpression: string,
|
||||
queryDataOverrides: Record<string, any> = {},
|
||||
): Dashboard => ({
|
||||
id: 'dash1',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'Test Dashboard',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'widget-1',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'Widget 1',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'query1',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
...DEFAULT_QUERY_DATA,
|
||||
...queryDataOverrides,
|
||||
filter: { expression: filterExpression },
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildClickhouseDashboard = (query: string): Dashboard => ({
|
||||
id: 'dash-ch',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'CH',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'w1',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: '',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'q1',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
promql: [],
|
||||
clickhouse_sql: [{ name: 'A', query, legend: '', disabled: false }],
|
||||
builder: EMPTY_BUILDER,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildPromqlDashboard = (query: string): Dashboard => ({
|
||||
id: 'dash-prom',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'PromQL Dashboard',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'widget-prom',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'PromQL Widget',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'query-prom',
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [{ name: 'A', query, legend: '', disabled: false }],
|
||||
clickhouse_sql: [],
|
||||
builder: EMPTY_BUILDER,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
/** Run removeVariableReferencesFromDashboard on a single-widget clickhouse dashboard and return the cleaned SQL. */
|
||||
const chQuery = (sql: string, varName: string): string => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildClickhouseDashboard(sql),
|
||||
varName,
|
||||
);
|
||||
return (result!.data.widgets![0] as any).query.clickhouse_sql[0].query;
|
||||
};
|
||||
|
||||
/** Extract the first builder queryData from a cleaned dashboard. */
|
||||
const firstBuilderQueryData = (dashboard: Dashboard | undefined): any =>
|
||||
(dashboard!.data.widgets![0] as any).query.builder.queryData[0];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('removeVariableReferencesFromDashboard', () => {
|
||||
describe('builder filter expression cleanup', () => {
|
||||
it('removes a variable clause from filter.expression', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
|
||||
});
|
||||
|
||||
it('leaves no dangling AND/OR after removing a variable clause', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
const { expression } = firstBuilderQueryData(result).filter;
|
||||
|
||||
expect(expression).toBe("env = 'prod'");
|
||||
expect(expression).not.toMatch(/^\s*(AND|OR)/i);
|
||||
expect(expression).not.toMatch(/(AND|OR)\s*$/i);
|
||||
});
|
||||
|
||||
it('does not remove $environment clause when deleting $env', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
'env = $env AND deployment.environment = $environment',
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'env');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
'deployment.environment = $environment',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves literal filter expressions untouched when removing a variable', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name = 'api-gateway' AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
"service.name = 'api-gateway' AND env = 'prod'",
|
||||
);
|
||||
});
|
||||
|
||||
it('removes only the variable clause, preserving a literal clause on the same key', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND service.name = 'api-gateway'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
"service.name = 'api-gateway'",
|
||||
);
|
||||
});
|
||||
|
||||
it('returns filter.expression unchanged when the variable has no clauses in it', () => {
|
||||
const dashboard = buildBuilderDashboard("env = 'prod'");
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('PromQL query cleanup', () => {
|
||||
it('removes variable placeholder from a promql query', () => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildPromqlDashboard('sum(rate(http_requests_total{$service}[5m]))'),
|
||||
'service',
|
||||
);
|
||||
|
||||
const widget = result!.data.widgets![0] as any;
|
||||
expect(widget.query.promql[0].query).toBe(
|
||||
'sum(rate(http_requests_total{}[5m]))',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips only the variable token inside a PromQL label matcher (token-only path)', () => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildPromqlDashboard('up{env="$env", job="api"}'),
|
||||
'env',
|
||||
);
|
||||
|
||||
const widget = result!.data.widgets![0] as any;
|
||||
expect(widget.query.promql[0].query).toBe('up{env="", job="api"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClickHouse SQL query cleanup', () => {
|
||||
it('removes a quoted variable clause and its WHERE keyword', () => {
|
||||
expect(
|
||||
chQuery(
|
||||
"SELECT count() FROM signoz_logs WHERE service_name = '$service'",
|
||||
'service',
|
||||
),
|
||||
).toBe('SELECT count() FROM signoz_logs');
|
||||
});
|
||||
|
||||
it('removes a middle clause: AND env={{.env}} AND', () => {
|
||||
expect(
|
||||
chQuery('SELECT count() FROM t WHERE a=1 AND env={{.env}} AND b=2', 'env'),
|
||||
).toBe('SELECT count() FROM t WHERE a=1 AND b=2');
|
||||
});
|
||||
|
||||
it('removes the first clause: env={{.env}} AND rest', () => {
|
||||
expect(
|
||||
chQuery('SELECT count() FROM t WHERE env={{.env}} AND b=2', 'env'),
|
||||
).toBe('SELECT count() FROM t WHERE b=2');
|
||||
});
|
||||
|
||||
it('removes the last clause: rest AND env=$env', () => {
|
||||
expect(chQuery('SELECT count() FROM t WHERE a=1 AND env=$env', 'env')).toBe(
|
||||
'SELECT count() FROM t WHERE a=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a clause with double-bracket syntax: service=[[svc]]', () => {
|
||||
expect(chQuery('SELECT count() FROM t WHERE service=[[svc]]', 'svc')).toBe(
|
||||
'SELECT count() FROM t',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to token-only strip for a bare variable in SELECT', () => {
|
||||
expect(chQuery('SELECT $metric FROM table', 'metric')).toBe(
|
||||
'SELECT FROM table',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('is idempotent — calling twice produces the same result', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const once = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
const twice = removeVariableReferencesFromDashboard(once, 'service');
|
||||
|
||||
expect(twice).toStrictEqual(once);
|
||||
});
|
||||
|
||||
it('handles a dashboard with no widgets without throwing', () => {
|
||||
const dashboard: Dashboard = {
|
||||
id: 'dash-empty',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: { title: 'Empty Dashboard', widgets: undefined, variables: {} },
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
removeVariableReferencesFromDashboard(dashboard, 'service'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
createVariablePlaceholderRegExp,
|
||||
removeKeysFromExpression,
|
||||
removeVariableFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
@@ -157,6 +159,139 @@ const updateAfterRemoval = (
|
||||
};
|
||||
};
|
||||
|
||||
const removeVariablePlaceholders = (
|
||||
text: string | undefined,
|
||||
variableName: string,
|
||||
): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tokenPattern = createVariablePlaceholderRegExp(variableName);
|
||||
|
||||
// Step 1: attempt clause-aware removal for SQL WHERE patterns.
|
||||
// Strips the entire `key op $var` unit plus its adjacent AND/OR so we
|
||||
// never leave a dangling `key = ` in unquoted ClickHouse SQL clauses.
|
||||
// Handles three shapes:
|
||||
// (a) preceding conjunction: AND key = $var
|
||||
// (b) following conjunction: key = $var AND
|
||||
// (c) standalone clause: key = $var (end of expression)
|
||||
const escapedToken = tokenPattern.source;
|
||||
const clausePattern = new RegExp(
|
||||
// (a) conjunction before the clause
|
||||
`\\s*\\b(?:AND|OR)\\b\\s+[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?` +
|
||||
// (b)+(c) clause first, optional conjunction after
|
||||
`|[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?(?:\\s*\\b(?:AND|OR)\\b)?`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
const withClauseRemoval = text.replace(clausePattern, '');
|
||||
if (withClauseRemoval !== text) {
|
||||
return withClauseRemoval
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.replace(/\bWHERE\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Step 2: fallback — bare variable usage outside a key-op-value pattern
|
||||
// (e.g. SELECT $metric, LIMIT $n). Token-only removal is correct here.
|
||||
return text
|
||||
.replace(tokenPattern, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const removeVariableReferencesFromQueryData = (
|
||||
queryData: IBuilderQuery,
|
||||
variableName: string,
|
||||
): IBuilderQuery => {
|
||||
const updatedFilter = queryData.filter?.expression
|
||||
? {
|
||||
...queryData.filter,
|
||||
expression: removeVariableFromExpression(
|
||||
queryData.filter.expression,
|
||||
variableName,
|
||||
),
|
||||
}
|
||||
: queryData.filter;
|
||||
|
||||
return { ...queryData, filter: updatedFilter };
|
||||
};
|
||||
|
||||
const removeVariableReferencesFromWidget = (
|
||||
widget: Widgets,
|
||||
variableName: string,
|
||||
): Widgets => {
|
||||
let updatedWidget = { ...widget };
|
||||
|
||||
if (updatedWidget.query?.builder?.queryData) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
builder: {
|
||||
...updatedWidget.query.builder,
|
||||
queryData: updatedWidget.query.builder.queryData.map((queryData) =>
|
||||
removeVariableReferencesFromQueryData(queryData, variableName),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (updatedWidget.query?.promql) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
promql: updatedWidget.query.promql.map((promqlQuery) => ({
|
||||
...promqlQuery,
|
||||
query: removeVariablePlaceholders(promqlQuery.query, variableName),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (updatedWidget.query?.clickhouse_sql) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
clickhouse_sql: updatedWidget.query.clickhouse_sql.map((sqlQuery) => ({
|
||||
...sqlQuery,
|
||||
query: removeVariablePlaceholders(sqlQuery.query, variableName),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return updatedWidget;
|
||||
};
|
||||
|
||||
export const removeVariableReferencesFromDashboard = (
|
||||
dashboard: Dashboard | undefined,
|
||||
variableName: string,
|
||||
): Dashboard | undefined => {
|
||||
if (!dashboard || !variableName) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
const updatedDashboard = cloneDeep(dashboard);
|
||||
|
||||
if (updatedDashboard.data.widgets) {
|
||||
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
|
||||
(widget) => {
|
||||
if ('query' in widget) {
|
||||
return removeVariableReferencesFromWidget(widget as Widgets, variableName);
|
||||
}
|
||||
return widget;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return updatedDashboard;
|
||||
};
|
||||
|
||||
/**
|
||||
* A function that takes a dashboard configuration and a list of tag filters
|
||||
* and returns an updated dashboard with the filters appended to widget queries.
|
||||
|
||||
@@ -18,10 +18,11 @@ import { convertVariablesToDbFormat } from 'container/DashboardContainer/Dashboa
|
||||
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
|
||||
|
||||
import { TVariableMode } from './types';
|
||||
import VariableItem from './VariableItem/VariableItem';
|
||||
@@ -92,8 +93,6 @@ function VariablesSettings({
|
||||
const { dashboardData, setDashboardData } = useDashboardStore();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [variablesTableData, setVariablesTableData] = useState<any>([]);
|
||||
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
|
||||
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
|
||||
@@ -201,9 +200,7 @@ function VariablesSettings({
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
notifications.success({
|
||||
message: t('variable_updated_successfully'),
|
||||
});
|
||||
toast.success(t('variable_updated_successfully'));
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -256,6 +253,11 @@ function VariablesSettings({
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = (): void => {
|
||||
if (!dashboardData || !variableToDelete.current) {
|
||||
setDeleteVariableModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newVariablesArr = variablesTableData.filter(
|
||||
(variable: IDashboardVariable) =>
|
||||
variable.id !== variableToDelete?.current?.id,
|
||||
@@ -263,7 +265,31 @@ function VariablesSettings({
|
||||
|
||||
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
|
||||
|
||||
updateVariables(updatedVariables);
|
||||
const cleanedDashboard =
|
||||
removeVariableReferencesFromDashboard(
|
||||
dashboardData,
|
||||
variableToDelete.current.name || '',
|
||||
) || dashboardData;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...cleanedDashboard.data,
|
||||
variables: updatedVariables,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
toast.success(t('variable_updated_successfully'));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
variableToDelete.current = null;
|
||||
setDeleteVariableModal(false);
|
||||
};
|
||||
@@ -476,6 +502,7 @@ function VariablesSettings({
|
||||
open={deleteVariableModal}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
okButtonProps={{ loading: updateMutation.isLoading }}
|
||||
>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete variable{' '}
|
||||
|
||||
@@ -232,7 +232,7 @@ function DashboardsList(): JSX.Element {
|
||||
isLocked: !!e.locked || false,
|
||||
lastUpdatedBy: e.updatedBy,
|
||||
image: e.data.image || Base64Icons[0],
|
||||
variables: e.data.variables,
|
||||
variables: e.data.variables ?? {},
|
||||
widgets: e.data.widgets,
|
||||
layout: e.data.layout,
|
||||
panelMap: e.data.panelMap,
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
expect(result.data.variables!.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
@@ -97,7 +97,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
expect(result.data.variables!.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
expect(result.data.variables!.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
@@ -163,7 +163,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
@@ -196,7 +196,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
@@ -237,8 +237,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
@@ -258,8 +258,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
@@ -277,7 +277,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
@@ -292,7 +292,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toStrictEqual(['prod']);
|
||||
expect(result.data.variables!.v1.selectedValue).toStrictEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
@@ -306,7 +306,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,11 +327,11 @@ describe('useTransformDashboardVariables', () => {
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
const originalValue = dashboard.data.variables!.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
expect(dashboard.data.variables!.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,12 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variables = data?.data?.variables;
|
||||
if (data && localStorageVariables && variables) {
|
||||
const updatedVariables = variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
Object.keys(variables).forEach((variable) => {
|
||||
const variableData = variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
@@ -34,7 +35,7 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...data.data.variables[variable],
|
||||
...variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
ClipboardCopy,
|
||||
Ellipsis,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
} from '@signozhq/icons';
|
||||
import { Popover } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
isDashboardLocked: boolean;
|
||||
editDashboard: boolean;
|
||||
isAuthor: boolean;
|
||||
addPanelPermission: boolean;
|
||||
onAddPanel: () => void;
|
||||
onLockToggle: () => void;
|
||||
onOpenRename: () => void;
|
||||
}
|
||||
|
||||
function DashboardActions({
|
||||
dashboard,
|
||||
handle,
|
||||
isDashboardLocked,
|
||||
editDashboard,
|
||||
isAuthor,
|
||||
addPanelPermission,
|
||||
onAddPanel,
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: Props): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const id = dashboard.id;
|
||||
const title = dashboard.spec?.display?.name ?? '';
|
||||
|
||||
const [isDashboardSettingsOpen, setIsDashboardSettingsOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
toast.error(t('something_went_wrong', { ns: 'common' }));
|
||||
}
|
||||
if (state.value) {
|
||||
toast.success(t('success', { ns: 'common' }));
|
||||
}
|
||||
}, [state.error, state.value, t]);
|
||||
|
||||
const dashboardDataJSON = (): string => JSON.stringify(dashboard, null, 2);
|
||||
|
||||
const exportJSON = (): void => {
|
||||
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${title || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.rightSection}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<Popover
|
||||
open={isDashboardSettingsOpen}
|
||||
arrow={false}
|
||||
onOpenChange={(visible): void => setIsDashboardSettingsOpen(visible)}
|
||||
rootClassName={styles.dashboardSettings}
|
||||
content={
|
||||
<div className={styles.menuContent}>
|
||||
<section className={styles.section1}>
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<TooltipSimple
|
||||
title={
|
||||
dashboard.createdBy === 'integration'
|
||||
? 'Dashboards created by integrations cannot be unlocked'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
prefix={<LockKeyhole size={14} />}
|
||||
disabled={dashboard.createdBy === 'integration'}
|
||||
onClick={(): void => {
|
||||
setIsDashboardSettingsOpen(false);
|
||||
onLockToggle();
|
||||
}}
|
||||
testId="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
prefix={<PenLine size={14} />}
|
||||
onClick={(): void => {
|
||||
onOpenRename();
|
||||
setIsDashboardSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
prefix={<Fullscreen size={14} />}
|
||||
onClick={handle.enter}
|
||||
>
|
||||
Full screen
|
||||
</Button>
|
||||
</section>
|
||||
<section className={styles.section2}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
prefix={<FileJson size={14} />}
|
||||
onClick={(): void => {
|
||||
exportJSON();
|
||||
setIsDashboardSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
prefix={<ClipboardCopy size={14} />}
|
||||
onClick={(): void => {
|
||||
setCopy(dashboardDataJSON());
|
||||
setIsDashboardSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Copy as JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className={styles.deleteDashboard}>
|
||||
<DeleteButton
|
||||
createdBy={dashboard.createdBy || ''}
|
||||
name={title}
|
||||
id={id}
|
||||
isLocked={isDashboardLocked}
|
||||
routeToListPage
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
prefix={<Ellipsis size={14} />}
|
||||
className={styles.icons}
|
||||
testId="options"
|
||||
/>
|
||||
</Popover>
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={styles.addPanelBtn}
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardActions;
|
||||
@@ -0,0 +1,303 @@
|
||||
.dashboardDescriptionContainer {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: unset;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.dashboardDetails {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 0px 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
.leftSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 45%;
|
||||
|
||||
.dashboardImg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.08px;
|
||||
max-width: 80%;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.publicDashboardIcon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.rightSection {
|
||||
display: flex;
|
||||
width: 55%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 34px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 10px; /* 83.333% */
|
||||
letter-spacing: 0.12px;
|
||||
}
|
||||
|
||||
.icons:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.configureButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 93px;
|
||||
height: 34px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 10px; /* 83.333% */
|
||||
letter-spacing: 0.12px;
|
||||
}
|
||||
|
||||
.addPanelBtn {
|
||||
display: flex;
|
||||
width: 119px;
|
||||
height: 34px;
|
||||
padding: 5.937px 11.875px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary-background);
|
||||
font-family: Inter;
|
||||
font-size: 11.875px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 17.812px; /* 150% */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 16px 16px 0px 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardDescriptionSection {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 20px 16px 0px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardSettings {
|
||||
width: 191px;
|
||||
height: 302px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.ant-popover-inner) {
|
||||
padding: 0px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
) !important;
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.menuContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: unset;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.section1,
|
||||
.section2 {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.deleteDashboard button {
|
||||
color: var(--bg-cherry-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.renameDashboard {
|
||||
:global(.ant-modal-content) {
|
||||
width: 384px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0px;
|
||||
|
||||
:global(.ant-modal-header) {
|
||||
height: 52px;
|
||||
padding: 16px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
margin-bottom: 0px;
|
||||
|
||||
:global(.ant-modal-title) {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
width: 349px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-modal-body) {
|
||||
padding: 16px;
|
||||
|
||||
.dashboardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.nameText {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
display: flex;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-modal-footer) {
|
||||
padding: 16px;
|
||||
margin-top: 0px;
|
||||
|
||||
.dashboardRename {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 12px;
|
||||
|
||||
.cancelBtn {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.renameBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 169px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
function DashboardMeta({ tags, description }: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.dashboardTags}>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} className={styles.tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className={styles.dashboardDescriptionSection}>
|
||||
{description}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardMeta;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Globe, LockKeyhole } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image: string;
|
||||
isPublicDashboard: boolean;
|
||||
isDashboardLocked: boolean;
|
||||
}
|
||||
|
||||
function DashboardTitle({
|
||||
title,
|
||||
image,
|
||||
isPublicDashboard,
|
||||
isDashboardLocked,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.leftSection}>
|
||||
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
|
||||
<TooltipSimple title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className={styles.dashboardTitle}
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className={styles.publicDashboardIcon} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardTitle;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Input, Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
value: string;
|
||||
isLoading: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onRename: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function RenameDashboardModal({
|
||||
open,
|
||||
value,
|
||||
isLoading,
|
||||
onChange,
|
||||
onRename,
|
||||
onClose,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Rename Dashboard"
|
||||
onOk={onRename}
|
||||
onCancel={onClose}
|
||||
rootClassName={styles.renameDashboard}
|
||||
footer={
|
||||
<div className={styles.dashboardRename}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
className={styles.renameBtn}
|
||||
onClick={onRename}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Rename Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
prefix={<X size={14} />}
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.dashboardContent}>
|
||||
<Typography.Text className={styles.nameText}>
|
||||
Enter a new name
|
||||
</Typography.Text>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameDashboardModal;
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { Card } from 'antd';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardMeta from './DashboardMeta/DashboardMeta';
|
||||
import DashboardTitle from './DashboardTitle/DashboardTitle';
|
||||
import RenameDashboardModal from './RenameDashboardModal/RenameDashboardModal';
|
||||
|
||||
import styles from './DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardDescriptionProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const isDashboardLocked = !!dashboard.locked;
|
||||
|
||||
const title = dashboard.spec?.display?.name ?? '';
|
||||
const description = dashboard.spec?.display?.description ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
(dashboard.tags ?? []).map((t) =>
|
||||
t.key === t.value ? t.key : `${t.key}:${t.value}`,
|
||||
),
|
||||
[dashboard.tags],
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
|
||||
useState<boolean>(false);
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUpdatedTitle(title);
|
||||
}, [title]);
|
||||
|
||||
const handleLockDashboardToggle = async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const onNameChangeHandler = async (): Promise<void> => {
|
||||
const trimmed = updatedTitle.trim();
|
||||
if (!id || !trimmed || trimmed === title) {
|
||||
setIsRenameDashboardOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRenameLoading(true);
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/display/name',
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
setIsRenameDashboardOpen(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsRenameDashboardOpen(true);
|
||||
} finally {
|
||||
setIsRenameLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEmptyWidgetHandler = (): void => {
|
||||
void logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
toast.info('V2 panel editor coming next');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={styles.dashboardDescriptionContainer}>
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className={styles.dashboardDetails}>
|
||||
<DashboardTitle
|
||||
title={title}
|
||||
image={image}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
/>
|
||||
<DashboardActions
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
editDashboard={editDashboard}
|
||||
isAuthor={isAuthor}
|
||||
addPanelPermission={addPanelPermission}
|
||||
onAddPanel={onEmptyWidgetHandler}
|
||||
onLockToggle={handleLockDashboardToggle}
|
||||
onOpenRename={(): void => setIsRenameDashboardOpen(true)}
|
||||
/>
|
||||
</section>
|
||||
<DashboardMeta tags={tags} description={description} />
|
||||
|
||||
<RenameDashboardModal
|
||||
open={isRenameDashboardOpen}
|
||||
value={updatedTitle}
|
||||
isLoading={isRenameLoading}
|
||||
onChange={setUpdatedTitle}
|
||||
onRename={onNameChangeHandler}
|
||||
onClose={(): void => setIsRenameDashboardOpen(false)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardDescription;
|
||||
@@ -0,0 +1,52 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bodyKind {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
interface Props {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
/**
|
||||
* Placeholder: true once this panel's section enters the viewport. The panel
|
||||
* query-loading implementation (later PR) will consume this to lazily fetch
|
||||
* data. Currently unused on purpose.
|
||||
*/
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
function Panel({ panel, panelId, isVisible }: Props): JSX.Element {
|
||||
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
|
||||
const description = panel?.spec?.display?.description;
|
||||
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel?.spec?.queries?.length ?? 0;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) {
|
||||
return name;
|
||||
}
|
||||
return (
|
||||
<TooltipSimple title={description}>
|
||||
<span>{name}</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}, [name, description]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>
|
||||
{headerTitle}
|
||||
</Typography.Text>
|
||||
<Badge className={styles.badge}>{kind}</Badge>
|
||||
</div>
|
||||
<EllipsisVertical size={14} />
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
|
||||
coming next
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Panel;
|
||||
@@ -0,0 +1,10 @@
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.section {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
import SectionHeader from '../SectionHeader/SectionHeader';
|
||||
import styles from './Section.module.scss';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSection;
|
||||
}
|
||||
|
||||
function Section({ section }: Props): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
// true once the section scrolls into (or near) the viewport.
|
||||
const isVisible = useIntersectionObserver(containerRef, {
|
||||
rootMargin: '200px',
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState<boolean>(section.open);
|
||||
const toggle = (): void => setOpen((prev) => !prev);
|
||||
|
||||
const grid = <SectionGrid items={section.items} isVisible={isVisible} />;
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — just the grid (no header chrome), but still observed
|
||||
// for the viewport signal.
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
{grid}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
<SectionHeader
|
||||
sectionId={section.id}
|
||||
title={section.title}
|
||||
open={open}
|
||||
onToggle={toggle}
|
||||
repeatVariable={section.repeatVariable}
|
||||
/>
|
||||
{open ? grid : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Section;
|
||||
@@ -0,0 +1,12 @@
|
||||
.grid {
|
||||
// Override react-grid-layout's default red drag/resize placeholder with the
|
||||
// SigNoz brand blue.
|
||||
:global(.react-grid-item.react-grid-placeholder) {
|
||||
background: var(--bg-robin-500);
|
||||
opacity: 0.2;
|
||||
border-radius: 4px;
|
||||
transition-duration: 100ms;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import Panel from '../../Panel/Panel';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
interface Props {
|
||||
items: DashboardSection['items'];
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
function SectionGrid({ items, isVisible }: Props): JSX.Element {
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className={styles.grid}
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
isDraggable={false}
|
||||
isResizable={false}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<Panel panel={item.panel} panelId={item.id} isVisible={isVisible} />
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionGrid;
|
||||
@@ -0,0 +1,52 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
&.headerOpen {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.repeatBadge {
|
||||
margin-left: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './SectionHeader.module.scss';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
sectionId,
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
repeatVariable,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={onToggle}
|
||||
data-testid={`dashboard-section-toggle-${sectionId}`}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{repeatVariable ? (
|
||||
<Typography.Text className={styles.repeatBadge}>
|
||||
(repeats per ${repeatVariable})
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionHeader;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { layoutsToSections } from '../utils';
|
||||
import Section from './Section/Section/Section';
|
||||
import styles from './PanelsAndSectionsLayout.module.scss';
|
||||
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
interface Props {
|
||||
layouts: DashboardtypesLayoutDTO[];
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined>;
|
||||
}
|
||||
|
||||
function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(layouts, panels),
|
||||
[layouts, panels],
|
||||
);
|
||||
|
||||
const isEmpty =
|
||||
sections.length === 0 || sections.every((s) => s.items.length === 0);
|
||||
|
||||
const renderContent = (): ReactNode => {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Typography.Text>No panels in this dashboard yet</Typography.Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return sections.map((section) => (
|
||||
<Section key={section.id} section={section} />
|
||||
));
|
||||
};
|
||||
|
||||
return <div className={styles.body}>{renderContent()}</div>;
|
||||
}
|
||||
|
||||
export default PanelsAndSectionsLayout;
|
||||
@@ -0,0 +1,63 @@
|
||||
.dashboardBreadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboardBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboardBtn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.idBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.ant-btn-icon) {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
.idBtn:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboardIconImage {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import styles from './DashboardBreadcrumbs.module.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (dashboardsListQueryParamsString) {
|
||||
safeNavigate({
|
||||
pathname: ROUTES.ALL_DASHBOARD,
|
||||
search: `?${dashboardsListQueryParamsString}`,
|
||||
});
|
||||
} else {
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardBreadcrumbs}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
prefix={<LayoutGrid size={14} />}
|
||||
className={styles.dashboardBtn}
|
||||
onClick={goToListPage}
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button variant="ghost" className={styles.idBtn}>
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-icon"
|
||||
className={styles.dashboardIconImage}
|
||||
/>
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -0,0 +1,9 @@
|
||||
.dashboardHeader {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import styles from './DashboardHeader.module.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardHeader({ title, image }: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.dashboardHeader}>
|
||||
<DashboardBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import DashboardDescription from './DashboardDescription';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
const { spec } = dashboard;
|
||||
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
|
||||
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardDescription
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
|
||||
</div>
|
||||
</FullScreen>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardContainer;
|
||||
154
frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts
Normal file
154
frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface GridItem {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
}
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
export function extractPanelIdFromRef(ref: string | undefined): string | null {
|
||||
if (!ref) {
|
||||
return null;
|
||||
}
|
||||
if (!ref.startsWith(PANEL_REF_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return ref.slice(PANEL_REF_PREFIX.length);
|
||||
}
|
||||
|
||||
export function flattenGridLayout(
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null,
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
|
||||
): GridItem[] {
|
||||
if (!layouts?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: GridItem[] = [];
|
||||
layouts.forEach((layoutEnvelope) => {
|
||||
if (layoutEnvelope?.kind !== 'Grid') {
|
||||
return;
|
||||
}
|
||||
const gridItems = layoutEnvelope.spec?.items ?? [];
|
||||
gridItems.forEach((item) => {
|
||||
const id = extractPanelIdFromRef(item.content?.$ref);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
items.push({
|
||||
id,
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
width: item.width ?? 6,
|
||||
height: item.height ?? 6,
|
||||
panel: panels?.[id],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
|
||||
* `display.title`, it renders with a collapsible header; otherwise it is a
|
||||
* "default" untitled section (visually just the grid).
|
||||
*/
|
||||
export interface DashboardSection {
|
||||
/**
|
||||
* Stable identity used for React keys and dnd-kit sortable item ids. Derived
|
||||
* from the section's content (its first panel ref) so it survives reordering
|
||||
* — unlike the positional `layoutIndex`. See `getSectionStableId`.
|
||||
*/
|
||||
id: string;
|
||||
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
|
||||
layoutIndex: number;
|
||||
title: string | undefined;
|
||||
open: boolean;
|
||||
items: GridItem[];
|
||||
repeatVariable: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable id for a section from its content. Reordering sections changes
|
||||
* their `layoutIndex` but not their content, so keying off the first panel ref
|
||||
* keeps React component instances (and any local state) bound to the right
|
||||
* section across a reorder. Empty sections fall back to a positional id — they
|
||||
* are rarely reordered, and a future backend `id` on the layout spec is the
|
||||
* proper long-term fix.
|
||||
*/
|
||||
export function getSectionStableId(
|
||||
items: GridItem[],
|
||||
layoutIndex: number,
|
||||
): string {
|
||||
if (items.length > 0) {
|
||||
return `sec-${items[0].id}`;
|
||||
}
|
||||
return `sec-empty-${layoutIndex}`;
|
||||
}
|
||||
|
||||
export function layoutsToSections(
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null,
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
|
||||
): DashboardSection[] {
|
||||
if (!layouts?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return layouts
|
||||
.map((layoutEnvelope, idx) => {
|
||||
if (layoutEnvelope?.kind !== 'Grid') {
|
||||
return null;
|
||||
}
|
||||
const spec = layoutEnvelope.spec;
|
||||
const items: GridItem[] = (spec?.items ?? [])
|
||||
.map((item) => {
|
||||
const id = extractPanelIdFromRef(item.content?.$ref);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
width: item.width ?? 6,
|
||||
height: item.height ?? 6,
|
||||
panel: panels?.[id],
|
||||
};
|
||||
})
|
||||
.filter((it): it is GridItem => it !== null);
|
||||
|
||||
const title = spec?.display?.title;
|
||||
// `open` defaults to true when no collapse field is set (the section
|
||||
// is expanded by default).
|
||||
const open = spec?.display?.collapse?.open !== false;
|
||||
|
||||
return {
|
||||
id: getSectionStableId(items, idx),
|
||||
layoutIndex: idx,
|
||||
title,
|
||||
open,
|
||||
items,
|
||||
repeatVariable: spec?.repeatVariable,
|
||||
};
|
||||
})
|
||||
.filter((s): s is DashboardSection => s !== null);
|
||||
}
|
||||
|
||||
export function getPanelKindLabel(
|
||||
panel: DashboardtypesPanelDTO | undefined,
|
||||
): string {
|
||||
const kind = panel?.spec?.plugin?.kind;
|
||||
if (!kind) {
|
||||
return 'unknown';
|
||||
}
|
||||
return kind.replace(/^signoz\//, '');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.errorState {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -1,5 +1,43 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import Spinner from 'components/Spinner';
|
||||
|
||||
import DashboardContainer from './DashboardContainer';
|
||||
import styles from './DashboardPageV2.module.scss';
|
||||
|
||||
function DashboardPageV2(): JSX.Element {
|
||||
return <>DashboardPageV2</>;
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
|
||||
const dashboard = data?.data;
|
||||
const name = dashboard?.spec?.display?.name;
|
||||
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
document.title = name;
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState}>
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>{(error as Error)?.message}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DashboardContainer dashboard={dashboard} refetch={refetch} />;
|
||||
}
|
||||
|
||||
export default DashboardPageV2;
|
||||
|
||||
@@ -86,7 +86,7 @@ function DashboardWidgetInternal({
|
||||
setDashboardData(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables,
|
||||
variables: updatedDashboardData.data.variables ?? {},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,20 @@ describe('dashboardVariablesStoreUtils', () => {
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when variables is undefined', () => {
|
||||
const result = buildSortedVariablesArray(
|
||||
undefined as unknown as IDashboardVariables,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when variables is null', () => {
|
||||
const result = buildSortedVariablesArray(
|
||||
null as unknown as IDashboardVariables,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should create copies of variables (not references)', () => {
|
||||
const original = createVariable({ name: 'a', order: 0 });
|
||||
const variables: IDashboardVariables = { a: original };
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
* Build a sorted array of variables by their order property
|
||||
*/
|
||||
export function buildSortedVariablesArray(
|
||||
variables: IDashboardVariables,
|
||||
variables?: IDashboardVariables,
|
||||
): IDashboardVariable[] {
|
||||
const sortedVariablesArray: IDashboardVariable[] = [];
|
||||
|
||||
Object.values(variables).forEach((value) => {
|
||||
Object.values(variables ?? {}).forEach((value) => {
|
||||
sortedVariablesArray.push({ ...value });
|
||||
});
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface DashboardData {
|
||||
title: string;
|
||||
layout?: Layout[];
|
||||
panelMap?: Record<string, { widgets: Layout[]; collapsed: boolean }>;
|
||||
variables: Record<string, IDashboardVariable>;
|
||||
variables?: Record<string, IDashboardVariable>;
|
||||
version?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
@@ -648,3 +648,176 @@ describe('getQueryContextAtCursor - trailing dot in key/value', () => {
|
||||
expect(ctx.keyToken).toBe('k8s.namespace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueryContextAtCursor - partial operator', () => {
|
||||
it('treats text after an incomplete key as an operator prefix', () => {
|
||||
const q = 'service.name c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('c');
|
||||
expect(ctx.currentPair).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
key: 'service.name',
|
||||
operator: 'c',
|
||||
position: expect.objectContaining({
|
||||
operatorStart: 13,
|
||||
operatorEnd: 13,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the operator context while completing contains', () => {
|
||||
const q = 'service.name cont';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('cont');
|
||||
});
|
||||
|
||||
it('treats cursor mid-token as operator context', () => {
|
||||
const q = 'service.name cont';
|
||||
// cursor sits between "con" and "t" — user still typing the operator
|
||||
const ctx = getQueryContextAtCursor(q, 15);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('cont');
|
||||
});
|
||||
|
||||
it('keeps operator context when an AND conjunction precedes the pair', () => {
|
||||
const q = 'a = 1 AND service.name c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('c');
|
||||
expect(ctx.currentPair).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
key: 'service.name',
|
||||
operator: 'c',
|
||||
position: expect.objectContaining({
|
||||
operatorStart: 23,
|
||||
operatorEnd: 23,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps operator context when an open parenthesis precedes the pair', () => {
|
||||
const q = '(service.name c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('c');
|
||||
});
|
||||
|
||||
it('re-glues a partial operator that follows a NOT negation', () => {
|
||||
const q = 'service.name NOT c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT c');
|
||||
// operatorStart points at the partial operator (post-NOT), not at the
|
||||
// negation — so suggestion selection only replaces the partial, never
|
||||
// the user's typed NOT.
|
||||
expect(ctx.currentPair).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
key: 'service.name',
|
||||
operator: 'NOT c',
|
||||
hasNegation: true,
|
||||
position: expect.objectContaining({
|
||||
negationStart: 13,
|
||||
negationEnd: 15,
|
||||
operatorStart: 17,
|
||||
operatorEnd: 17,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-glues a multi-character partial operator after NOT', () => {
|
||||
const q = 'service.name NOT lik';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT lik');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('re-glues an uppercase partial operator after NOT', () => {
|
||||
const q = 'service.name NOT EXI';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT EXI');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves original NOT casing in the operator text', () => {
|
||||
const q = 'service.name not c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('not c');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates extra whitespace between NOT and the partial operator', () => {
|
||||
const q = 'service.name NOT c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
// Display text uses a canonical single space between NOT and the
|
||||
// partial, regardless of how many spaces the user typed.
|
||||
expect(ctx.operatorToken).toBe('NOT c');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps operator context for NOT-prefixed partial inside parentheses', () => {
|
||||
const q = '(service.name NOT c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT c');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps operator context for NOT-prefixed partial after an AND conjunction', () => {
|
||||
const q = 'a = 1 AND service.name NOT c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT c');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('re-glues the most recent incomplete pair when three partial tokens are typed', () => {
|
||||
// Pins documented behavior: with two trailing partial pairs (`c` and
|
||||
// `k`), the heuristic pairs the most recent two — `c` becomes the
|
||||
// key, `k` becomes the partial operator. The earlier `service.name`
|
||||
// is dropped from the current pair view.
|
||||
const q = 'service.name c k';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('c');
|
||||
expect(ctx.operatorToken).toBe('k');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -605,6 +605,98 @@ export function getQueryContextAtCursor(
|
||||
queryPairs,
|
||||
);
|
||||
|
||||
// Re-glue a partial operator that ANTLR has lexed as a second key.
|
||||
//
|
||||
// When the user types `service.name c` (or `service.name NOT c`), the
|
||||
// lexer sees two KEY tokens (`service.name`, `c`) instead of a key +
|
||||
// partial operator, so `extractQueryPairs` emits two consecutive
|
||||
// key-only incomplete pairs. Downstream, that makes the dropdown
|
||||
// suggest keys when it should be suggesting operators.
|
||||
//
|
||||
// Detect that pattern — a previous incomplete key-only pair followed
|
||||
// by another incomplete key-only `currentPair`, separated only by
|
||||
// whitespace (or by a negation token attached to the previous pair) —
|
||||
// and rebuild a single synthetic pair where the previous pair's key
|
||||
// is the key and the current pair's key is treated as the partial
|
||||
// operator. The synthetic pair inherits the previous pair's negation
|
||||
// flag and positions via the spread, so `NOT <partial>` propagates
|
||||
// correctly to consumers.
|
||||
const previousIncompletePair = queryPairs
|
||||
.filter(
|
||||
(pair) =>
|
||||
!pair.isComplete &&
|
||||
!!pair.key &&
|
||||
!pair.operator &&
|
||||
pair.position.keyEnd < (currentPair?.position.keyStart ?? cursorIndex),
|
||||
)
|
||||
.sort((a, b) => b.position.keyEnd - a.position.keyEnd)[0];
|
||||
|
||||
if (
|
||||
previousIncompletePair &&
|
||||
currentPair &&
|
||||
currentPair !== previousIncompletePair &&
|
||||
!currentPair.operator &&
|
||||
currentPair.position.keyStart > previousIncompletePair.position.keyEnd
|
||||
) {
|
||||
const negationStart = previousIncompletePair.position.negationStart ?? 0;
|
||||
const negationEnd = previousIncompletePair.position.negationEnd ?? 0;
|
||||
const negationAfterKey =
|
||||
previousIncompletePair.hasNegation &&
|
||||
negationStart > previousIncompletePair.position.keyEnd;
|
||||
const gapStart = negationAfterKey
|
||||
? negationEnd + 1
|
||||
: previousIncompletePair.position.keyEnd + 1;
|
||||
const textBetweenPairs = query.slice(
|
||||
gapStart,
|
||||
currentPair.position.keyStart,
|
||||
);
|
||||
|
||||
if (textBetweenPairs.trim() === '') {
|
||||
// The replacement range (operatorStart/operatorEnd) must point
|
||||
// at the partial operator only, NOT the leading negation.
|
||||
// Consumers like QuerySearch use it to splice the chosen
|
||||
// suggestion in-place, so including the negation would let a
|
||||
// `NOT lik` -> `LIKE` selection erase the user's typed `NOT`.
|
||||
// Matches the convention used for complete pairs in
|
||||
// extractQueryPairs, where operatorStart starts after the
|
||||
// negation token.
|
||||
const operatorStart = currentPair.position.keyStart;
|
||||
const operatorEnd = currentPair.position.keyEnd;
|
||||
const partialOperator = query.slice(operatorStart, operatorEnd + 1);
|
||||
const operatorText = negationAfterKey
|
||||
? `${query.slice(negationStart, negationEnd + 1)} ${partialOperator}`
|
||||
: partialOperator;
|
||||
|
||||
return {
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: cursorIndex,
|
||||
stop: cursorIndex,
|
||||
currentToken: operatorText,
|
||||
isInKey: false,
|
||||
isInNegation: false,
|
||||
isInOperator: true,
|
||||
isInValue: false,
|
||||
isInConjunction: false,
|
||||
isInFunction: false,
|
||||
isInParenthesis: false,
|
||||
isInBracketList: false,
|
||||
keyToken: previousIncompletePair.key,
|
||||
operatorToken: operatorText,
|
||||
queryPairs,
|
||||
currentPair: {
|
||||
...previousIncompletePair,
|
||||
operator: operatorText,
|
||||
position: {
|
||||
...previousIncompletePair.position,
|
||||
operatorStart,
|
||||
operatorEnd,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cursor is within any of the specific context boundaries
|
||||
// FIXED: Include the case where the cursor is exactly at the end of a boundary
|
||||
const isInKeyBoundary =
|
||||
|
||||
@@ -19,7 +19,7 @@ type WaterfallConfig struct {
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("tracedetail"), newConfig)
|
||||
return factory.NewConfigFactory(factory.MustNewName("traces"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
@@ -34,16 +34,13 @@ func newConfig() factory.Config {
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Waterfall.SpanPageSize <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize)
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize)
|
||||
}
|
||||
if c.Waterfall.MaxDepthToAutoExpand < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand)
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand)
|
||||
}
|
||||
if c.Waterfall.MaxLimitToSelectAllSpans == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.waterfall.max_limit_to_select_all_spans must be positive")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_limit_to_select_all_spans must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,21 +7,34 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store spantypes.TraceStore
|
||||
settings factory.ScopedProviderSettings
|
||||
config tracedetail.Config
|
||||
metrics *moduleMetrics
|
||||
}
|
||||
|
||||
func NewModule(traceStore spantypes.TraceStore, providerSettings factory.ProviderSettings, cfg tracedetail.Config) *module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail")
|
||||
return &module{
|
||||
|
||||
metrics, err := newModuleMetrics(scopedProviderSettings.Meter())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
m := &module{
|
||||
config: cfg,
|
||||
store: traceStore,
|
||||
settings: scopedProviderSettings,
|
||||
metrics: metrics,
|
||||
}
|
||||
|
||||
m.metrics.waterfallSpanLimit.Record(context.Background(), int64(cfg.Waterfall.MaxLimitToSelectAllSpans), metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed)))
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) {
|
||||
@@ -80,6 +93,9 @@ func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpa
|
||||
}
|
||||
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
|
||||
if summary.NumSpans > uint64(effectiveLimit) {
|
||||
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
|
||||
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
|
||||
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
|
||||
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
|
||||
}
|
||||
return m.getFullWaterfall(ctx, traceID, summary)
|
||||
|
||||
55
pkg/modules/tracedetail/impltracedetail/telemetry.go
Normal file
55
pkg/modules/tracedetail/impltracedetail/telemetry.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package impltracedetail
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
const (
|
||||
attrResponseType = attribute.Key("response_type")
|
||||
attrResponseTypeWindowed = "windowed"
|
||||
)
|
||||
|
||||
type moduleMetrics struct {
|
||||
waterfallSpanLimit metric.Int64Gauge
|
||||
waterfallRequestCount metric.Int64Counter
|
||||
waterfallSpanCount metric.Int64Counter
|
||||
}
|
||||
|
||||
func newModuleMetrics(meter metric.Meter) (*moduleMetrics, error) {
|
||||
var errs error
|
||||
|
||||
spanLimit, err := meter.Int64Gauge(
|
||||
"signoz.traces.waterfall.span.limit",
|
||||
metric.WithDescription("The span count limit above which windowed waterfall is returned instead of the full waterfall."),
|
||||
metric.WithUnit("{span}"),
|
||||
)
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
requestCount, err := meter.Int64Counter(
|
||||
"signoz.traces.waterfall.request.count",
|
||||
metric.WithDescription("Total number of waterfall requests, by response_type."),
|
||||
metric.WithUnit("{request}"),
|
||||
)
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
spanCount, err := meter.Int64Counter(
|
||||
"signoz.traces.waterfall.span.count",
|
||||
metric.WithDescription("Total number of spans across waterfall requests, by response_type."),
|
||||
metric.WithUnit("{span}"),
|
||||
)
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
return &moduleMetrics{
|
||||
waterfallSpanLimit: spanLimit,
|
||||
waterfallRequestCount: requestCount,
|
||||
waterfallSpanCount: spanCount,
|
||||
}, errs
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
}
|
||||
}
|
||||
preseededResults := make(map[string]any)
|
||||
for _, name := range missingMetricQueries { // at this point missing metrics will not have any non existent metrics, only normal ones
|
||||
for _, name := range missingMetricQueries {
|
||||
switch req.RequestType {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
|
||||
@@ -375,11 +375,24 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
return missingMetricQueries, "", nil
|
||||
}
|
||||
|
||||
isInternalMetric := func(n string) bool { return strings.HasPrefix(n, "signoz.") || strings.HasPrefix(n, "signoz_") }
|
||||
externalMissingMetrics := make([]string, 0, len(missingMetrics))
|
||||
for _, m := range missingMetrics {
|
||||
if !isInternalMetric(m) {
|
||||
externalMissingMetrics = append(externalMissingMetrics, m)
|
||||
}
|
||||
}
|
||||
if len(externalMissingMetrics) == 0 {
|
||||
// this means all missing metrics are internal, and since internal metrics
|
||||
// aren't user-controlled, skip errors/warnings for them since users can't act on them
|
||||
return missingMetricQueries, "", nil
|
||||
}
|
||||
|
||||
// Classify each missing metric: never-seen → NotFound error; seen-but-no-
|
||||
// data-in-window → dormant warning.
|
||||
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, missingMetrics...)
|
||||
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, externalMissingMetrics...)
|
||||
nonExistentMetrics := []string{}
|
||||
for _, name := range missingMetrics {
|
||||
for _, name := range externalMissingMetrics {
|
||||
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
|
||||
continue
|
||||
}
|
||||
@@ -400,11 +413,11 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
return name
|
||||
}
|
||||
if len(missingMetrics) == 1 {
|
||||
if len(externalMissingMetrics) == 1 {
|
||||
dormantWarning = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
|
||||
} else {
|
||||
parts := make([]string, len(missingMetrics))
|
||||
for i, m := range missingMetrics {
|
||||
parts := make([]string, len(externalMissingMetrics))
|
||||
for i, m := range externalMissingMetrics {
|
||||
parts[i] = lastSeenStr(m)
|
||||
}
|
||||
dormantWarning = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
|
||||
|
||||
@@ -143,7 +143,7 @@ type Config struct {
|
||||
CloudIntegration cloudintegration.Config `mapstructure:"cloudintegration"`
|
||||
|
||||
// TraceDetail config
|
||||
TraceDetail tracedetail.Config `mapstructure:"tracedetail"`
|
||||
TraceDetail tracedetail.Config `mapstructure:"traces"`
|
||||
|
||||
// Authz config
|
||||
Authz authz.Config `mapstructure:"authz"`
|
||||
|
||||
@@ -640,6 +640,32 @@ def test_non_existent_metrics_returns_404(
|
||||
assert get_error_message(response.json()) == "could not find the metric whatevergoennnsgoeshere"
|
||||
|
||||
|
||||
def test_non_existent_internal_metrics_returns_no_warning(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
metric_name = "signoz_calls_total"
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_builder_query(
|
||||
"A",
|
||||
metric_name,
|
||||
"doesnotreallymatter",
|
||||
"sum",
|
||||
)
|
||||
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
response = make_query_request(signoz, token, start_2h, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()
|
||||
assert get_all_warnings(data) == []
|
||||
|
||||
|
||||
# Verify /api/v1/fields/values filters label values by metricNamespace prefix.
|
||||
# Inserts metrics under ns.a and ns.b, then asserts a specific prefix returns
|
||||
# only matching values while a common prefix returns both.
|
||||
|
||||
Reference in New Issue
Block a user