mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-04 12:10:43 +01:00
Compare commits
3 Commits
main
...
cursor/poc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55d5ddb9ba | ||
|
|
fead012459 | ||
|
|
5f50dcd349 |
@@ -31,6 +31,13 @@ interface FieldsSelectorProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
/**
|
||||
* Caller-supplied field list. When provided, Other Fields offers exactly
|
||||
* these and key discovery is skipped — use it when the set of selectable
|
||||
* fields is known up front rather than fetched. Omit to discover keys from
|
||||
* the API for `signal`.
|
||||
*/
|
||||
availableFields?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
type FieldsSelectorContentProps = Omit<FieldsSelectorProps, 'isOpen'>;
|
||||
@@ -49,6 +56,7 @@ function FieldsSelectorContent({
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
availableFields,
|
||||
}: FieldsSelectorContentProps): JSX.Element {
|
||||
const resolvedHeight =
|
||||
height ?? window.innerHeight - DEFAULT_PANEL_HEIGHT_OFFSET;
|
||||
@@ -153,6 +161,7 @@ function FieldsSelectorContent({
|
||||
addedFields={draftFields}
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
availableFields={availableFields}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
|
||||
@@ -21,6 +21,11 @@ interface OtherFieldsProps {
|
||||
addedFields: TelemetryFieldKey[];
|
||||
onAdd: (field: TelemetryFieldKey) => void;
|
||||
isAtLimit: boolean;
|
||||
/**
|
||||
* Caller-supplied field list. When provided, key discovery is skipped and
|
||||
* these are filtered locally by the search term instead.
|
||||
*/
|
||||
availableFields?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
@@ -29,7 +34,10 @@ function OtherFields({
|
||||
addedFields,
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
availableFields,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
const useRegistry = Boolean(availableFields);
|
||||
|
||||
const { data, isFetching } = useGetQueryKeySuggestions(
|
||||
{
|
||||
signal,
|
||||
@@ -41,11 +49,34 @@ function OtherFields({
|
||||
signal,
|
||||
debouncedInputValue,
|
||||
],
|
||||
enabled: true,
|
||||
enabled: !useRegistry,
|
||||
},
|
||||
);
|
||||
|
||||
const otherFields: TelemetryFieldKey[] = useMemo(() => {
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
|
||||
if (useRegistry && availableFields) {
|
||||
const search = debouncedInputValue.trim().toLowerCase();
|
||||
return availableFields
|
||||
.filter((attr) => {
|
||||
const id = attr.key ?? buildCompositeKey(attr.name, attr.fieldContext);
|
||||
if (addedIds.has(id)) {
|
||||
return false;
|
||||
}
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
return attr.name.toLowerCase().includes(search);
|
||||
})
|
||||
.map((attr) => ({
|
||||
...attr,
|
||||
key: attr.key ?? buildCompositeKey(attr.name, attr.fieldContext),
|
||||
}));
|
||||
}
|
||||
|
||||
const suggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
@@ -57,15 +88,12 @@ function OtherFields({
|
||||
fieldDataType: attr.fieldDataType,
|
||||
}),
|
||||
);
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
);
|
||||
}, [data, addedFields]);
|
||||
}, [data, addedFields, availableFields, debouncedInputValue, useRegistry]);
|
||||
|
||||
if (isFetching) {
|
||||
if (!useRegistry && isFetching) {
|
||||
return (
|
||||
<div className={cx(styles.section, styles.sectionOther)}>
|
||||
<div className={styles.sectionHeader}>OTHER FIELDS</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
/** AI Observability Trace View column visibility. */
|
||||
AI_TRACE_VIEW_COLUMNS = 'AI_TRACE_VIEW_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
BASE_TRACE_VIEW_COLUMNS,
|
||||
renderTraceDurationCell,
|
||||
TraceViewColumn,
|
||||
} from 'container/TracesExplorer/TracesView/configs';
|
||||
import { TraceViewColumnSelection } from 'container/TracesExplorer/TracesView/useTraceViewColumns';
|
||||
|
||||
function isBlank(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
function renderCountCell(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
const count = Number(value);
|
||||
return (
|
||||
<Typography>
|
||||
{Number.isFinite(count) ? count.toLocaleString() : String(value)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCostCell(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
const cost = Number(value);
|
||||
return (
|
||||
<Typography>
|
||||
{Number.isFinite(cost) ? `$${cost.toFixed(4)}` : String(value)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace-level gen_ai aggregates. The query-range response does not carry these
|
||||
* yet, so they render as em dashes until the AI trace API lands — visible but
|
||||
* empty is intentional, it lets the column set be reviewed ahead of the data.
|
||||
*/
|
||||
const AI_ONLY_COLUMNS: TraceViewColumn[] = [
|
||||
{
|
||||
field: {
|
||||
name: 'input_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Input Tokens',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'output_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Output Tokens',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'total_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Total Tokens',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'llm_call_count',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'LLM Calls',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'tool_call_count',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Tool Calls',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'distinct_tool_count',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Distinct Tools',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'estimated_cost_usd',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
title: 'Est. Cost (USD)',
|
||||
render: renderCostCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'max_llm_latency_ns',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Max LLM Latency',
|
||||
render: renderTraceDurationCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'last_activity_time',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Last Activity',
|
||||
},
|
||||
{
|
||||
field: { name: 'start_time', fieldContext: 'trace', fieldDataType: 'int64' },
|
||||
title: 'Start Time',
|
||||
},
|
||||
{
|
||||
field: { name: 'end_time', fieldContext: 'trace', fieldDataType: 'int64' },
|
||||
title: 'End Time',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'trace_duration_nano',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Trace Duration',
|
||||
render: renderTraceDurationCell,
|
||||
},
|
||||
{
|
||||
field: { name: 'error_count', fieldContext: 'trace', fieldDataType: 'int64' },
|
||||
title: 'Errors',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'root_span_name',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
title: 'Root Span Name',
|
||||
},
|
||||
{
|
||||
field: { name: 'input', fieldContext: 'trace', fieldDataType: 'string' },
|
||||
title: 'Input',
|
||||
},
|
||||
{
|
||||
field: { name: 'output', fieldContext: 'trace', fieldDataType: 'string' },
|
||||
title: 'Output',
|
||||
},
|
||||
];
|
||||
|
||||
export const AI_TRACE_VIEW_COLUMNS: TraceViewColumn[] = [
|
||||
...BASE_TRACE_VIEW_COLUMNS,
|
||||
...AI_ONLY_COLUMNS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Hand this to `<TracesView columnSelection={…} />` to get the AI column set
|
||||
* plus the Options → Edit columns picker. Module-level so its identity is
|
||||
* stable across renders.
|
||||
*/
|
||||
export const AI_TRACE_VIEW_COLUMN_SELECTION: TraceViewColumnSelection = {
|
||||
columns: AI_TRACE_VIEW_COLUMNS,
|
||||
storageKey: LOCALSTORAGE.AI_TRACE_VIEW_COLUMNS,
|
||||
defaultVisible: BASE_TRACE_VIEW_COLUMNS.map((column) => column.field.name),
|
||||
};
|
||||
@@ -11,4 +11,12 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
// Rendered as a <button> for keyboard access — strip the native chrome so it
|
||||
// still reads as the inline text trigger it was.
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
@@ -15,6 +16,7 @@ function TraceExplorerControls({
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
availableFields,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
@@ -30,13 +32,15 @@ function TraceExplorerControls({
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
data-testid="trace-view-options-trigger"
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
</button>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
@@ -44,6 +48,7 @@ function TraceExplorerControls({
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
availableFields={availableFields}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -63,20 +68,20 @@ function TraceExplorerControls({
|
||||
);
|
||||
}
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
/** Forwarded to FieldsSelector — see `availableFields` there. */
|
||||
availableFields?: TelemetryFieldKey[];
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
showSizeChanger: true,
|
||||
availableFields: undefined,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
|
||||
@@ -4,47 +4,105 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { ListItem } from 'types/api/widgets/getQuery';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
export const columns: ColumnsType<ListItem['data']> = [
|
||||
/**
|
||||
* One Trace View column: the telemetry field it reads, its header, and how the
|
||||
* cell renders. Callers own their column sets — Trace View has no knowledge of
|
||||
* any particular product's columns.
|
||||
*/
|
||||
export interface TraceViewColumn {
|
||||
field: TelemetryFieldKey;
|
||||
title: string;
|
||||
/** Defaults to `renderTraceCellValue`. */
|
||||
render?: (value: unknown) => JSX.Element;
|
||||
}
|
||||
|
||||
function isBlank(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
/** Fallback cell: em dash when empty, otherwise stringified. */
|
||||
export function renderTraceCellValue(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
return <Typography>{String(value)}</Typography>;
|
||||
}
|
||||
|
||||
/** Nanosecond duration rendered as milliseconds. */
|
||||
export function renderTraceDurationCell(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
return <Typography>{getMs(String(value))}ms</Typography>;
|
||||
}
|
||||
|
||||
function renderTraceIdCell(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: String(value),
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{String(value)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The root-span columns Trace View renders when the caller configures no
|
||||
* column selection. Matches the pre-selection behaviour exactly.
|
||||
*/
|
||||
export const BASE_TRACE_VIEW_COLUMNS: TraceViewColumn[] = [
|
||||
{
|
||||
field: {
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
title: 'Root Service Name',
|
||||
dataIndex: 'service.name',
|
||||
key: 'serviceName',
|
||||
},
|
||||
{
|
||||
field: { name: 'name', fieldContext: 'span', fieldDataType: 'string' },
|
||||
title: 'Root Operation Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'duration_nano',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Root Duration (in ms)',
|
||||
dataIndex: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
render: (duration: number): JSX.Element => (
|
||||
<Typography>{getMs(String(duration))}ms</Typography>
|
||||
),
|
||||
render: renderTraceDurationCell,
|
||||
},
|
||||
{
|
||||
field: { name: 'span_count', fieldContext: 'trace', fieldDataType: 'int64' },
|
||||
title: 'No of Spans',
|
||||
dataIndex: 'span_count',
|
||||
key: 'span_count',
|
||||
},
|
||||
{
|
||||
field: { name: 'trace_id', fieldContext: 'span', fieldDataType: 'string' },
|
||||
title: 'TraceID',
|
||||
dataIndex: 'trace_id',
|
||||
key: 'traceID',
|
||||
render: (traceID: string): JSX.Element => (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceID,
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
),
|
||||
render: renderTraceIdCell,
|
||||
},
|
||||
];
|
||||
|
||||
/** Build antd columns, preserving the order given. */
|
||||
export function buildTraceViewColumns(
|
||||
columns: TraceViewColumn[],
|
||||
): ColumnsType<ListItem['data']> {
|
||||
return columns.map(({ field, title, render }) => ({
|
||||
title,
|
||||
dataIndex: field.name,
|
||||
key: field.name,
|
||||
render: (value: unknown): JSX.Element =>
|
||||
(render ?? renderTraceCellValue)(value),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -34,14 +34,26 @@ import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import {
|
||||
BASE_TRACE_VIEW_COLUMNS,
|
||||
buildTraceViewColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
} from './configs';
|
||||
import { ActionsContainer, Container } from './styles';
|
||||
import useTraceViewColumns, {
|
||||
TraceViewColumnSelection,
|
||||
} from './useTraceViewColumns';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
/**
|
||||
* Opt in to user-editable columns. Omit for the base root-span columns with
|
||||
* no Options → Edit columns picker.
|
||||
*/
|
||||
columnSelection?: TraceViewColumnSelection;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
@@ -49,6 +61,7 @@ function TracesView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
columnSelection,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -62,6 +75,32 @@ function TracesView({
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
// Column visibility is owned here rather than by useOptionsMenu, whose
|
||||
// TRACES_LIST_OPTIONS storage is already claimed by List View.
|
||||
const { visibleColumns, selectedFields, availableFields, onFieldsChange } =
|
||||
useTraceViewColumns(columnSelection);
|
||||
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
() =>
|
||||
columnSelection
|
||||
? {
|
||||
fieldsSelector: {
|
||||
value: selectedFields,
|
||||
onFieldsChange,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[columnSelection, selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
buildTraceViewColumns(
|
||||
columnSelection ? visibleColumns : BASE_TRACE_VIEW_COLUMNS,
|
||||
),
|
||||
[columnSelection, visibleColumns],
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
@@ -76,6 +115,8 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
// Column visibility is deliberately absent: it is client-side only, and
|
||||
// this array doubles as the parent's cancelQueries handle.
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
@@ -100,6 +141,8 @@ function TracesView({
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
// No selectColumns: the backend returns all columns and visibility is
|
||||
// resolved client-side, so toggling a column is refetch-free.
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
@@ -162,6 +205,8 @@ function TracesView({
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={fieldsSelectorConfig}
|
||||
availableFields={columnSelection ? availableFields : undefined}
|
||||
/>
|
||||
</div>
|
||||
</ActionsContainer>
|
||||
@@ -190,7 +235,7 @@ function TracesView({
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ResizeTable
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
columns={tableColumns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
scroll={{ x: true }}
|
||||
@@ -203,6 +248,7 @@ function TracesView({
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
columnSelection: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { TraceViewColumn } from './configs';
|
||||
|
||||
/**
|
||||
* Opt-in column selection for Trace View. Passing one enables the Options →
|
||||
* Edit columns picker; omitting it leaves Trace View on its base columns with
|
||||
* no picker at all.
|
||||
*
|
||||
* Pass a module-level constant, not an inline literal — a fresh object each
|
||||
* render invalidates the memoised column set on every pass.
|
||||
*/
|
||||
export interface TraceViewColumnSelection {
|
||||
/** Every column offered, in default display order. */
|
||||
columns: TraceViewColumn[];
|
||||
/** Where this caller persists visibility. Must be unique per view. */
|
||||
storageKey: LOCALSTORAGE;
|
||||
/** Field names visible before the user customises anything. */
|
||||
defaultVisible: string[];
|
||||
}
|
||||
|
||||
interface UseTraceViewColumnsReturn {
|
||||
/** Columns to render, in the user's persisted order. */
|
||||
visibleColumns: TraceViewColumn[];
|
||||
/** Visible fields, for the picker's "added" list. */
|
||||
selectedFields: TelemetryFieldKey[];
|
||||
/** Every offered field, for the picker's "other" list. */
|
||||
availableFields: TelemetryFieldKey[];
|
||||
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
|
||||
}
|
||||
|
||||
function readStoredKeys(
|
||||
selection: TraceViewColumnSelection | undefined,
|
||||
): string[] {
|
||||
if (!selection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { storageKey, columns, defaultVisible } = selection;
|
||||
const raw = getLocalStorageKey(storageKey);
|
||||
if (!raw) {
|
||||
return defaultVisible;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as string[];
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
return defaultVisible;
|
||||
}
|
||||
// Drop anything the caller no longer offers, so a stale localStorage
|
||||
// entry from an earlier column set self-heals instead of rendering blank.
|
||||
const allowed = new Set(columns.map((column) => column.field.name));
|
||||
const filtered = parsed.filter((key) => allowed.has(key));
|
||||
return filtered.length > 0 ? filtered : defaultVisible;
|
||||
} catch {
|
||||
return defaultVisible;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns Trace View column visibility client-side: toggling reorders or hides
|
||||
* columns without touching the query, so it never triggers a refetch.
|
||||
*/
|
||||
function useTraceViewColumns(
|
||||
selection?: TraceViewColumnSelection,
|
||||
): UseTraceViewColumnsReturn {
|
||||
const [visibleKeys, setVisibleKeys] = useState<string[]>(() =>
|
||||
readStoredKeys(selection),
|
||||
);
|
||||
|
||||
const offeredColumns = selection?.columns;
|
||||
|
||||
const columnsByName = useMemo(
|
||||
() =>
|
||||
new Map((offeredColumns ?? []).map((column) => [column.field.name, column])),
|
||||
[offeredColumns],
|
||||
);
|
||||
|
||||
// Ordered by visibleKeys, so reordering in the picker moves the column.
|
||||
const visibleColumns = useMemo(
|
||||
() =>
|
||||
visibleKeys
|
||||
.map((key) => columnsByName.get(key))
|
||||
.filter((column): column is TraceViewColumn => Boolean(column)),
|
||||
[columnsByName, visibleKeys],
|
||||
);
|
||||
|
||||
const selectedFields = useMemo(
|
||||
() => visibleColumns.map((column) => column.field),
|
||||
[visibleColumns],
|
||||
);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() => (offeredColumns ?? []).map((column) => column.field),
|
||||
[offeredColumns],
|
||||
);
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(fields: TelemetryFieldKey[]): void => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const nextKeys = fields.map((field) => field.name);
|
||||
const keys = nextKeys.length > 0 ? nextKeys : selection.defaultVisible;
|
||||
setVisibleKeys(keys);
|
||||
setLocalStorageKey(selection.storageKey, JSON.stringify(keys));
|
||||
},
|
||||
[selection],
|
||||
);
|
||||
|
||||
return {
|
||||
visibleColumns,
|
||||
selectedFields,
|
||||
availableFields,
|
||||
onFieldsChange,
|
||||
};
|
||||
}
|
||||
|
||||
export default useTraceViewColumns;
|
||||
@@ -114,23 +114,6 @@ describe('getCaretContext — stage detection', () => {
|
||||
expect(ctx.partial).toBe('');
|
||||
});
|
||||
|
||||
it('never replaces past the caret when it sits before the operator', () => {
|
||||
const ctx = getCaretContext("env = 'prod'", 4);
|
||||
expect(ctx.stage).toBe('operator');
|
||||
expect(ctx.partial).toBe('');
|
||||
expect(ctx.replaceStart).toBe(4);
|
||||
expect(ctx.replaceEnd).toBe(4);
|
||||
});
|
||||
|
||||
it('never replaces past the caret when it sits before the value', () => {
|
||||
const ctx = getCaretContext("env = 'prod'", 6);
|
||||
expect(ctx.stage).toBe('value');
|
||||
expect(ctx.operator).toBe('=');
|
||||
expect(ctx.partial).toBe('');
|
||||
expect(ctx.replaceStart).toBe(6);
|
||||
expect(ctx.replaceEnd).toBe(6);
|
||||
});
|
||||
|
||||
it('detects the stage of the term under a mid-string caret', () => {
|
||||
const q = "env = AND team = 'core'";
|
||||
// caret right after the first `env ` (index 4) is the operator stage
|
||||
@@ -159,13 +142,6 @@ describe('spliceAtCaret', () => {
|
||||
expect(next).toBe("env = 'prod'");
|
||||
});
|
||||
|
||||
it('inserts (without duplicating text) at a caret parked before a token', () => {
|
||||
const q = "env = 'prod'";
|
||||
const ctx = getCaretContext(q, 4);
|
||||
const { next } = spliceAtCaret(q, ctx, '!= ');
|
||||
expect(next).toBe("env != = 'prod'");
|
||||
});
|
||||
|
||||
it('preserves text after the caret', () => {
|
||||
const q = "env AND team = 'core'";
|
||||
const ctx = getCaretContext(q, 4); // operator gap after `env`
|
||||
|
||||
@@ -328,7 +328,7 @@ export const getCaretContext = (query: string, caret: number): CaretContext => {
|
||||
fieldKey: scan.key ? scan.key.text : '',
|
||||
operator: slot.operator,
|
||||
partial: slot.partial,
|
||||
replaceStart: Math.min(term.start + slot.replaceStartRel, pos),
|
||||
replaceStart: term.start + slot.replaceStartRel,
|
||||
replaceEnd: pos,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import { AI_TRACE_VIEW_COLUMN_SELECTION } from 'container/AIObservability/TraceView/aiTraceViewColumns';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
@@ -330,6 +331,10 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
// DEMO ONLY — surfaces the AI Observability column set here because
|
||||
// the AI Explorer does not exist yet. Drop this prop (and the import)
|
||||
// to return Trace View to its five base columns.
|
||||
columnSelection={AI_TRACE_VIEW_COLUMN_SELECTION}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceDefinitionsAreValid(t *testing.T) {
|
||||
store := NewServiceDefinitionStore()
|
||||
|
||||
for _, provider := range []citypes.CloudProviderType{
|
||||
citypes.CloudProviderTypeAWS,
|
||||
citypes.CloudProviderTypeAzure,
|
||||
citypes.CloudProviderTypeGCP,
|
||||
} {
|
||||
t.Run(provider.StringValue(), func(t *testing.T) {
|
||||
defs, err := store.List(context.Background(), provider)
|
||||
require.NoError(t, err, "all embedded definitions must load and validate")
|
||||
require.NotEmpty(t, defs, "provider should ship at least one service definition")
|
||||
|
||||
for _, def := range defs {
|
||||
assert.NotEmpty(t, def.ID, "service definition must have an id")
|
||||
assert.NotEmpty(t, def.Title, "service %q must have a title", def.ID)
|
||||
|
||||
// Get() must agree with List() for every service it advertises.
|
||||
serviceID, err := citypes.NewServiceID(provider, def.ID)
|
||||
if !assert.NoError(t, err, "service id %q must be registered in serviceid.go", def.ID) {
|
||||
continue
|
||||
}
|
||||
got, err := store.Get(context.Background(), provider, serviceID)
|
||||
require.NoError(t, err, "service %q listed but not gettable", def.ID)
|
||||
assert.Equal(t, def.ID, got.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -621,7 +621,7 @@
|
||||
{
|
||||
"metricName": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"timeAggregation": "max",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
@@ -882,7 +882,7 @@
|
||||
{
|
||||
"metricName": "cloudsql.googleapis.com/database/postgresql/transaction_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"timeAggregation": "max",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
|
||||
@@ -54,13 +54,13 @@
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/transaction_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
|
||||
@@ -925,7 +925,7 @@
|
||||
"metricName": "compute.googleapis.com/instance/disk/average_io_latency",
|
||||
"temporality": "",
|
||||
"timeAggregation": "avg",
|
||||
"spaceAggregation": "max",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1207,8 +1207,8 @@
|
||||
{
|
||||
"metricName": "kubernetes.io/container/restart_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "increase",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "max",
|
||||
"spaceAggregation": "max",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -601,8 +601,8 @@
|
||||
{
|
||||
"metricName": "redis.googleapis.com/stats/cache_hit_ratio",
|
||||
"temporality": "",
|
||||
"timeAggregation": "min",
|
||||
"spaceAggregation": "min",
|
||||
"timeAggregation": "max",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
@@ -788,7 +788,7 @@
|
||||
"metricName": "redis.googleapis.com/commands/usec_per_call",
|
||||
"temporality": "",
|
||||
"timeAggregation": "max",
|
||||
"spaceAggregation": "max",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
@@ -945,7 +945,7 @@
|
||||
{
|
||||
"metricName": "redis.googleapis.com/commands/calls",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"timeAggregation": "max",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
@@ -1044,8 +1044,8 @@
|
||||
{
|
||||
"metricName": "redis.googleapis.com/stats/reject_connections_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "avg",
|
||||
"spaceAggregation": "avg",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
@@ -1213,4 +1213,4 @@
|
||||
"refreshInterval": "",
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user