Compare commits

..

4 Commits

Author SHA1 Message Date
Tushar Vats
509a1cfb85 fix: added constants to improve readability 2025-11-18 00:03:24 +05:30
Tushar Vats
fd118d386a fix: handle conflicting attributes 2025-11-17 20:54:50 +05:30
Abhi kumar
8752022cef fix: updated dashboard panel colors for better contrast ratio (#9500)
* fix: updated dashboard panel colors for better contrast ratio

* chore: preetier fix

* feat: added changes for the tooltip to follow cursor
2025-11-06 17:17:33 +05:30
Aditya Singh
c7e4a9c45d Fix: uplot dense points selection (#9469)
* feat: fix uplot focused series logic selection

* fix: stop propogation only if drilldown enabled

* feat: minor refactor

* feat: minor refactor

* feat: minor refactor

* feat: minor refactor

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-11-06 11:14:02 +00:00
29 changed files with 792 additions and 383 deletions

View File

@@ -10,6 +10,10 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso } from 'react-virtuoso';
@@ -85,11 +89,15 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
dataType: 'string',
type: '',
name: 'body',
displayName: 'Body',
key: LOG_FIELD_BODY_KEY,
},
{
dataType: 'string',
type: '',
name: 'timestamp',
displayName: 'Timestamp',
key: LOG_FIELD_TIMESTAMP_KEY,
},
]}
/>

View File

@@ -13,6 +13,10 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
import { FlatLogData } from 'lib/logs/flatLogData';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useMemo, useState } from 'react';
// interfaces
@@ -42,7 +46,9 @@ interface LogFieldProps {
}
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
Pick<AddToQueryHOCProps, 'onAddToQuery'> & {
fieldKeyDisplay: string;
};
function LogGeneralField({
fieldKey,
@@ -74,6 +80,7 @@ function LogGeneralField({
function LogSelectedField({
fieldKey = '',
fieldValue = '',
fieldKeyDisplay = '',
onAddToQuery,
fontSize,
}: LogSelectedFieldProps): JSX.Element {
@@ -90,7 +97,7 @@ function LogSelectedField({
style={{ color: blue[4] }}
className={cx('selected-log-field-key', fontSize)}
>
{fieldKey}
{fieldKeyDisplay}
</span>
</Typography.Text>
</AddToQueryHOC>
@@ -162,7 +169,7 @@ function ListLogView({
);
const updatedSelecedFields = useMemo(
() => selectedFields.filter((e) => e.name !== 'id'),
() => selectedFields.filter((e) => e.key !== 'id'),
[selectedFields],
);
@@ -170,16 +177,16 @@ function ListLogView({
const timestampValue = useMemo(
() =>
typeof flattenLogData.timestamp === 'string'
typeof flattenLogData[LOG_FIELD_TIMESTAMP_KEY] === 'string'
? formatTimezoneAdjustedTimestamp(
flattenLogData.timestamp,
flattenLogData[LOG_FIELD_TIMESTAMP_KEY],
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
flattenLogData.timestamp / 1e6,
flattenLogData[LOG_FIELD_TIMESTAMP_KEY] / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
),
[flattenLogData.timestamp, formatTimezoneAdjustedTimestamp],
[flattenLogData, formatTimezoneAdjustedTimestamp],
);
const logType = getLogIndicatorType(logData);
@@ -215,10 +222,12 @@ function ListLogView({
/>
<div>
<LogContainer fontSize={fontSize}>
{updatedSelecedFields.some((field) => field.name === 'body') && (
{updatedSelecedFields.some(
(field) => field.key === LOG_FIELD_BODY_KEY,
) && (
<LogGeneralField
fieldKey="Log"
fieldValue={flattenLogData.body}
fieldValue={flattenLogData[LOG_FIELD_BODY_KEY]}
linesPerRow={linesPerRow}
fontSize={fontSize}
/>
@@ -230,7 +239,9 @@ function ListLogView({
fontSize={fontSize}
/>
)}
{updatedSelecedFields.some((field) => field.name === 'timestamp') && (
{updatedSelecedFields.some(
(field) => field.key === LOG_FIELD_TIMESTAMP_KEY,
) && (
<LogGeneralField
fieldKey="Timestamp"
fieldValue={timestampValue}
@@ -239,13 +250,17 @@ function ListLogView({
)}
{updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.filter(
(field) =>
![LOG_FIELD_TIMESTAMP_KEY, LOG_FIELD_BODY_KEY].includes(field.key),
)
.map((field) =>
isValidLogField(flattenLogData[field.name] as never) ? (
isValidLogField(flattenLogData[field.key] as never) ? (
<LogSelectedField
key={field.name}
fieldKey={field.name}
fieldValue={flattenLogData[field.name] as never}
key={field.key}
fieldKey={field.key}
fieldKeyDisplay={field.displayName}
fieldValue={flattenLogData[field.key] as never}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
/>

View File

@@ -73,16 +73,25 @@ function RawLogView({
);
const attributesValues = updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) => flattenLogData[field.name])
.filter((attribute) => {
.filter(
(field) => !['log.timestamp:string', 'log.body:string'].includes(field.key),
)
.map((field) => {
const value = flattenLogData[field.key];
const label = field.displayName;
// loadash isEmpty doesnot work with numbers
if (isNumber(attribute)) {
return true;
if (isNumber(value)) {
return `${label}: ${value}`;
}
return !isUndefined(attribute) && !isEmpty(attribute);
});
if (!isUndefined(value) && !isEmpty(value)) {
return `${label}: ${value}`;
}
return null;
})
.filter((attribute) => attribute !== null);
let attributesText = attributesValues.join(' | ');

View File

@@ -6,7 +6,11 @@ import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { FlatLogData } from 'lib/logs/flatLogData';
import {
FlatLogData,
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { useMemo } from 'react';
@@ -51,28 +55,33 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
.filter((e) => !['id', 'body', 'timestamp'].includes(e.name))
.map(({ name }) => ({
title: name,
dataIndex: name,
accessorKey: name,
id: name.toLowerCase().replace(/\./g, '_'),
key: name,
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
},
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{field}
</Typography.Paragraph>
),
}),
.filter(
(e) => !['id', LOG_FIELD_BODY_KEY, LOG_FIELD_TIMESTAMP_KEY].includes(e.key),
)
.map((field) => ({
title: field.displayName,
dataIndex: field.key,
accessorKey: field.key,
id: field.key.toLowerCase().replace(/\./g, '_').replace(/:/g, '_'),
key: field.key,
render: (fieldValue, record): ColumnTypeRender<Record<string, unknown>> => {
const value = record[field.key] || fieldValue;
return {
props: {
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
},
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{value}
</Typography.Paragraph>
),
};
},
}));
if (isListViewPanel) {
@@ -100,26 +109,29 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
),
}),
},
...(fields.some((field) => field.name === 'timestamp')
...(fields.some((field) => field.key === LOG_FIELD_TIMESTAMP_KEY)
? [
{
title: 'timestamp',
dataIndex: 'timestamp',
dataIndex: LOG_FIELD_TIMESTAMP_KEY,
key: 'timestamp',
accessorKey: 'timestamp',
accessorKey: LOG_FIELD_TIMESTAMP_KEY,
id: 'timestamp',
// https://github.com/ant-design/ant-design/discussions/36886
render: (
field: string | number,
record: Record<string, unknown>,
): ColumnTypeRender<Record<string, unknown>> => {
const timestampValue =
(record[LOG_FIELD_TIMESTAMP_KEY] as string | number) || field;
const date =
typeof field === 'string'
typeof timestampValue === 'string'
? formatTimezoneAdjustedTimestamp(
field,
timestampValue,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
field / 1e6,
timestampValue / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return {
@@ -136,33 +148,37 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
]
: []),
...(appendTo === 'center' ? fieldColumns : []),
...(fields.some((field) => field.name === 'body')
...(fields.some((field) => field.key === LOG_FIELD_BODY_KEY)
? [
{
title: 'body',
dataIndex: 'body',
dataIndex: LOG_FIELD_BODY_KEY,
key: 'body',
accessorKey: 'body',
accessorKey: LOG_FIELD_BODY_KEY,
id: 'body',
render: (
field: string | number,
): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: bodyColumnStyle,
},
children: (
<TableBodyContent
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(field as string, {
shouldEscapeHtml: true,
}),
}}
fontSize={fontSize}
linesPerRow={linesPerRow}
isDarkMode={isDarkMode}
/>
),
}),
record: Record<string, unknown>,
): ColumnTypeRender<Record<string, unknown>> => {
const bodyValue = (record[LOG_FIELD_BODY_KEY] as string) || '';
return {
props: {
style: bodyColumnStyle,
},
children: (
<TableBodyContent
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(bodyValue, {
shouldEscapeHtml: true,
}),
}}
fontSize={fontSize}
linesPerRow={linesPerRow}
isDarkMode={isDarkMode}
/>
),
};
},
},
]
: []),

View File

@@ -416,18 +416,21 @@ function OptionsMenu({
)}
<div className="column-format">
{addColumn?.value?.map(({ name }) => (
<div className="column-name" key={name}>
{addColumn?.value?.map((column) => (
<div className="column-name" key={column.key}>
<div className="name">
<Tooltip placement="left" title={name}>
{name}
<Tooltip
placement="left"
title={column.displayName || column.name}
>
{column.displayName || column.name}
</Tooltip>
</div>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(name)}
onClick={(): void => addColumn.onRemove(column.key)}
/>
)}
</div>

View File

@@ -17,12 +17,6 @@ export const Card = styled(CardComponent)<CardProps>`
overflow: hidden;
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
background: linear-gradient(
0deg,
rgba(171, 189, 255, 0) 0%,
rgba(171, 189, 255, 0) 100%
),
#0b0c0e;
${({ isDarkMode }): StyledCSS =>
!isDarkMode &&

View File

@@ -11,6 +11,10 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso } from 'react-virtuoso';
@@ -75,11 +79,15 @@ function EntityLogs({
dataType: 'string',
type: '',
name: 'body',
displayName: 'Body',
key: LOG_FIELD_BODY_KEY,
},
{
dataType: 'string',
type: '',
name: 'timestamp',
displayName: 'Timestamp',
key: LOG_FIELD_TIMESTAMP_KEY,
},
]}
/>

View File

@@ -13,7 +13,6 @@ import InfinityTableView from 'container/LogsExplorerList/InfinityTableView';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useEventSource } from 'providers/EventSource';
@@ -57,10 +56,7 @@ function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
[formattedLogs, activeLogId],
);
const selectedFields = convertKeysToColumnFields([
...defaultLogsSelectedColumns,
...options.selectColumns,
]);
const selectedFields = convertKeysToColumnFields(options.selectColumns);
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => {

View File

@@ -13,7 +13,9 @@ export const convertKeysToColumnFields = (
.filter((item) => !isEmpty(item.name))
.map((item) => ({
dataType: item.fieldDataType ?? '',
name: item.name,
name: item.name ?? '',
key: item.key ?? '',
displayName: item.displayName ?? '',
type: item.fieldContext ?? '',
}));
/**

View File

@@ -1,6 +1,10 @@
import { ColumnsType } from 'antd/es/table';
import { Typography } from 'antd/lib';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
// import Typography from 'antd/es/typography/Typography';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { ReactNode } from 'react';
@@ -18,15 +22,15 @@ export const getLogPanelColumnsList = (
const columns: ColumnsType<RowData> =
selectedLogFields?.map((field: IField) => {
const { name } = field;
const { name, key, displayName } = field;
return {
title: name,
dataIndex: name,
key: name,
width: name === 'body' ? 350 : 100,
title: displayName,
dataIndex: key,
key,
width: key === LOG_FIELD_BODY_KEY ? 350 : 100,
render: (value: ReactNode): JSX.Element => {
if (name === 'timestamp') {
if (key === LOG_FIELD_TIMESTAMP_KEY) {
return (
<Typography.Text>
{formatTimezoneAdjustedTimestamp(value as string)}
@@ -34,7 +38,7 @@ export const getLogPanelColumnsList = (
);
}
if (name === 'body') {
if (key === LOG_FIELD_BODY_KEY) {
return (
<Typography.Paragraph ellipsis={{ rows: 1 }} data-testid={name}>
{value}

View File

@@ -558,8 +558,10 @@ export const getDefaultWidgetData = (
decimalPrecision: PrecisionOptionsEnum.TWO, // default decimal precision
selectedLogFields: defaultLogsSelectedColumns.map((field) => ({
...field,
key: field.key ?? '',
type: field.fieldContext ?? '',
dataType: field.fieldDataType ?? '',
displayName: field.displayName || field.name,
})),
selectedTracesFields: defaultTraceSelectedColumns,
});

View File

@@ -42,10 +42,12 @@ function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
</SearchIconWrapper>
</Input.Group>
{config.value?.map(({ name }) => (
<AddColumnItem direction="horizontal" key={name}>
<Typography>{name}</Typography>
<DeleteOutlinedIcon onClick={(): void => config.onRemove(name)} />
{config.value?.map((column) => (
<AddColumnItem direction="horizontal" key={column.key}>
<Typography>{column.displayName || column.name}</Typography>
<DeleteOutlinedIcon
onClick={(): void => config.onRemove(column.key || column.name)}
/>
</AddColumnItem>
))}
</AddColumnWrapper>

View File

@@ -1,4 +1,8 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { FontSize, OptionsQuery } from './types';
@@ -16,15 +20,19 @@ export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
name: 'timestamp',
signal: 'logs',
fieldContext: 'log',
fieldDataType: '',
fieldDataType: 'string',
isIndexed: false,
key: LOG_FIELD_TIMESTAMP_KEY,
displayName: 'Timestamp',
},
{
name: 'body',
signal: 'logs',
fieldContext: 'log',
fieldDataType: '',
fieldDataType: 'string',
isIndexed: false,
key: LOG_FIELD_BODY_KEY,
displayName: 'Body',
},
];
@@ -34,29 +42,47 @@ export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
key: 'resource.service.name:string',
displayName: 'Service Name',
},
{
name: 'name',
signal: 'traces',
fieldContext: 'span',
fieldDataType: 'string',
key: 'span.name:string',
displayName: 'Span Name',
},
{
name: 'duration_nano',
signal: 'traces',
fieldContext: 'span',
fieldDataType: '',
fieldDataType: 'number',
key: 'span.duration_nano:number',
displayName: 'Duration',
},
{
name: 'http_method',
signal: 'traces',
fieldContext: 'span',
fieldDataType: '',
fieldDataType: 'string',
key: 'span.http_method:string',
displayName: 'HTTP Method',
},
{
name: 'response_status_code',
signal: 'traces',
fieldContext: 'span',
fieldDataType: '',
fieldDataType: 'string',
key: 'span.response_status_code:string',
displayName: 'Status Code',
},
{
name: 'timestamp',
signal: 'traces',
fieldContext: 'span',
fieldDataType: 'string',
key: 'span.timestamp:string',
displayName: 'Timestamp',
},
];

View File

@@ -16,11 +16,6 @@ import {
QueryKeyRequestProps,
QueryKeySuggestionsResponseProps,
} from 'types/api/querySuggestions/types';
import {
FieldContext,
FieldDataType,
SignalType,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -35,7 +30,11 @@ import {
OptionsMenuConfig,
OptionsQuery,
} from './types';
import { getOptionsFromKeys } from './utils';
import {
createTelemetryFieldKey,
getOptionsFromKeys,
resolveColumnConflicts,
} from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
@@ -104,28 +103,19 @@ const useOptionsMenu = ({
return [];
}
const attributesData = initialAttributesResult?.reduce(
(acc: TelemetryFieldKey[], attributeResponse): TelemetryFieldKey[] => {
const suggestions =
Object.values(attributeResponse?.data?.data?.data?.keys || {}).flat() ||
[];
// Collect all suggestions from all API responses first
const allSuggestions =
initialAttributesResult?.flatMap((attributeResponse) =>
Object.values(attributeResponse?.data?.data?.data?.keys || {})
.flat()
.map((suggestion) => createTelemetryFieldKey(suggestion)),
) || [];
const mappedSuggestions: TelemetryFieldKey[] = suggestions.map(
(suggestion) => ({
name: suggestion.name,
signal: suggestion.signal as SignalType,
fieldDataType: suggestion.fieldDataType as FieldDataType,
fieldContext: suggestion.fieldContext as FieldContext,
}),
);
return [...acc, ...mappedSuggestions];
},
[],
);
// Resolve conflicts and deduplicate once at the end for better performance
const attributesData = resolveColumnConflicts(allSuggestions);
let initialSelected: TelemetryFieldKey[] = (initialOptions?.selectColumns
?.map((column) => attributesData.find(({ name }) => name === column))
?.map((column) => attributesData.find(({ key }) => key === column))
.filter((e) => !!e) || []) as TelemetryFieldKey[];
if (dataSource === DataSource.TRACES) {
@@ -133,13 +123,15 @@ const useOptionsMenu = ({
?.map((col) => {
if (col && Object.keys(AllTraceFilterKeyValue).includes(col?.name)) {
const metaData = defaultTraceSelectedColumns.find(
(coln) => coln.name === col.name,
(coln) => coln.key === col.key,
);
return {
...metaData,
name: metaData?.name || '',
};
if (metaData) {
return {
...metaData,
name: metaData.name,
};
}
}
return col;
})
@@ -187,40 +179,23 @@ const useOptionsMenu = ({
const searchedAttributesDataList = Object.values(
searchedAttributesDataV5?.data.data.keys || {},
).flat();
if (searchedAttributesDataList.length) {
if (dataSource === DataSource.LOGS) {
const logsSelectedColumns: TelemetryFieldKey[] = defaultLogsSelectedColumns.map(
(e) => ({
...e,
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
}),
);
return [
...logsSelectedColumns,
...searchedAttributesDataList
.filter((attribute) => attribute.name !== 'body')
// eslint-disable-next-line sonarjs/no-identical-functions
.map((e) => ({
...e,
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
})),
];
}
// eslint-disable-next-line sonarjs/no-identical-functions
return searchedAttributesDataList.map((e) => ({
...e,
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
}));
// Map all attributes with proper key and displayName
const mappedAttributes = searchedAttributesDataList.map((e) =>
createTelemetryFieldKey(e),
);
// Combine with default columns and resolve conflicts
const allColumns =
dataSource === DataSource.LOGS
? [...defaultLogsSelectedColumns, ...mappedAttributes]
: mappedAttributes;
// Resolve conflicts with deduplication
return resolveColumnConflicts(allColumns);
}
if (dataSource === DataSource.TRACES) {
return defaultTraceSelectedColumns.map((e) => ({
...e,
@@ -234,19 +209,10 @@ const useOptionsMenu = ({
const initialOptionsQuery: OptionsQuery = useMemo(() => {
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;
if (dataSource === DataSource.TRACES) {
defaultColumns = defaultTraceSelectedColumns.map((e) => ({
...e,
name: e.name,
}));
defaultColumns = defaultTraceSelectedColumns;
} else if (dataSource === DataSource.LOGS) {
// eslint-disable-next-line sonarjs/no-identical-functions
defaultColumns = defaultLogsSelectedColumns.map((e) => ({
...e,
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
}));
defaultColumns = defaultLogsSelectedColumns;
}
const finalSelectColumns = initialOptions?.selectColumns
@@ -261,7 +227,7 @@ const useOptionsMenu = ({
}, [dataSource, initialOptions, initialSelectedColumns]);
const selectedColumnKeys = useMemo(
() => preferences?.columns?.map(({ name }) => name) || [],
() => preferences?.columns?.map(({ key }) => key) || [],
[preferences?.columns],
);
@@ -290,7 +256,7 @@ const useOptionsMenu = ({
const column = [
...searchedAttributeKeys,
...(preferences?.columns || []),
].find(({ name }) => name === key);
].find((column) => column.key === key);
if (!column) return acc;
return [...acc, column];
@@ -319,7 +285,7 @@ const useOptionsMenu = ({
const handleRemoveSelectedColumn = useCallback(
(columnKey: string) => {
const newSelectedColumns = preferences?.columns?.filter(
({ name }) => name !== columnKey,
(column) => column.key !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {

View File

@@ -1,13 +1,163 @@
import { SelectProps } from 'antd';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
FieldContext,
FieldDataType,
SignalType,
} from 'types/api/v5/queryRange';
/**
* Display name mapping for log fieldContext columns
* Provides user-friendly names for standard log fields
*/
const LOG_FIELD_DISPLAY_NAMES: Record<string, string> = {
body: 'Body',
severity_number: 'Severity Number',
severity_text: 'Severity Text',
span_id: 'Span ID',
trace_flags: 'Trace Flags',
trace_id: 'Trace ID',
scope_name: 'Scope Name',
scope_version: 'Scope Version',
};
/**
* Helper function to create a TelemetryFieldKey with properly formatted key and displayName
* Ensures consistent key format: fieldContext.name:fieldDataType
* Uses display name map for log fieldContext columns
* @param suggestion - The raw suggestion data from the API
* @returns A TelemetryFieldKey with key and displayName fields properly set
*/
export function createTelemetryFieldKey(suggestion: any): TelemetryFieldKey {
let displayName = suggestion.displayName || suggestion.name;
// Use mapped display name for log fieldContext columns
// We need to check the fieldContext to avoid overriding non-log fields coming from attributes
if (
(suggestion.fieldContext === 'log' || suggestion.fieldContext === 'scope') &&
LOG_FIELD_DISPLAY_NAMES[suggestion.name]
) {
displayName = LOG_FIELD_DISPLAY_NAMES[suggestion.name];
}
return {
name: suggestion.name,
displayName,
key: `${suggestion.fieldContext}.${suggestion.name}:${suggestion.fieldDataType}`,
signal: suggestion.signal as SignalType,
fieldDataType: suggestion.fieldDataType as FieldDataType,
fieldContext: suggestion.fieldContext as FieldContext,
};
}
/**
* Generates a suffix for a column display name based on conflicts
* @param column - The column to generate suffix for
* @param hasContextConflict - Whether there's a conflict in fieldContext
* @param hasDataTypeConflict - Whether there's a conflict in fieldDataType
* @returns The suffix string to append to the column name
*/
function generateColumnSuffix(
column: TelemetryFieldKey,
hasContextConflict: boolean,
hasDataTypeConflict: boolean,
): string {
if (hasContextConflict && column.fieldContext) {
return ` (${column.fieldContext})`;
}
if (!hasContextConflict && hasDataTypeConflict && column.fieldDataType) {
return ` (${column.fieldDataType})`;
}
return '';
}
/**
* Updates display names for conflicting columns in the columnsByKey map
* @param columns - Array of columns with the same name
* @param columnsByKey - Map to update with new display names
*/
function updateConflictingDisplayNames(
columns: TelemetryFieldKey[],
columnsByKey: Map<string, TelemetryFieldKey>,
): void {
const contexts = new Set(columns.map((c) => c.fieldContext));
const dataTypes = new Set(columns.map((c) => c.fieldDataType));
const hasContextConflict = contexts.size > 1;
const hasDataTypeConflict = dataTypes.size > 1;
if (!hasContextConflict && !hasDataTypeConflict) {
return;
}
columns.forEach((column) => {
// Skip if already has a custom displayName (not just the name)
if (column.displayName && column.displayName !== column.name) {
return;
}
const suffix = generateColumnSuffix(
column,
hasContextConflict,
hasDataTypeConflict,
);
if (suffix) {
columnsByKey.set(column.key || column.name, {
...column,
displayName: `${column.name}${suffix}`,
});
}
});
}
/**
* Processes a list of TelemetryFieldKeys and updates displayName for conflicting columns
* Adds suffix with fieldContext and/or fieldDataType when columns have the same name
* but different context or data type.
* Note: 'log' & 'scope' fieldContext suffix is hidden as it's the default context for logs.
* Also deduplicates columns with the same key.
* @param suggestions - Array of TelemetryFieldKey objects to process
* @returns Array with updated displayNames for conflicting columns and no duplicates
*/
export function resolveColumnConflicts(
suggestions: TelemetryFieldKey[],
): TelemetryFieldKey[] {
// Use Map for O(1) deduplication by key
const columnsByKey = new Map<string, TelemetryFieldKey>();
// Track columns by name to detect conflicts
const columnsByName = new Map<string, TelemetryFieldKey[]>();
// First pass: deduplicate by key and group by name
suggestions.forEach((suggestion) => {
// Skip duplicates (same key)
if (columnsByKey.has(suggestion.key || suggestion.name)) {
return;
}
columnsByKey.set(suggestion.key || suggestion.name, suggestion);
// Group by name for conflict detection
const existing = columnsByName.get(suggestion.name) || [];
columnsByName.set(suggestion.name, [...existing, suggestion]);
});
// Second pass: resolve conflicts for columns with same name
columnsByName.forEach((columns) => {
if (columns.length > 1) {
updateConflictingDisplayNames(columns, columnsByKey);
}
});
return Array.from(columnsByKey.values());
}
export const getOptionsFromKeys = (
keys: TelemetryFieldKey[],
selectedKeys: (string | undefined)[],
): SelectProps['options'] => {
const options = keys.map(({ name }) => ({
label: name,
value: name,
const options = keys.map(({ key, displayName, name }) => ({
label: displayName || name,
value: key,
}));
return options.filter(

View File

@@ -90,8 +90,9 @@ export function QueryTable({
column: any,
tableColumns: any,
): void => {
e.stopPropagation();
if (isQueryTypeBuilder && enableDrillDown) {
e.stopPropagation();
onClick({ x: e.clientX, y: e.clientY }, { record, column, tableColumns });
}
},

View File

@@ -20,6 +20,10 @@ import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import createQueryParams from 'lib/createQueryParams';
import {
LOG_FIELD_BODY_KEY,
LOG_FIELD_TIMESTAMP_KEY,
} from 'lib/logs/flatLogData';
import { Compass } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
@@ -182,12 +186,16 @@ function SpanLogs({
{
dataType: 'string',
type: '',
displayName: 'Body',
name: 'body',
key: LOG_FIELD_BODY_KEY,
},
{
dataType: 'string',
type: '',
name: 'timestamp',
key: LOG_FIELD_TIMESTAMP_KEY,
displayName: 'Timestamp',
},
]}
/>

View File

@@ -1,17 +1,61 @@
import { defaultTo } from 'lodash-es';
import { ILog } from 'types/api/logs/log';
// Exported constants for top-level field mappings
export const LOG_FIELD_ID_KEY = 'id';
export const LOG_FIELD_TIMESTAMP_KEY = 'log.timestamp:string';
export const LOG_FIELD_BODY_KEY = 'log.body:string';
export const LOG_FIELD_SPAN_ID_KEY = 'log.span_id:string';
export const LOG_FIELD_TRACE_ID_KEY = 'log.trace_id:string';
export const LOG_FIELD_TRACE_FLAGS_KEY = 'log.trace_flags:number';
export const LOG_FIELD_SEVERITY_TEXT_KEY = 'log.severity_text:string';
export const LOG_FIELD_SEVERITY_NUMBER_KEY = 'log.severity_number:number';
export const LOG_FIELD_SCOPE_NAME_KEY = 'scope.scope_name:string';
export const LOG_FIELD_SCOPE_VERSION_KEY = 'scope.scope_version:string';
export function FlatLogData(log: ILog): Record<string, string> {
const flattenLogObject: Record<string, string> = {};
Object.keys(log).forEach((key: string): void => {
if (typeof log[key as never] !== 'object') {
flattenLogObject[key] = log[key as never];
} else {
Object.keys(defaultTo(log[key as never], {})).forEach((childKey) => {
flattenLogObject[childKey] = log[key as never][childKey];
// Map of field names to their contexts and data types
const fieldMappings: Record<string, { context: string; datatype: string }> = {
resources_string: { context: 'resource', datatype: 'string' },
scope_string: { context: 'scope', datatype: 'string' },
attributes_string: { context: 'attribute', datatype: 'string' },
attributes_number: { context: 'attribute', datatype: 'number' },
attributes_bool: { context: 'attribute', datatype: 'bool' },
};
// Flatten specific fields with context and datatype
Object.entries(fieldMappings).forEach(([fieldKey, { context, datatype }]) => {
const fieldData = log[fieldKey as keyof ILog];
if (fieldData && typeof fieldData === 'object') {
Object.entries(defaultTo(fieldData, {})).forEach(([key, value]) => {
const flatKey = `${context}.${key}:${datatype}`;
flattenLogObject[flatKey] = String(value);
});
}
});
// Add top-level fields
const topLevelFieldsToContextMapping: Record<string, string> = {
id: LOG_FIELD_ID_KEY,
timestamp: LOG_FIELD_TIMESTAMP_KEY,
body: LOG_FIELD_BODY_KEY,
span_id: LOG_FIELD_SPAN_ID_KEY,
trace_id: LOG_FIELD_TRACE_ID_KEY,
trace_flags: LOG_FIELD_TRACE_FLAGS_KEY,
severity_text: LOG_FIELD_SEVERITY_TEXT_KEY,
severity_number: LOG_FIELD_SEVERITY_NUMBER_KEY,
scope_name: LOG_FIELD_SCOPE_NAME_KEY,
scope_version: LOG_FIELD_SCOPE_VERSION_KEY,
};
Object.entries(topLevelFieldsToContextMapping).forEach(([field, key]) => {
const value = log[field as keyof ILog];
if (value !== undefined && value !== null) {
flattenLogObject[key] = String(value);
}
});
return flattenLogObject;
}

View File

@@ -16,8 +16,20 @@
// https://tobyzerner.github.io/placement.js/dist/index.js
/**
* Positions an element (tooltip/popover) relative to a reference element.
* Automatically flips to the opposite side if there's insufficient space.
*
* @param element - The HTMLElement to position
* @param reference - Reference element/Range or bounding rect
* @param side - Preferred side: 'top', 'bottom', 'left', 'right' (default: 'bottom')
* @param align - Alignment: 'start', 'center', 'end' (default: 'center')
* @param options - Optional bounds for constraining the element
* - bound: Custom boundary rect/element
* - followCursor: { x, y } - If provided, tooltip follows cursor with smart positioning
*/
export const placement = (function () {
const e = {
const AXIS_PROPS = {
size: ['height', 'width'],
clientSize: ['clientHeight', 'clientWidth'],
offsetSize: ['offsetHeight', 'offsetWidth'],
@@ -28,87 +40,241 @@ export const placement = (function () {
marginAfter: ['marginBottom', 'marginRight'],
scrollOffset: ['pageYOffset', 'pageXOffset'],
};
function t(e) {
return { top: e.top, bottom: e.bottom, left: e.left, right: e.right };
}
return function (o, r, f, a, i) {
void 0 === f && (f = 'bottom'),
void 0 === a && (a = 'center'),
void 0 === i && (i = {}),
(r instanceof Element || r instanceof Range) &&
(r = t(r.getBoundingClientRect()));
const n = {
top: r.bottom,
bottom: r.top,
left: r.right,
right: r.left,
...r,
function extractRect(source) {
return {
top: source.top,
bottom: source.bottom,
left: source.left,
right: source.right,
};
const s = {
}
return function (element, reference, side, align, options) {
// Default parameters
void 0 === side && (side = 'bottom');
void 0 === align && (align = 'center');
void 0 === options && (options = {});
// Handle cursor following mode
if (options.followCursor) {
const cursorX = options.followCursor.x;
const cursorY = options.followCursor.y;
const offset = options.followCursor.offset || 10; // Default 10px offset from cursor
element.style.position = 'absolute';
element.style.maxWidth = '';
element.style.maxHeight = '';
const elementWidth = element.offsetWidth;
const elementHeight = element.offsetHeight;
// Use viewport bounds for cursor following (not chart bounds)
const viewportBounds = {
top: 0,
left: 0,
bottom: window.innerHeight,
right: window.innerWidth,
};
// Vertical positioning: follow cursor Y with offset, clamped to viewport
const topPosition = cursorY + offset;
const clampedTop = Math.max(
viewportBounds.top,
Math.min(topPosition, viewportBounds.bottom - elementHeight),
);
element.style.top = `${clampedTop}px`;
element.style.bottom = 'auto';
// Horizontal positioning: auto-detect left or right based on available space
const spaceOnRight = viewportBounds.right - cursorX;
const spaceOnLeft = cursorX - viewportBounds.left;
if (spaceOnRight >= elementWidth + offset) {
// Enough space on the right
element.style.left = `${cursorX + offset}px`;
element.style.right = 'auto';
element.dataset.side = 'right';
} else if (spaceOnLeft >= elementWidth + offset) {
// Not enough space on right, use left
element.style.left = `${cursorX - elementWidth - offset}px`;
element.style.right = 'auto';
element.dataset.side = 'left';
} else if (spaceOnRight > spaceOnLeft) {
// Not enough space on either side, pick the side with more space
const leftPos = cursorX + offset;
const clampedLeft = Math.max(
viewportBounds.left,
Math.min(leftPos, viewportBounds.right - elementWidth),
);
element.style.left = `${clampedLeft}px`;
element.style.right = 'auto';
element.dataset.side = 'right';
} else {
const leftPos = cursorX - elementWidth - offset;
const clampedLeft = Math.max(
viewportBounds.left,
Math.min(leftPos, viewportBounds.right - elementWidth),
);
element.style.left = `${clampedLeft}px`;
element.style.right = 'auto';
element.dataset.side = 'left';
}
element.dataset.align = 'cursor';
return; // Exit early, don't run normal positioning logic
}
// Normalize reference to rect object
(reference instanceof Element || reference instanceof Range) &&
(reference = extractRect(reference.getBoundingClientRect()));
// Create anchor rect with swapped opposite edges for positioning
const anchorRect = {
top: reference.bottom,
bottom: reference.top,
left: reference.right,
right: reference.left,
...reference,
};
// Viewport bounds (can be overridden via options.bound)
const bounds = {
top: 0,
left: 0,
bottom: window.innerHeight,
right: window.innerWidth,
};
i.bound &&
((i.bound instanceof Element || i.bound instanceof Range) &&
(i.bound = t(i.bound.getBoundingClientRect())),
Object.assign(s, i.bound));
const l = getComputedStyle(o);
const m = {};
const b = {};
for (const g in e)
(m[g] = e[g][f === 'top' || f === 'bottom' ? 0 : 1]),
(b[g] = e[g][f === 'top' || f === 'bottom' ? 1 : 0]);
(o.style.position = 'absolute'),
(o.style.maxWidth = ''),
(o.style.maxHeight = '');
const d = parseInt(l[b.marginBefore]);
const c = parseInt(l[b.marginAfter]);
const u = d + c;
const p = s[b.after] - s[b.before] - u;
const h = parseInt(l[b.maxSize]);
(!h || p < h) && (o.style[b.maxSize] = `${p}px`);
const x = parseInt(l[m.marginBefore]) + parseInt(l[m.marginAfter]);
const y = n[m.before] - s[m.before] - x;
const z = s[m.after] - n[m.after] - x;
((f === m.before && o[m.offsetSize] > y) ||
(f === m.after && o[m.offsetSize] > z)) &&
(f = y > z ? m.before : m.after);
const S = f === m.before ? y : z;
const v = parseInt(l[m.maxSize]);
(!v || S < v) && (o.style[m.maxSize] = `${S}px`);
const w = window[m.scrollOffset];
const O = function (e) {
return Math.max(s[m.before], Math.min(e, s[m.after] - o[m.offsetSize] - x));
options.bound &&
((options.bound instanceof Element || options.bound instanceof Range) &&
(options.bound = extractRect(options.bound.getBoundingClientRect())),
Object.assign(bounds, options.bound));
const styles = getComputedStyle(element);
const isVertical = side === 'top' || side === 'bottom';
// Build axis property maps based on orientation
const mainAxis = {}; // Properties for the main positioning axis
const crossAxis = {}; // Properties for the perpendicular axis
for (const prop in AXIS_PROPS) {
mainAxis[prop] = AXIS_PROPS[prop][isVertical ? 0 : 1];
crossAxis[prop] = AXIS_PROPS[prop][isVertical ? 1 : 0];
}
// Reset element positioning
element.style.position = 'absolute';
element.style.maxWidth = '';
element.style.maxHeight = '';
// Cross-axis: calculate and apply max size constraint
const crossMarginBefore = parseInt(styles[crossAxis.marginBefore]);
const crossMarginAfter = parseInt(styles[crossAxis.marginAfter]);
const crossMarginTotal = crossMarginBefore + crossMarginAfter;
const crossAvailableSpace =
bounds[crossAxis.after] - bounds[crossAxis.before] - crossMarginTotal;
const crossMaxSize = parseInt(styles[crossAxis.maxSize]);
(!crossMaxSize || crossAvailableSpace < crossMaxSize) &&
(element.style[crossAxis.maxSize] = `${crossAvailableSpace}px`);
// Main-axis: calculate space on both sides
const mainMarginTotal =
parseInt(styles[mainAxis.marginBefore]) +
parseInt(styles[mainAxis.marginAfter]);
const spaceBefore =
anchorRect[mainAxis.before] - bounds[mainAxis.before] - mainMarginTotal;
const spaceAfter =
bounds[mainAxis.after] - anchorRect[mainAxis.after] - mainMarginTotal;
// Auto-flip to the side with more space if needed
((side === mainAxis.before && element[mainAxis.offsetSize] > spaceBefore) ||
(side === mainAxis.after && element[mainAxis.offsetSize] > spaceAfter)) &&
(side = spaceBefore > spaceAfter ? mainAxis.before : mainAxis.after);
// Apply main-axis max size constraint
const mainAvailableSpace =
side === mainAxis.before ? spaceBefore : spaceAfter;
const mainMaxSize = parseInt(styles[mainAxis.maxSize]);
(!mainMaxSize || mainAvailableSpace < mainMaxSize) &&
(element.style[mainAxis.maxSize] = `${mainAvailableSpace}px`);
// Position on main axis
const mainScrollOffset = window[mainAxis.scrollOffset];
const clampMainPosition = function (pos) {
return Math.max(
bounds[mainAxis.before],
Math.min(
pos,
bounds[mainAxis.after] - element[mainAxis.offsetSize] - mainMarginTotal,
),
);
};
f === m.before
? ((o.style[m.before] = `${w + O(n[m.before] - o[m.offsetSize] - x)}px`),
(o.style[m.after] = 'auto'))
: ((o.style[m.before] = `${w + O(n[m.after])}px`),
(o.style[m.after] = 'auto'));
const B = window[b.scrollOffset];
const I = function (e) {
return Math.max(s[b.before], Math.min(e, s[b.after] - o[b.offsetSize] - u));
side === mainAxis.before
? ((element.style[mainAxis.before] = `${
mainScrollOffset +
clampMainPosition(
anchorRect[mainAxis.before] -
element[mainAxis.offsetSize] -
mainMarginTotal,
)
}px`),
(element.style[mainAxis.after] = 'auto'))
: ((element.style[mainAxis.before] = `${
mainScrollOffset + clampMainPosition(anchorRect[mainAxis.after])
}px`),
(element.style[mainAxis.after] = 'auto'));
// Position on cross axis based on alignment
const crossScrollOffset = window[crossAxis.scrollOffset];
const clampCrossPosition = function (pos) {
return Math.max(
bounds[crossAxis.before],
Math.min(
pos,
bounds[crossAxis.after] - element[crossAxis.offsetSize] - crossMarginTotal,
),
);
};
switch (a) {
switch (align) {
case 'start':
(o.style[b.before] = `${B + I(n[b.before] - d)}px`),
(o.style[b.after] = 'auto');
(element.style[crossAxis.before] = `${
crossScrollOffset +
clampCrossPosition(anchorRect[crossAxis.before] - crossMarginBefore)
}px`),
(element.style[crossAxis.after] = 'auto');
break;
case 'end':
(o.style[b.before] = 'auto'),
(o.style[b.after] = `${
B + I(document.documentElement[b.clientSize] - n[b.after] - c)
(element.style[crossAxis.before] = 'auto'),
(element.style[crossAxis.after] = `${
crossScrollOffset +
clampCrossPosition(
document.documentElement[crossAxis.clientSize] -
anchorRect[crossAxis.after] -
crossMarginAfter,
)
}px`);
break;
default:
var H = n[b.after] - n[b.before];
(o.style[b.before] = `${
B + I(n[b.before] + H / 2 - o[b.offsetSize] / 2 - d)
// 'center'
var crossSize = anchorRect[crossAxis.after] - anchorRect[crossAxis.before];
(element.style[crossAxis.before] = `${
crossScrollOffset +
clampCrossPosition(
anchorRect[crossAxis.before] +
crossSize / 2 -
element[crossAxis.offsetSize] / 2 -
crossMarginBefore,
)
}px`),
(o.style[b.after] = 'auto');
(element.style[crossAxis.after] = 'auto');
}
(o.dataset.side = f), (o.dataset.align = a);
// Store final placement as data attributes
(element.dataset.side = side), (element.dataset.align = align);
};
})();

View File

@@ -3,7 +3,71 @@ import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
function isSeriesValueValid(seriesValue: number | undefined | null): boolean {
return (
seriesValue !== undefined &&
seriesValue !== null &&
!Number.isNaN(seriesValue)
);
}
// Helper function to get the focused/highlighted series at a specific position
function resolveSeriesColor(series: uPlot.Series, index: number): string {
let color = '#000000';
if (typeof series.stroke === 'string') {
color = series.stroke;
} else if (typeof series.fill === 'string') {
color = series.fill;
} else {
const seriesLabel = series.label || `Series ${index}`;
const isDarkMode = !document.body.classList.contains('lightMode');
color = generateColor(
seriesLabel,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
}
return color;
}
function getPreferredSeriesIndex(
u: uPlot,
timestampIndex: number,
e: MouseEvent,
): number {
const bbox = u.over.getBoundingClientRect();
const top = e.clientY - bbox.top;
// Prefer series explicitly marked as focused
for (let i = 1; i < u.series.length; i++) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const isSeriesFocused = u.series[i]?._focus === true;
const isSeriesShown = u.series[i].show !== false;
const seriesValue = u.data[i]?.[timestampIndex];
if (isSeriesFocused && isSeriesShown && isSeriesValueValid(seriesValue)) {
return i;
}
}
// Fallback: choose series with Y closest to mouse position
let focusedSeriesIndex = -1;
let closestPixelDiff = Infinity;
for (let i = 1; i < u.series.length; i++) {
const series = u.data[i];
const seriesValue = series?.[timestampIndex];
if (isSeriesValueValid(seriesValue) && u.series[i].show !== false) {
const yPx = u.valToPos(seriesValue as number, 'y');
const diff = Math.abs(yPx - top);
if (diff < closestPixelDiff) {
closestPixelDiff = diff;
focusedSeriesIndex = i;
}
}
}
return focusedSeriesIndex;
}
export const getFocusedSeriesAtPosition = (
e: MouseEvent,
u: uPlot,
@@ -17,74 +81,28 @@ export const getFocusedSeriesAtPosition = (
} | null => {
const bbox = u.over.getBoundingClientRect();
const left = e.clientX - bbox.left;
const top = e.clientY - bbox.top;
const timestampIndex = u.posToIdx(left);
let focusedSeriesIndex = -1;
let closestPixelDiff = Infinity;
// Check all series (skip index 0 which is the x-axis)
for (let i = 1; i < u.data.length; i++) {
const series = u.data[i];
const seriesValue = series[timestampIndex];
if (
seriesValue !== undefined &&
seriesValue !== null &&
!Number.isNaN(seriesValue)
) {
const seriesYPx = u.valToPos(seriesValue, 'y');
const pixelDiff = Math.abs(seriesYPx - top);
if (pixelDiff < closestPixelDiff) {
closestPixelDiff = pixelDiff;
focusedSeriesIndex = i;
}
}
}
// If we found a focused series, return its data
if (focusedSeriesIndex > 0) {
const series = u.series[focusedSeriesIndex];
const seriesValue = u.data[focusedSeriesIndex][timestampIndex];
// Ensure we have a valid value
if (
seriesValue !== undefined &&
seriesValue !== null &&
!Number.isNaN(seriesValue)
) {
// Get color - try series stroke first, then generate based on label
let color = '#000000';
if (typeof series.stroke === 'string') {
color = series.stroke;
} else if (typeof series.fill === 'string') {
color = series.fill;
} else {
// Generate color based on series label (like the tooltip plugin does)
const seriesLabel = series.label || `Series ${focusedSeriesIndex}`;
// Detect theme mode by checking body class
const isDarkMode = !document.body.classList.contains('lightMode');
color = generateColor(
seriesLabel,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
}
const preferredIndex = getPreferredSeriesIndex(u, timestampIndex, e);
if (preferredIndex > 0) {
const series = u.series[preferredIndex];
const seriesValue = u.data[preferredIndex][timestampIndex];
if (isSeriesValueValid(seriesValue)) {
const color = resolveSeriesColor(series, preferredIndex);
return {
seriesIndex: focusedSeriesIndex,
seriesName: series.label || `Series ${focusedSeriesIndex}`,
seriesIndex: preferredIndex,
seriesName: series.label || `Series ${preferredIndex}`,
value: seriesValue as number,
color,
show: series.show !== false,
isFocused: true, // This indicates it's the highlighted/bold one
isFocused: true,
};
}
}
return null;
};
export interface OnClickPluginOpts {
onClick: (
xValue: number,
@@ -137,50 +155,31 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
const yValue = u.posToVal(event.offsetY, 'y');
// Get the focused/highlighted series (the one that would be bold in hover)
const focusedSeries = getFocusedSeriesAtPosition(event, u);
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
let metric = {};
const { series } = u;
const apiResult = opts.apiResponse?.data?.result || [];
const outputMetric = {
queryName: '',
inFocusOrNot: false,
};
// this is to get the metric value of the focused series
if (Array.isArray(series) && series.length > 0) {
series.forEach((item, index) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (item?.show && item?._focus) {
const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
metric = focusedMetric;
outputMetric.queryName = queryName;
outputMetric.inFocusOrNot = true;
}
});
}
if (!outputMetric.queryName) {
// Get the focused series data
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
// If we found a valid focused series, get its data
if (
focusedSeriesData &&
focusedSeriesData.seriesIndex <= apiResult.length
) {
const { metric: focusedMetric, queryName } =
apiResult[focusedSeriesData.seriesIndex - 1] || [];
metric = focusedMetric;
outputMetric.queryName = queryName;
outputMetric.inFocusOrNot = true;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (
focusedSeriesData &&
focusedSeriesData.seriesIndex <= apiResult.length
) {
const { metric: focusedMetric, queryName } =
apiResult[focusedSeriesData.seriesIndex - 1] || {};
metric = focusedMetric;
outputMetric.queryName = queryName;
outputMetric.inFocusOrNot = true;
}
// Get the actual data point timestamp from the focused series
let actualDataTimestamp = xValue; // fallback to click position timestamp
if (focusedSeries) {
if (focusedSeriesData) {
// Get the data index from the focused series
const dataIndex = u.posToIdx(event.offsetX);
// Get the actual timestamp from the x-axis data (u.data[0])
@@ -209,7 +208,7 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
absoluteMouseX,
absoluteMouseY,
axesData,
focusedSeries,
focusedSeriesData,
);
};
u.over.addEventListener('click', handleClick);

View File

@@ -415,7 +415,11 @@ ToolTipPluginProps): any => {
}
// Clear and set new content in one operation
overlay.replaceChildren(content);
placement(overlay, anchor, 'right', 'start', { bound });
placement(overlay, anchor, 'right', 'start', {
bound,
followCursor: { x: anchor.left, y: anchor.top, offset: 4 },
});
showOverlay();
} else {
hideOverlay();

View File

@@ -401,14 +401,14 @@ body {
font-size: 12px;
position: absolute;
margin: 0.5rem;
background: rgba(0, 0, 0);
background: var(--bg-ink-300);
-webkit-font-smoothing: antialiased;
color: #fff;
color: var(--bg-vanilla-100);
z-index: 10000;
// pointer-events: none;
overflow: auto;
max-height: 480px !important;
max-width: 240px !important;
max-width: 300px !important;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -571,6 +571,12 @@ body {
}
.lightMode {
#overlay {
color: var(--bg-ink-500);
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
}
.ant-dropdown-menu {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);

View File

@@ -1,5 +1,7 @@
export interface IField {
name: string;
displayName: string;
key: string;
type: string;
dataType: string;
}

View File

@@ -128,7 +128,11 @@ export interface VariableItem {
export interface TelemetryFieldKey {
name: string;
displayName?: string;
// display name can change dynamically depending on if there's a conflicting field with same name and in only meant for UI display
key?: string;
// key is a unique identifier generated for each field, used for comparisons and selections
// key = fieldContext.name:fieldDataType
description?: string;
unit?: string;
signal?: SignalType;

View File

@@ -54,8 +54,6 @@ func (r *aggExprRewriter) Rewrite(
expr string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
startNs uint64,
endNs uint64,
) (string, []any, error) {
wrapped := fmt.Sprintf("SELECT %s", expr)
@@ -85,8 +83,6 @@ func (r *aggExprRewriter) Rewrite(
r.conditionBuilder,
r.jsonBodyPrefix,
r.jsonKeyToKey,
startNs,
endNs,
)
// Rewrite the first select item (our expression)
if err := sel.SelectItems[0].Accept(visitor); err != nil {
@@ -105,14 +101,12 @@ func (r *aggExprRewriter) RewriteMulti(
exprs []string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
startNs uint64,
endNs uint64,
) ([]string, [][]any, error) {
out := make([]string, len(exprs))
var errs []error
var chArgsList [][]any
for i, e := range exprs {
w, chArgs, err := r.Rewrite(ctx, e, rateInterval, keys, startNs, endNs)
w, chArgs, err := r.Rewrite(ctx, e, rateInterval, keys)
if err != nil {
errs = append(errs, err)
out[i] = e
@@ -140,8 +134,6 @@ type exprVisitor struct {
Modified bool
chArgs []any
isRate bool
startNs uint64
endNs uint64
}
func newExprVisitor(
@@ -152,8 +144,6 @@ func newExprVisitor(
conditionBuilder qbtypes.ConditionBuilder,
jsonBodyPrefix string,
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
startNs uint64,
endNs uint64,
) *exprVisitor {
return &exprVisitor{
logger: logger,
@@ -163,8 +153,6 @@ func newExprVisitor(
conditionBuilder: conditionBuilder,
jsonBodyPrefix: jsonBodyPrefix,
jsonKeyToKey: jsonKeyToKey,
startNs: startNs,
endNs: endNs,
}
}
@@ -202,7 +190,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
if aggFunc.FuncCombinator {
// Map the predicate (last argument)
origPred := args[len(args)-1].String()
whereClause, err := PrepareWhereClause(
whereClause, err := PrepareWhereClause(
origPred,
FilterExprVisitorOpts{
Logger: v.logger,
@@ -212,7 +200,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
FullTextColumn: v.fullTextColumn,
JsonBodyPrefix: v.jsonBodyPrefix,
JsonKeyToKey: v.jsonKeyToKey,
}, v.startNs, v.endNs,
}, 0, 0,
)
if err != nil {
return err

View File

@@ -350,8 +350,6 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
ctx, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
start,
end,
)
if err != nil {
return nil, err
@@ -501,8 +499,6 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
ctx, aggExpr.Expression,
rateInterval,
keys,
start,
end,
)
if err != nil {
return nil, err
@@ -596,7 +592,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
JsonBodyPrefix: b.jsonBodyPrefix,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
}, start, end)
}, start, end)
if err != nil {
return nil, err

View File

@@ -512,8 +512,6 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
ctx, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
start,
end,
)
if err != nil {
return nil, err
@@ -659,8 +657,6 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
ctx, aggExpr.Expression,
rateInterval,
keys,
start,
end,
)
if err != nil {
return nil, err
@@ -750,7 +746,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
FieldKeys: keys,
SkipResourceFilter: true,
Variables: variables,
}, start, end)
}, start, end)
if err != nil {
return nil, err

View File

@@ -237,7 +237,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
ConditionBuilder: b.stmtBuilder.cb,
FieldKeys: keys,
SkipResourceFilter: true,
}, b.start, b.end,
}, b.start, b.end,
)
if err != nil {
b.stmtBuilder.logger.ErrorContext(ctx, "Failed to prepare where clause", "error", err, "filter", query.Filter.Expression)
@@ -575,8 +575,6 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
agg.Expression,
uint64(b.operator.StepInterval.Seconds()),
keys,
b.start,
b.end,
)
if err != nil {
return nil, errors.NewInvalidInputf(
@@ -689,8 +687,6 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
agg.Expression,
rateInterval,
keys,
b.start,
b.end,
)
if err != nil {
return nil, errors.NewInvalidInputf(
@@ -829,8 +825,6 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
agg.Expression,
uint64((b.end-b.start)/querybuilder.NsToSeconds),
keys,
b.start,
b.end,
)
if err != nil {
return nil, errors.NewInvalidInputf(

View File

@@ -37,8 +37,8 @@ type ConditionBuilder interface {
type AggExprRewriter interface {
// Rewrite rewrites the aggregation expression to be used in the query.
Rewrite(ctx context.Context, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, startNs uint64, endNs uint64) (string, []any, error)
RewriteMulti(ctx context.Context, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, startNs uint64, endNs uint64) ([]string, [][]any, error)
Rewrite(ctx context.Context, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error)
RewriteMulti(ctx context.Context, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, error)
}
type Statement struct {