Compare commits

...

4 Commits

Author SHA1 Message Date
grandwizard28
b251aa60bf feat: revamp invite email 2026-02-13 18:07:37 +05:30
grandwizard28
b166b20069 feat: revamp invite email 2026-02-13 18:06:40 +05:30
Abhi kumar
3c30114642 feat: added option to copy legend text (#10294)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
build-staging / js-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat: added option to copy legend text

* chore: added test for legend copy action

* chore: updated legend styles

* chore: added check icon when legend copied

* chore: added copytoclipboard hook

* chore: removed copytoclipboard options
2026-02-13 08:08:04 +00:00
Ashwin Bhatkal
d042fad1e3 chore: shared utils update + API plumbing (#10257)
* chore: shared utils update + API plumbing

* chore: add tests
2026-02-13 06:05:44 +00:00
19 changed files with 994 additions and 36 deletions

View File

@@ -14,5 +14,8 @@
},
"[sql]": {
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
}
}

View File

@@ -193,6 +193,15 @@ emailing:
templates:
# The directory containing the email templates. This directory should contain a list of files defined at pkg/types/emailtypes/template.go.
directory: /opt/signoz/conf/templates/email
format:
header:
enabled: false
logo_url: ""
help:
enabled: false
email: ""
footer:
enabled: false
smtp:
# The SMTP server address.
address: localhost:25

View File

@@ -11,6 +11,7 @@ import {
const dashboardVariablesQuery = async (
props: Props,
signal?: AbortSignal,
): Promise<SuccessResponse<VariableResponseProps> | ErrorResponse> => {
try {
const { globalTime } = store.getState();
@@ -32,7 +33,7 @@ const dashboardVariablesQuery = async (
payload.variables = { ...payload.variables, ...timeVariables };
const response = await axios.post(`/variables/query`, payload);
const response = await axios.post(`/variables/query`, payload, { signal });
return {
statusCode: 200,

View File

@@ -19,6 +19,7 @@ export const getFieldValues = async (
startUnixMilli?: number,
endUnixMilli?: number,
existingQuery?: string,
abortSignal?: AbortSignal,
): Promise<SuccessResponseV2<FieldValueResponse>> => {
const params: Record<string, string> = {};
@@ -47,7 +48,10 @@ export const getFieldValues = async (
}
try {
const response = await axios.get('/fields/values', { params });
const response = await axios.get('/fields/values', {
params,
signal: abortSignal,
});
// Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) {

View File

@@ -73,6 +73,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
enableRegexOption = false,
isDynamicVariable = false,
showRetryButton = true,
waitingMessage,
...rest
}) => {
// ===== State & Refs =====
@@ -1681,6 +1682,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{!loading &&
!errorMessage &&
!noDataMessage &&
!waitingMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
@@ -1698,7 +1700,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
<div className="navigation-text">Refreshing values...</div>
</div>
)}
{errorMessage && !loading && (
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}
</div>
</div>
)}
{errorMessage && !loading && !waitingMessage && (
<div className="navigation-error">
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
@@ -1720,6 +1732,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!waitingMessage &&
!errorMessage && (
<div className="navigation-text-incomplete">
Don&apos;t see the value? Use search
@@ -1762,6 +1775,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
isDarkMode,
isDynamicVariable,
showRetryButton,
waitingMessage,
]);
// Custom handler for dropdown visibility changes

View File

@@ -63,6 +63,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
showIncompleteDataMessage = false,
showRetryButton = true,
isDynamicVariable = false,
waitingMessage,
...rest
}) => {
// ===== State & Refs =====
@@ -568,6 +569,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{!loading &&
!errorMessage &&
!noDataMessage &&
!waitingMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
@@ -583,6 +585,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="navigation-text">Refreshing values...</div>
</div>
)}
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}
</div>
</div>
)}
{errorMessage && !loading && (
<div className="navigation-error">
<div className="navigation-text">
@@ -605,6 +617,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!waitingMessage &&
!errorMessage && (
<div className="navigation-text-incomplete">
Don&apos;t see the value? Use search
@@ -641,6 +654,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
showRetryButton,
isDarkMode,
isDynamicVariable,
waitingMessage,
]);
// Handle dropdown visibility changes

View File

@@ -30,6 +30,7 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
showIncompleteDataMessage?: boolean;
showRetryButton?: boolean;
isDynamicVariable?: boolean;
waitingMessage?: string;
}
export interface CustomTagProps {
@@ -66,4 +67,5 @@ export interface CustomMultiSelectProps
enableRegexOption?: boolean;
isDynamicVariable?: boolean;
showRetryButton?: boolean;
waitingMessage?: string;
}

View File

@@ -0,0 +1,61 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_COPIED_RESET_MS = 2000;
export interface UseCopyToClipboardOptions {
/** How long (ms) to keep "copied" state before resetting. Default 2000. */
copiedResetMs?: number;
}
export type ID = number | string | null;
export interface UseCopyToClipboardReturn {
/** Copy text to clipboard. Pass an optional id to track which item was copied (e.g. seriesIndex). */
copyToClipboard: (text: string, id?: ID) => void;
/** True when something was just copied and still within the reset threshold. */
isCopied: boolean;
/** The id passed to the last successful copy, or null after reset. Use to show "copied" state for a specific item (e.g. copiedId === item.seriesIndex). */
id: ID;
}
export function useCopyToClipboard(
options: UseCopyToClipboardOptions = {},
): UseCopyToClipboardReturn {
const { copiedResetMs = DEFAULT_COPIED_RESET_MS } = options;
const [state, setState] = useState<{ isCopied: boolean; id: ID }>({
isCopied: false,
id: null,
});
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return (): void => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const copyToClipboard = useCallback(
(text: string, id?: ID): void => {
navigator.clipboard.writeText(text).then(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setState({ isCopied: true, id: id ?? null });
timeoutRef.current = setTimeout(() => {
setState({ isCopied: false, id: null });
timeoutRef.current = null;
}, copiedResetMs);
});
},
[copiedResetMs],
);
return {
copyToClipboard,
isCopied: state.isCopied,
id: state.id,
};
}

View File

@@ -0,0 +1,445 @@
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
buildVariableReferencePattern,
extractQueryTextStrings,
getVariableReferencesInQuery,
textContainsVariableReference,
} from './variableReference';
describe('buildVariableReferencePattern', () => {
const varName = 'deployment_environment';
it.each([
['{{.deployment_environment}}', '{{.var}} syntax'],
['{{ .deployment_environment }}', '{{.var}} with spaces'],
['{{deployment_environment}}', '{{var}} syntax'],
['{{ deployment_environment }}', '{{var}} with spaces'],
['$deployment_environment', '$var syntax'],
['[[deployment_environment]]', '[[var]] syntax'],
['[[ deployment_environment ]]', '[[var]] with spaces'],
])('matches %s (%s)', (text) => {
expect(buildVariableReferencePattern(varName).test(text)).toBe(true);
});
it('does not match partial variable names', () => {
const pattern = buildVariableReferencePattern('env');
// $env should match at word boundary, but $environment should not match $env
expect(pattern.test('$environment')).toBe(false);
});
it('matches $var at word boundary within larger text', () => {
const pattern = buildVariableReferencePattern('env');
expect(pattern.test('SELECT * WHERE x = $env')).toBe(true);
expect(pattern.test('$env AND y = 1')).toBe(true);
});
});
describe('textContainsVariableReference', () => {
describe('guard clauses', () => {
it('returns false for empty text', () => {
expect(textContainsVariableReference('', 'var')).toBe(false);
});
it('returns false for empty variable name', () => {
expect(textContainsVariableReference('some text', '')).toBe(false);
});
});
describe('all syntax formats', () => {
const varName = 'service_name';
it('detects {{.var}} format', () => {
const query = "SELECT * FROM table WHERE service = '{{.service_name}}'";
expect(textContainsVariableReference(query, varName)).toBe(true);
});
it('detects {{var}} format', () => {
const query = "SELECT * FROM table WHERE service = '{{service_name}}'";
expect(textContainsVariableReference(query, varName)).toBe(true);
});
it('detects $var format', () => {
const query = "SELECT * FROM table WHERE service = '$service_name'";
expect(textContainsVariableReference(query, varName)).toBe(true);
});
it('detects [[var]] format', () => {
const query = "SELECT * FROM table WHERE service = '[[service_name]]'";
expect(textContainsVariableReference(query, varName)).toBe(true);
});
});
describe('embedded in larger text', () => {
it('finds variable in a multi-line query', () => {
const query = `SELECT JSONExtractString(labels, 'k8s_node_name') AS k8s_node_name
FROM signoz_metrics.distributed_time_series_v4_1day
WHERE metric_name = 'k8s_node_cpu_time' AND JSONExtractString(labels, 'k8s_cluster_name') = {{.k8s_cluster_name}}
GROUP BY k8s_node_name`;
expect(textContainsVariableReference(query, 'k8s_cluster_name')).toBe(true);
expect(textContainsVariableReference(query, 'k8s_node_name')).toBe(false); // plain text, not a variable reference
});
});
describe('no false positives', () => {
it('does not match substring of a longer variable name', () => {
expect(
textContainsVariableReference('$service_name_v2', 'service_name'),
).toBe(false);
});
it('does not match plain text that happens to contain the name', () => {
expect(
textContainsVariableReference(
'the service_name column is important',
'service_name',
),
).toBe(false);
});
});
});
// ---- Query text extraction & variable reference detection ----
const baseQuery: Query = {
id: 'test-query',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
clickhouse_sql: [],
};
describe('extractQueryTextStrings', () => {
it('returns empty array for query builder with no data', () => {
expect(extractQueryTextStrings(baseQuery)).toEqual([]);
});
it('extracts string values from query builder filter items', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
({
filters: {
items: [
{ id: '1', op: '=', value: ['$service_name', 'hardcoded'] },
{ id: '2', op: '=', value: '$env' },
],
op: 'AND',
},
} as unknown) as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
};
const texts = extractQueryTextStrings(query);
expect(texts).toEqual(['$service_name', 'hardcoded', '$env']);
});
it('extracts filter expression from query builder', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
({
filters: { items: [], op: 'AND' },
filter: { expression: 'env = $deployment_environment' },
} as unknown) as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
};
const texts = extractQueryTextStrings(query);
expect(texts).toEqual(['env = $deployment_environment']);
});
it('skips non-string filter values', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
({
filters: {
items: [{ id: '1', op: '=', value: [42, true] }],
op: 'AND',
},
} as unknown) as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
};
expect(extractQueryTextStrings(query)).toEqual([]);
});
it('extracts promql query strings', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.PROM,
promql: [
{ name: 'A', query: 'up{env="$env"}', legend: '', disabled: false },
{ name: 'B', query: 'cpu{ns="$namespace"}', legend: '', disabled: false },
],
};
expect(extractQueryTextStrings(query)).toEqual([
'up{env="$env"}',
'cpu{ns="$namespace"}',
]);
});
it('extracts clickhouse sql query strings', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.CLICKHOUSE,
clickhouse_sql: [
{
name: 'A',
query: 'SELECT * WHERE env = {{.env}}',
legend: '',
disabled: false,
},
],
};
expect(extractQueryTextStrings(query)).toEqual([
'SELECT * WHERE env = {{.env}}',
]);
});
it('accumulates texts across multiple queryData entries', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
({
filters: {
items: [{ id: '1', op: '=', value: '$env' }],
op: 'AND',
},
} as unknown) as IBuilderQuery,
({
filters: {
items: [{ id: '2', op: '=', value: ['$service_name'] }],
op: 'AND',
},
} as unknown) as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
};
expect(extractQueryTextStrings(query)).toEqual(['$env', '$service_name']);
});
it('collects both filter items and filter expression from the same queryData', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
({
filters: {
items: [{ id: '1', op: '=', value: '$service_name' }],
op: 'AND',
},
filter: { expression: 'env = $deployment_environment' },
} as unknown) as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
};
expect(extractQueryTextStrings(query)).toEqual([
'$service_name',
'env = $deployment_environment',
]);
});
it('skips promql entries with empty query strings', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.PROM,
promql: [
{ name: 'A', query: '', legend: '', disabled: false },
{ name: 'B', query: 'up{env="$env"}', legend: '', disabled: false },
],
};
expect(extractQueryTextStrings(query)).toEqual(['up{env="$env"}']);
});
it('skips clickhouse entries with empty query strings', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.CLICKHOUSE,
clickhouse_sql: [
{ name: 'A', query: '', legend: '', disabled: false },
{
name: 'B',
query: 'SELECT * WHERE x = {{.env}}',
legend: '',
disabled: false,
},
],
};
expect(extractQueryTextStrings(query)).toEqual([
'SELECT * WHERE x = {{.env}}',
]);
});
it('returns empty array for unknown query type', () => {
const query = {
...baseQuery,
queryType: ('unknown' as unknown) as EQueryType,
};
expect(extractQueryTextStrings(query)).toEqual([]);
});
});
describe('getVariableReferencesInQuery', () => {
const variableNames = [
'deployment_environment',
'service_name',
'endpoint',
'unused_var',
];
it('returns empty array when query has no text', () => {
expect(getVariableReferencesInQuery(baseQuery, variableNames)).toEqual([]);
});
it('detects variables referenced in query builder filters', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
({
filters: {
items: [
{ id: '1', op: '=', value: '$service_name' },
{ id: '2', op: 'IN', value: ['$deployment_environment'] },
],
op: 'AND',
},
} as unknown) as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
};
const result = getVariableReferencesInQuery(query, variableNames);
expect(result).toEqual(['deployment_environment', 'service_name']);
});
it('detects variables in promql queries', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.PROM,
promql: [
{
name: 'A',
query:
'http_requests{env="{{.deployment_environment}}", endpoint="$endpoint"}',
legend: '',
disabled: false,
},
],
};
const result = getVariableReferencesInQuery(query, variableNames);
expect(result).toEqual(['deployment_environment', 'endpoint']);
});
it('detects variables in clickhouse sql queries', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.CLICKHOUSE,
clickhouse_sql: [
{
name: 'A',
query: 'SELECT * FROM table WHERE service = [[service_name]]',
legend: '',
disabled: false,
},
],
};
const result = getVariableReferencesInQuery(query, variableNames);
expect(result).toEqual(['service_name']);
});
it('detects variables spread across multiple queryData entries', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
({
filters: {
items: [{ id: '1', op: '=', value: '$service_name' }],
op: 'AND',
},
} as unknown) as IBuilderQuery,
({
filter: { expression: 'env = $deployment_environment' },
} as unknown) as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
};
const result = getVariableReferencesInQuery(query, variableNames);
expect(result).toEqual(['deployment_environment', 'service_name']);
});
it('returns empty array when no variables are referenced', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.PROM,
promql: [
{
name: 'A',
query: 'up{job="api"}',
legend: '',
disabled: false,
},
],
};
expect(getVariableReferencesInQuery(query, variableNames)).toEqual([]);
});
it('returns empty array when variableNames list is empty', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.PROM,
promql: [
{
name: 'A',
query: 'up{env="$deployment_environment"}',
legend: '',
disabled: false,
},
],
};
expect(getVariableReferencesInQuery(query, [])).toEqual([]);
});
});

View File

@@ -0,0 +1,136 @@
import { isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
/**
* Builds a RegExp that matches any recognized variable reference syntax:
* {{.variableName}} — dot prefix, optional whitespace
* {{variableName}} — no dot, optional whitespace
* $variableName — dollar prefix, word-boundary terminated
* [[variableName]] — square brackets, optional whitespace
*/
export function buildVariableReferencePattern(variableName: string): RegExp {
const patterns = [
`\\{\\{\\s*?\\.${variableName}\\s*?\\}\\}`,
`\\{\\{\\s*${variableName}\\s*\\}\\}`,
`\\$${variableName}\\b`,
`\\[\\[\\s*${variableName}\\s*\\]\\]`,
];
return new RegExp(patterns.join('|'));
}
/**
* Returns true if `text` contains a reference to `variableName` in any of the
* recognized variable syntaxes.
*/
export function textContainsVariableReference(
text: string,
variableName: string,
): boolean {
if (!text || !variableName) {
return false;
}
return buildVariableReferencePattern(variableName).test(text);
}
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
* - QUERY_BUILDER: filter items' string values + filter.expression
* - PROM: each promql[].query
* - CLICKHOUSE: each clickhouse_sql[].query
*/
function extractQueryBuilderTexts(query: Query): string[] {
const texts: string[] = [];
const queryDataList = query.builder?.queryData;
if (isArray(queryDataList)) {
queryDataList.forEach((queryData) => {
// Collect string values from filter items
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
if (isArray(filter.value)) {
filter.value.forEach((v) => {
if (typeof v === 'string') {
texts.push(v);
}
});
} else if (typeof filter.value === 'string') {
texts.push(filter.value);
}
});
// Collect filter expression
if (queryData.filter?.expression) {
texts.push(queryData.filter.expression);
}
});
}
return texts;
}
function extractPromQLTexts(query: Query): string[] {
const texts: string[] = [];
if (isArray(query.promql)) {
query.promql.forEach((promqlQuery) => {
if (promqlQuery.query) {
texts.push(promqlQuery.query);
}
});
}
return texts;
}
function extractClickhouseSQLTexts(query: Query): string[] {
const texts: string[] = [];
if (isArray(query.clickhouse_sql)) {
query.clickhouse_sql.forEach((clickhouseQuery) => {
if (clickhouseQuery.query) {
texts.push(clickhouseQuery.query);
}
});
}
return texts;
}
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
* - QUERY_BUILDER: filter items' string values + filter.expression
* - PROM: each promql[].query
* - CLICKHOUSE: each clickhouse_sql[].query
*/
export function extractQueryTextStrings(query: Query): string[] {
if (query.queryType === EQueryType.QUERY_BUILDER) {
return extractQueryBuilderTexts(query);
}
if (query.queryType === EQueryType.PROM) {
return extractPromQLTexts(query);
}
if (query.queryType === EQueryType.CLICKHOUSE) {
return extractClickhouseSQLTexts(query);
}
return [];
}
/**
* Given a widget Query and an array of variable names, returns the subset of
* variable names that are referenced in the query text.
*
* This performs text-based detection only. Structural checks (like
* filter.key.key matching a variable attribute) are NOT included.
*/
export function getVariableReferencesInQuery(
query: Query,
variableNames: string[],
): string[] {
const texts = extractQueryTextStrings(query);
if (texts.length === 0) {
return [];
}
return variableNames.filter((name) =>
texts.some((text) => textContainsVariableReference(text, name)),
);
}

View File

@@ -128,6 +128,15 @@
opacity: 1;
}
.legend-item-label-trigger {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.legend-marker {
border-width: 2px;
border-radius: 50%;
@@ -157,10 +166,34 @@
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
user-select: none;
}
.legend-copy-button {
display: none;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 2px;
margin: 0;
border: none;
color: var(--bg-vanilla-400);
cursor: pointer;
border-radius: 4px;
opacity: 1;
transition: opacity 0.15s ease, color 0.15s ease;
&:hover {
color: var(--bg-vanilla-100);
}
}
&:hover {
background: rgba(255, 255, 255, 0.05);
.legend-copy-button {
display: flex;
opacity: 1;
}
}
}
@@ -172,4 +205,17 @@
}
}
}
.legend-item {
&:hover {
background: rgba(0, 0, 0, 0.05);
}
.legend-copy-button {
color: var(--bg-ink-400);
&:hover {
color: var(--bg-ink-500);
}
}
}
}

View File

@@ -2,8 +2,10 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input, Tooltip as AntdTooltip } from 'antd';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { Check, Copy } from 'lucide-react';
import { useLegendActions } from '../../hooks/useLegendActions';
import { LegendPosition, LegendProps } from '../types';
@@ -32,6 +34,7 @@ export default function Legend({
});
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
const legendItems = useMemo(() => Object.values(legendItemsMap), [
legendItemsMap,
@@ -59,26 +62,53 @@ export default function Legend({
);
}, [position, legendSearchQuery, legendItems]);
const handleCopyLegendItem = useCallback(
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
e.stopPropagation();
copyToClipboard(label, seriesIndex);
},
[copyToClipboard],
);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => (
<AntdTooltip key={item.seriesIndex} title={item.label}>
(item: LegendItem): JSX.Element => {
const isCopied = copiedId === item.seriesIndex;
return (
<div
key={item.seriesIndex}
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
<AntdTooltip title={item.label}>
<div className="legend-item-label-trigger">
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
<AntdTooltip title={isCopied ? 'Copied' : 'Copy'}>
<button
type="button"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
</AntdTooltip>
</div>
</AntdTooltip>
),
[focusedSeriesIndex, position],
);
},
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position],
);
const isEmptyState = useMemo(() => {

View File

@@ -1,5 +1,11 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import {
fireEvent,
render,
RenderResult,
screen,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
@@ -8,6 +14,9 @@ import { useLegendActions } from '../../hooks/useLegendActions';
import Legend from '../Legend/Legend';
import { LegendPosition } from '../types';
const mockWriteText = jest.fn().mockResolvedValue(undefined);
let clipboardSpy: jest.SpyInstance | undefined;
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
@@ -39,6 +48,15 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
>;
describe('Legend', () => {
beforeAll(() => {
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: () => Promise.resolve() },
writable: true,
configurable: true,
});
});
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
@@ -70,6 +88,11 @@ describe('Legend', () => {
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
mockWriteText.mockClear();
clipboardSpy = jest
.spyOn(navigator.clipboard, 'writeText')
.mockImplementation(mockWriteText);
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
@@ -86,6 +109,7 @@ describe('Legend', () => {
});
afterEach(() => {
clipboardSpy?.mockRestore();
jest.clearAllMocks();
});
@@ -210,4 +234,47 @@ describe('Legend', () => {
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
});
});
describe('copy action', () => {
it('copies the legend label to clipboard when copy button is clicked', () => {
renderLegend(LegendPosition.RIGHT);
const firstLegendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledTimes(1);
expect(mockWriteText).toHaveBeenCalledWith('A');
});
it('copies the correct label when copy is clicked on a different legend item', () => {
renderLegend(LegendPosition.RIGHT);
const thirdLegendItem = document.querySelector(
'[data-legend-item-id="2"]',
) as HTMLElement;
const copyButton = within(thirdLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledTimes(1);
expect(mockWriteText).toHaveBeenCalledWith('C');
});
it('does not call onLegendClick when copy button is clicked', () => {
renderLegend(LegendPosition.RIGHT);
const firstLegendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(onLegendClick).not.toHaveBeenCalled();
});
});
});

View File

@@ -10,6 +10,27 @@ type Config struct {
type Templates struct {
Directory string `mapstructure:"directory"`
Format Format `mapstructure:"format"`
}
type Format struct {
Header Header `mapstructure:"header" json:"header"`
Help Help `mapstructure:"help" json:"help"`
Footer Footer `mapstructure:"footer" json:"footer"`
}
type Header struct {
Enabled bool `mapstructure:"enabled" json:"enabled"`
LogoURL string `mapstructure:"logo_url" json:"logo_url"`
}
type Help struct {
Enabled bool `mapstructure:"enabled" json:"enabled"`
Email string `mapstructure:"email" json:"email"`
}
type Footer struct {
Enabled bool `mapstructure:"enabled" json:"enabled"`
}
type SMTP struct {
@@ -45,6 +66,19 @@ func newConfig() factory.Config {
Enabled: false,
Templates: Templates{
Directory: "/root/templates",
Format: Format{
Header: Header{
Enabled: false,
LogoURL: "",
},
Help: Help{
Enabled: false,
Email: "",
},
Footer: Footer{
Enabled: false,
},
},
},
SMTP: SMTP{
Address: "localhost:25",

View File

@@ -15,6 +15,7 @@ type provider struct {
settings factory.ScopedProviderSettings
store emailtypes.TemplateStore
client *client.Client
config emailing.Config
}
func NewFactory() factory.ProviderFactory[emailing.Emailing, emailing.Config] {
@@ -55,7 +56,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
return nil, err
}
return &provider{settings: settings, store: store, client: client}, nil
return &provider{settings: settings, store: store, client: client, config: config}, nil
}
func (provider *provider) SendHTML(ctx context.Context, to string, subject string, templateName emailtypes.TemplateName, data map[string]any) error {
@@ -69,6 +70,9 @@ func (provider *provider) SendHTML(ctx context.Context, to string, subject strin
return err
}
data["format"] = provider.config.Templates.Format
data["to"] = to
content, err := emailtypes.NewContent(template, data)
if err != nil {
return err

View File

@@ -146,11 +146,9 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
continue
}
if err := m.emailing.SendHTML(ctx, invites[i].Email.String(), "You are invited to join a team in SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"CustomerName": invites[i].Name,
"InviterName": creator.DisplayName,
"InviterEmail": creator.Email,
"Link": fmt.Sprintf("%s/signup?token=%s", bulkInvites.Invites[i].FrontendBaseUrl, invites[i].Token),
if err := m.emailing.SendHTML(ctx, invites[i].Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"inviter_email": creator.Email,
"link": fmt.Sprintf("%s/signup?token=%s", bulkInvites.Invites[i].FrontendBaseUrl, invites[i].Token),
}); err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err)
}

View File

@@ -16,7 +16,7 @@ var (
)
var (
TemplateNameInvitationEmail = TemplateName{valuer.NewString("invitation_email")}
TemplateNameInvitationEmail = TemplateName{valuer.NewString("invite")}
TemplateNameUpdateRole = TemplateName{valuer.NewString("update_role")}
TemplateNameResetPassword = TemplateName{valuer.NewString("reset_password_email")}
)

View File

@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<p>Hi {{.CustomerName}},</p>
<p>You have been invited to join SigNoz project by {{.InviterName}} ({{.InviterEmail}}).</p>
<p>Please click on the following button to accept the invitation:</p>
<a href="{{.Link}}" style="background-color: #000000; color: white; padding: 14px 20px; text-align: center; text-decoration: none; display: inline-block;">Accept Invitation</a>
<p>Button not working? Paste the following link into your browser:</p>
<p>{{.Link}}</p>
<p>Follow docs here 👉 to <a href="https://signoz.io/docs/cloud/">Get Started with SigNoz Cloud</a></p>
<p>Thanks,</p>
<p>SigNoz Team</p>
</body>
</html>

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>You're Invited to Join SigNoz</title>
</head>
<body
style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333333; background-color: #ffffff;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #ffffff;">
<tr>
<td align="center" style="padding: 0;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
style="background-color: #ffffff; max-width: 600px; width: 100%;">
{{ if .format.header.enabled }}
<tr>
<td align="center" style="padding: 40px 20px 24px 20px;">
<img src="{{.format.header.logo_url}}" alt="SigNoz" width="160" height="40"
style="display: block; border: 0; outline: none; max-width: 100%; height: auto;">
</td>
</tr>
{{ end }}
<tr>
<td style="padding: 24px 20px 32px 20px;">
<p style="margin: 0 0 24px 0; font-size: 16px; color: #1a1a1a;">Hi there,</p>
<p style="margin: 0 0 24px 0; font-size: 16px; color: #333333; line-height: 1.6;">
You've been invited by <strong>{{.inviter_email}}</strong> to join their SigNoz organization.
</p>
<p style="margin: 0 0 16px 0; font-size: 16px; color: #333333; line-height: 1.6;">
A new account has been created for you with the following details:
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"
style="margin: 0 0 24px 0;">
<tr>
<td
style="padding: 20px; background-color: #f5f5f5; border-radius: 6px; border-left: 4px solid #4E74F8;">
<p style="margin: 0; font-size: 15px; color: #333333; line-height: 1.6;">
<strong>Email:</strong> {{.to}}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 24px 0; font-size: 16px; color: #333333; line-height: 1.6;">
Accept the invitation to get started.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"
style="margin: 0 0 16px 0;">
<tr>
<td align="center">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="border-radius: 4px; background-color: #4E74F8;">
<a href="{{.link}}" target="_blank"
style="display: inline-block; padding: 16px 48px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 4px;">Accept
Invitation</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin: 0 0 4px 0; font-size: 13px; color: #666666; text-align: center;">Button not working?
Copy and paste this link into your browser:</p>
<p
style="margin: 0 0 32px 0; font-size: 13px; color: #4E74F8; word-break: break-all; text-align: center;">
<a href="{{.link}}" style="color: #4E74F8; text-decoration: none;">{{.link}}</a>
</p>
{{ if .format.help.enabled }}
<p style="margin: 0 0 32px 0; font-size: 16px; color: #333333; line-height: 1.6;">
Need help? Chat with our team in the SigNoz application or email us at <a
href="mailto:{{.format.help.email}}"
style="color: #4E74F8; text-decoration: none;">{{.format.help.email}}</a>.
</p>
{{ end }}
<p style="margin: 0; font-size: 16px; color: #333333; line-height: 1.6;">
Thanks,<br>
<strong>The SigNoz Team</strong>
</p>
</td>
</tr>
{{ if .format.footer.enabled }}
<tr>
<td align="center" style="padding: 32px 20px 40px 20px;">
<p style="margin: 0 0 8px 0; font-size: 12px; color: #999999; line-height: 1.5;">
<a href="https://signoz.io/terms-of-service/" style="color: #4E74F8; text-decoration: none;">Terms of
Service</a> -
<a href="https://signoz.io/privacy/" style="color: #4E74F8; text-decoration: none;">Privacy Policy</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999; line-height: 1.5;">
©2026 SigNoz Inc.
</p>
</td>
</tr>
{{ end }}
</table>
</td>
</tr>
</table>
</body>
</html>