Compare commits

..

16 Commits

Author SHA1 Message Date
Piyush Singariya
541f19c34a fix: array type filtering from dynamic arrays 2026-03-30 12:59:31 +05:30
Piyush Singariya
010db03d6e fix: indexed tests passing 2026-03-30 12:24:26 +05:30
Piyush Singariya
5408acbd8c fix: primitive conditions working 2026-03-30 12:01:35 +05:30
Piyush Singariya
0de6c85f81 feat: align negative operators to include other logs 2026-03-28 10:30:11 +05:30
Piyush Singariya
69ec24fa05 test: fix unit tests 2026-03-27 15:12:49 +05:30
Piyush Singariya
539d732b65 fix: contextual path index usage 2026-03-27 14:44:51 +05:30
Piyush Singariya
843d5fb199 Merge branch 'main' into feat/json-index 2026-03-27 14:17:52 +05:30
Piyush Singariya
fabdfb8cc1 feat: enable JSON Path index 2026-03-27 14:07:37 +05:30
Nityananda Gohain
f3e6892d5b fix: remove flakyness for trace waterfall tests (#10734) 2026-03-27 06:50:42 +00:00
Nityananda Gohain
23a4960e74 chore: don't run functions if the series is empty (#10725)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: funcRunningDiff don't panic when series is empty

* chore: don't run functions if the series is empty
2026-03-27 04:41:00 +00:00
Vinicius Lourenço
5d0c55d682 fix(alerts-history): formatTime expecting number but receiving string (#10719)
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-03-27 00:52:40 +00:00
Nityananda Gohain
15704e0433 chore: cleanup traversal in trace waterfall (#10706)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-03-26 11:46:45 +00:00
primus-bot[bot]
5db0501c02 chore(release): bump to v0.117.1 (#10721)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
Co-authored-by: Priyanshu Shrivastava <priyanshu@signoz.io>
2026-03-26 10:01:46 +00:00
Tushar Vats
73da474563 fix: select column option in export button (#10709)
* fix: all option in trace export

* fix: remove the hack, user can select fields

* fix: hide column selection for trace export
2026-03-26 09:11:23 +00:00
Srikanth Chekuri
028c134ea9 chore: reject empty aggregations in payload regardless of disabled st… (#10720)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: reject empty aggregations in payload regardless of disabled status

* chore: update tests

* chore: count -> count()
2026-03-26 05:14:21 +00:00
Ashwin Bhatkal
31b61a89fd fix: collapsed panels not expanding (#10716)
* fix: collapsed panels not expanding

* fix: breaking logs when ordering by timestamp and not filtering on id
2026-03-26 04:06:18 +00:00
41 changed files with 2109 additions and 2558 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.117.0
image: signoz/signoz:v0.117.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.117.0
image: signoz/signoz:v0.117.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.117.0}
image: signoz/signoz:${VERSION:-v0.117.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.117.0}
image: signoz/signoz:${VERSION:-v0.117.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -116,7 +116,12 @@ describe.each([
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
if (dataSource === DataSource.TRACES) {
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
} else {
expect(screen.getByText('Columns')).toBeInTheDocument();
}
});
it('allows changing export format', () => {
@@ -146,6 +151,17 @@ describe.each([
});
it('allows changing columns scope', () => {
if (dataSource === DataSource.TRACES) {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
expect(screen.queryByRole('radio', { name: 'All' })).not.toBeInTheDocument();
expect(
screen.queryByRole('radio', { name: 'Selected' }),
).not.toBeInTheDocument();
return;
}
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
@@ -210,7 +226,12 @@ describe.each([
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
// For traces, column scope is always Selected and the radio is hidden
if (dataSource !== DataSource.TRACES) {
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
}
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
@@ -227,6 +248,11 @@ describe.each([
});
it('sends no selectFields when column scope is All', async () => {
// For traces, column scope is always Selected — this test only applies to other sources
if (dataSource === DataSource.TRACES) {
return;
}
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Radio, Tooltip, Typography } from 'antd';
import { TelemetryFieldKey } from 'api/v5/v5';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
@@ -14,10 +15,12 @@ import './DownloadOptionsMenu.styles.scss';
interface DownloadOptionsMenuProps {
dataSource: DataSource;
selectedColumns?: TelemetryFieldKey[];
}
export default function DownloadOptionsMenu({
dataSource,
selectedColumns,
}: DownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
@@ -35,9 +38,19 @@ export default function DownloadOptionsMenu({
await handleExportRawData({
format: exportFormat,
rowLimit,
clearSelectColumns: columnsScope === DownloadColumnsScopes.ALL,
clearSelectColumns:
dataSource !== DataSource.TRACES &&
columnsScope === DownloadColumnsScopes.ALL,
selectedColumns,
});
}, [exportFormat, rowLimit, columnsScope, handleExportRawData]);
}, [
exportFormat,
rowLimit,
columnsScope,
selectedColumns,
handleExportRawData,
dataSource,
]);
const popoverContent = useMemo(
() => (
@@ -72,18 +85,22 @@ export default function DownloadOptionsMenu({
</Radio.Group>
</div>
<div className="horizontal-line" />
{dataSource !== DataSource.TRACES && (
<>
<div className="horizontal-line" />
<div className="columns-scope">
<Typography.Text className="title">Columns</Typography.Text>
<Radio.Group
value={columnsScope}
onChange={(e): void => setColumnsScope(e.target.value)}
>
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
</Radio.Group>
</div>
<div className="columns-scope">
<Typography.Text className="title">Columns</Typography.Text>
<Radio.Group
value={columnsScope}
onChange={(e): void => setColumnsScope(e.target.value)}
>
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
</Radio.Group>
</div>
</>
)}
<Button
type="primary"
@@ -97,7 +114,14 @@ export default function DownloadOptionsMenu({
</Button>
</div>
),
[exportFormat, rowLimit, columnsScope, isDownloading, handleExport],
[
exportFormat,
rowLimit,
columnsScope,
isDownloading,
handleExport,
dataSource,
],
);
return (

View File

@@ -39,7 +39,6 @@ import {
ScrollText,
X,
} from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
@@ -53,18 +52,15 @@ import {
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuidv4 } from 'uuid';
import { convertFiltersToExpression } from '../QueryBuilderV2/utils';
import { VIEW_TYPES, VIEWS } from './constants';
import Containers from './Containers/Containers';
import { HostDetailProps } from './HostMetricDetail.interfaces';
import { HOST_METRICS_LOGS_EXPR_QUERY_KEY } from './HostMetricsLogs/constants';
import HostMetricsLogs from './HostMetricsLogs/HostMetricsLogs';
import HostMetricLogsDetailedView from './HostMetricsLogs/HostMetricLogsDetailedView';
import HostMetricTraces from './HostMetricTraces/HostMetricTraces';
import Metrics from './Metrics/Metrics';
import Processes from './Processes/Processes';
import './HostMetricsDetail.styles.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
function HostMetricsDetails({
host,
@@ -133,6 +129,10 @@ function HostMetricsDetails({
};
}, [host?.hostName, searchParams]);
const [logFilters, setLogFilters] = useState<IBuilderQuery['filters']>(
initialFilters,
);
const [tracesFilters, setTracesFilters] = useState<IBuilderQuery['filters']>(
initialFilters,
);
@@ -147,6 +147,7 @@ function HostMetricsDetails({
}, [host]);
useEffect(() => {
setLogFilters(initialFilters);
setTracesFilters(initialFilters);
}, [initialFilters]);
@@ -171,6 +172,7 @@ function HostMetricsDetails({
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value,
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null),
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null),
});
}
@@ -208,30 +210,48 @@ function HostMetricsDetails({
[],
);
const initialLogsExpression = useMemo(
() =>
convertFiltersToExpression({
items: [
{
id: uuidv4(),
key: {
key: 'host.name',
dataType: DataTypes.String,
type: 'resource',
id: 'host.name--string--resource--false',
},
op: '=',
value: host?.hostName || '',
},
],
op: 'AND',
}).expression,
[host?.hostName],
);
const handleChangeLogFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogFilters((prevFilters) => {
const hostNameFilter = prevFilters?.items?.find(
(item) => item.key?.key === 'host.name',
);
const paginationFilter = value?.items?.find(
(item) => item.key?.key === 'id',
);
const newFilters = value?.items?.filter(
(item) => item.key?.key !== 'id' && item.key?.key !== 'host.name',
);
const [hostMetricLogsExpr] = useQueryState(
HOST_METRICS_LOGS_EXPR_QUERY_KEY,
parseAsString,
if (newFilters && newFilters?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.HostEntity,
view: InfraMonitoringEvents.LogsView,
page: InfraMonitoringEvents.DetailedPage,
});
}
const updatedFilters = {
op: 'AND',
items: [
hostNameFilter,
...(newFilters || []),
...(paginationFilter ? [paginationFilter] : []),
].filter((item): item is TagFilterItem => item !== undefined),
};
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(
updatedFilters,
),
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
});
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeTracesFilters = useCallback(
@@ -288,6 +308,11 @@ function HostMetricsDetails({
});
if (selectedView === VIEW_TYPES.LOGS) {
const filtersWithoutPagination = {
...logFilters,
items: logFilters?.items?.filter((item) => item.key?.key !== 'id') || [],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
@@ -297,11 +322,7 @@ function HostMetricsDetails({
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filter: { expression: hostMetricLogsExpr },
expression: hostMetricLogsExpr,
having: {
expression: '',
},
filters: filtersWithoutPagination,
},
],
},
@@ -543,11 +564,12 @@ function HostMetricsDetails({
/>
)}
{selectedView === VIEW_TYPES.LOGS && (
<HostMetricsLogs
<HostMetricLogsDetailedView
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
initialExpression={initialLogsExpression}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logFilters}
selectedInterval={selectedInterval}
/>
)}

View File

@@ -1,64 +0,0 @@
.header {
display: flex;
justify-content: flex-end;
padding: var(--spacing-4) 0px;
border-radius: 3px;
}
.logs {
border: 1px solid var(--border);
margin-top: var(--spacing-4);
}
.listContainer {
flex: 1;
height: calc(100vh - 278px) !important;
display: flex;
height: 100%;
:global(.raw-log-content) {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.listCard {
width: 100%;
margin-top: 12px;
:global(.ant-card-body) {
padding: 0;
height: 100%;
width: 100%;
}
}
.logsLoadingSkeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
:global(.ant-skeleton-input-sm) {
height: 18px;
}
}
.noLogsFound {
height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
box-sizing: border-box;
p {
display: flex;
align-items: center;
gap: 16px;
}
}

View File

@@ -0,0 +1,133 @@
.host-metrics-logs-container {
margin-top: 1rem;
.filter-section {
flex: 1;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--bg-slate-400) !important;
background-color: var(--bg-ink-300) !important;
input {
font-size: 12px;
}
.ant-tag .ant-typography {
font-size: 12px;
}
}
}
.host-metrics-logs-header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
}
.host-metrics-logs {
margin-top: 1rem;
.virtuoso-list {
overflow-y: hidden !important;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
.ant-row {
width: fit-content;
}
}
.skeleton-container {
height: 100%;
padding: 16px;
}
}
}
.host-metrics-logs-list-container {
flex: 1;
height: calc(100vh - 272px) !important;
display: flex;
height: 100%;
.raw-log-content {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.host-metrics-logs-list-card {
width: 100%;
margin-top: 12px;
.ant-card-body {
padding: 0;
height: 100%;
width: 100%;
}
}
.logs-loading-skeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
.ant-skeleton-input-sm {
height: 18px;
}
}
.no-logs-found {
height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
box-sizing: border-box;
.ant-typography {
display: flex;
align-items: center;
gap: 16px;
}
}
.lightMode {
.filter-section {
border-top: 1px solid var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-vanilla-300);
.ant-select-selector {
border-color: var(--bg-vanilla-300) !important;
background-color: var(--bg-vanilla-100) !important;
color: var(--bg-ink-200);
}
}
}

View File

@@ -0,0 +1,100 @@
import { useMemo } from 'react';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { VIEWS } from '../constants';
import HostMetricsLogs from './HostMetricsLogs';
import './HostMetricLogs.styles.scss';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
handleChangeLogFilters: (value: IBuilderQuery['filters'], view: VIEWS) => void;
logFilters: IBuilderQuery['filters'];
selectedInterval: Time;
}
function HostMetricLogsDetailedView({
timeRange,
isModalTimeSelection,
handleTimeChange,
handleChangeLogFilters,
logFilters,
selectedInterval,
}: Props): JSX.Element {
const { currentQuery } = useQueryBuilder();
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.LOGS,
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters: {
items:
logFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
[],
op: 'AND',
},
},
],
},
}),
[currentQuery, logFilters?.items],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
return (
<div className="host-metrics-logs-container">
<div className="host-metrics-logs-header">
<div className="filter-section">
{query && (
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={(value): void => handleChangeLogFilters(value, VIEWS.LOGS)}
disableNavigationShortcuts
/>
)}
</div>
<div className="datetime-section">
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
</div>
<HostMetricsLogs timeRange={timeRange} filters={logFilters} />
</div>
);
}
export default HostMetricLogsDetailedView;

View File

@@ -1,161 +1,88 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { InfraMonitoringEvents } from 'constants/events';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import useDebounce from 'hooks/useDebounce';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { parseAsString, useQueryState } from 'nuqs';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
getHostLogsQueryPayload,
HOST_METRICS_LOGS_EXPR_QUERY_KEY,
} from './constants';
import { useInfiniteHostMetricLogs } from './hooks';
import { getHostLogsQueryPayload } from './constants';
import NoLogsContainer from './NoLogsContainer';
import styles from './HostMetricLogs.module.scss';
import './HostMetricLogs.styles.scss';
interface Props {
initialExpression: string;
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
filters: IBuilderQuery['filters'];
}
const EXPRESSION_DEBOUNCE_TIME_MS = 300;
function HostMetricsLogs({
initialExpression,
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
}: Props): JSX.Element {
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [filterExpression, setFilterExpression] = useQueryState(
HOST_METRICS_LOGS_EXPR_QUERY_KEY,
parseAsString,
);
const [inputExpression, setInputExpression] = useState(
filterExpression || initialExpression,
);
useEffect(() => {
// If expression is present in the URL, prefer it and don't override it.
// Otherwise, initialize URL state from the host's default expression.
if (filterExpression) {
setInputExpression(filterExpression);
return;
}
setInputExpression(initialExpression);
setFilterExpression(initialExpression);
}, [filterExpression, initialExpression, setFilterExpression]);
const debouncedFilterExpression = useDebounce(
filterExpression?.trim() || initialExpression,
EXPRESSION_DEBOUNCE_TIME_MS,
);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
const partExpression = generateFilterQuery({
fieldKey,
fieldValue,
type: getOldLogsOperatorFromNew(operator),
});
const newExpression = inputExpression.trim()
? `${inputExpression} AND ${partExpression}`
: partExpression;
setInputExpression(newExpression);
setFilterExpression(newExpression);
},
[inputExpression, setFilterExpression, handleCloseLogDetail],
const basePayload = getHostLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
filters,
);
const handleFilterChange = useCallback(
(expression: string): void => {
setInputExpression(expression);
const validation = validateQuery(expression);
if (validation.isValid) {
setFilterExpression(expression);
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.HostEntity,
view: InfraMonitoringEvents.LogsView,
page: InfraMonitoringEvents.DetailedPage,
});
}
},
[setFilterExpression],
);
const queryData = useMemo(
() =>
getHostLogsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
// this should use inputExpression to show suggestions correctly
// while we don't accept the final expression yet
expression: inputExpression,
}).queryData,
[timeRange.startTime, timeRange.endTime, inputExpression],
);
const {
logs,
hasReachedEndOfLogs,
isPaginating,
currentPage,
setIsPaginating,
handleNewData,
loadMoreLogs,
hasNextPage,
isFetchingNextPage,
isLoading,
isFetching,
isError,
} = useInfiniteHostMetricLogs({
expression: debouncedFilterExpression,
startTime: timeRange.startTime,
endTime: timeRange.endTime,
queryPayload,
} = useHandleLogsPagination({
timeRange,
filters,
excludeFilterKeys: ['host.name'],
basePayload,
});
const { data, isLoading, isFetching, isError } = useQuery({
queryKey: [
'hostMetricsLogs',
timeRange.startTime,
timeRange.endTime,
filters,
currentPage,
],
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
enabled: !!queryPayload,
keepPreviousData: isPaginating,
});
useEffect(() => {
if (data?.payload?.data?.newResult?.data?.result) {
handleNewData(data.payload.data.newResult.data.result);
}
}, [data, handleNewData]);
useEffect(() => {
setIsPaginating(false);
}, [data, setIsPaginating]);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
@@ -195,21 +122,22 @@ function HostMetricsLogs({
const renderFooter = useCallback(
(): JSX.Element | null => (
<>
{isFetchingNextPage ? (
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
) : !hasNextPage && logs.length > 0 ? (
<div className={styles.logsLoadingSkeleton}> *** End *** </div>
{isFetching ? (
<div className="logs-loading-skeleton"> Loading more logs ... </div>
) : hasReachedEndOfLogs ? (
<div className="logs-loading-skeleton"> *** End *** </div>
) : null}
</>
),
[isFetchingNextPage, hasNextPage, logs.length],
[isFetching, hasReachedEndOfLogs],
);
const renderContent = useMemo(
() => (
<Card bordered={false} className={styles.listCard}>
<Card bordered={false} className="host-metrics-logs-list-card">
<OverlayScrollbar isVirtuoso>
<Virtuoso
className="host-metrics-logs-virtuoso"
key="host-metrics-logs-virtuoso"
ref={virtuosoRef}
data={logs}
@@ -227,55 +155,32 @@ function HostMetricsLogs({
[logs, loadMoreLogs, getItemContent, renderFooter],
);
const showInitialLoading = isLoading || (isFetching && logs.length === 0);
return (
<>
<div className={styles.header}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
<div className="host-metrics-logs">
{isLoading && <LogsLoading />}
{!isLoading && !isError && logs.length === 0 && <NoLogsContainer />}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && logs.length > 0 && (
<div
className="host-metrics-logs-list-container"
data-log-detail-ignore="true"
>
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
</div>
<QuerySearch
queryData={queryData}
onChange={handleFilterChange}
dataSource={DataSource.LOGS}
/>
<div className={styles.logs}>
{showInitialLoading && <LogsLoading />}
{!showInitialLoading && !isError && logs.length === 0 && (
<NoLogsContainer />
)}
{isError && !showInitialLoading && <LogsError />}
{!showInitialLoading && !isError && logs.length > 0 && (
<div className={styles.listContainer} data-log-detail-ignore="true">
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -1,15 +1,16 @@
import { Color } from '@signozhq/design-tokens';
import { Typography } from 'antd';
import { Ghost } from 'lucide-react';
import styles from './HostMetricLogs.module.scss';
const { Text } = Typography;
export default function NoLogsContainer(): React.ReactElement {
return (
<div className={styles.noLogsFound}>
<p>
<div className="no-logs-found">
<Text type="secondary">
<Ghost size={24} color={Color.BG_AMBER_500} /> No logs found for this host
in the selected time range.
</p>
</Text>
</div>
);
}

View File

@@ -1,975 +0,0 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
import HostMetricsLogs from '../HostMetricsLogs';
jest.mock('react-virtuoso', () => {
const actual = jest.requireActual('react-virtuoso');
return {
...actual,
Virtuoso: ({
data,
itemContent,
endReached,
components,
className,
}: {
data?: any[];
itemContent?: (index: number, item: any) => React.ReactNode;
endReached?: (index: number) => void;
components?: { Footer?: React.ComponentType };
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-mock" className={className}>
{Array.isArray(data) &&
data.map((item, index) => (
<div key={item?.id ?? index} data-testid={`virtuoso-item-${index}`}>
{itemContent?.(index, item)}
</div>
))}
<button
type="button"
data-testid="virtuoso-end-reached"
onClick={(): void => endReached?.((data?.length || 0) - 1)}
>
endReached
</button>
{components?.Footer ? <components.Footer /> : null}
</div>
),
};
});
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELDS_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
const FIELDS_VALUES_URL = `${ENVIRONMENT.baseURL}/api/v1/fields/values`;
// Creates a V5 API response structure for raw logs data
// The API response is wrapped in { data: { type: '...', data: { results: [...] } } }
const createLogsResponse = ({
offset = 0,
pageSize = 100,
hasMore = true,
}: {
offset?: number;
pageSize?: number;
hasMore?: boolean;
}): any => {
const itemsForThisPage = hasMore ? pageSize : Math.min(pageSize / 2, 10);
return {
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
rows: Array.from({ length: itemsForThisPage }, (_, index) => {
const cumulativeIndex = offset + index;
const baseTimestamp = new Date('2024-02-15T21:20:22Z').getTime();
const currentTimestamp = new Date(
baseTimestamp - cumulativeIndex * 1000,
);
const timestampString = currentTimestamp.toISOString();
const id = `log-id-${cumulativeIndex}`;
const logLevel = ['INFO', 'WARN', 'ERROR'][cumulativeIndex % 3];
const service = ['frontend', 'backend', 'database'][cumulativeIndex % 3];
return {
timestamp: timestampString,
data: {
attributes_bool: {},
attributes_float64: {},
attributes_int64: {},
attributes_string: {
host_name: 'test-host',
log_level: logLevel,
service,
},
body: `${timestampString} ${logLevel} ${service} Log message ${cumulativeIndex}`,
id,
resources_string: {
'host.name': 'test-host',
},
severity_number: [9, 13, 17][cumulativeIndex % 3],
severity_text: logLevel,
span_id: `span-${cumulativeIndex}`,
trace_flags: 0,
trace_id: `trace-${cumulativeIndex}`,
},
};
}),
},
],
},
},
};
};
const createEmptyLogsResponse = (): any => ({
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
rows: [],
},
],
},
},
});
const defaultProps = {
initialExpression: 'host_name = "test-host"',
timeRange: {
startTime: 1708000000,
endTime: 1708003600,
},
isModalTimeSelection: false,
handleTimeChange: jest.fn(),
selectedInterval: '15m' as const,
};
// Mock OverlayScrollbar to avoid scroll behavior issues in tests
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => {
return (
<div className="datetime-section" data-testid="datetime-selection">
<button
data-testid="time-picker-btn"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
</div>
);
},
}));
const createFieldKeysResponse = (): any => ({
status: 'success',
data: {
complete: true,
keys: {},
},
});
const createFieldValuesResponse = (): any => ({
status: 'success',
data: {
values: {
stringValues: [],
numberValues: [],
boolValues: [],
},
},
});
const renderComponent = (
props = defaultProps,
searchParams?: Record<string, string>,
): ReturnType<typeof render> =>
render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 600, itemHeight: 50 }}
>
<HostMetricsLogs {...props} />
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,
);
describe('HostMetricsLogs', () => {
beforeEach(() => {
window.history.pushState({}, 'Test', '/');
server.use(
rest.get(FIELDS_KEYS_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createFieldKeysResponse())),
),
rest.get(FIELDS_VALUES_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createFieldValuesResponse())),
),
);
});
describe('loading state', () => {
it('should show loading state while fetching logs', async () => {
let resolveRequest: (value: any) => void;
const pendingPromise = new Promise((resolve) => {
resolveRequest = resolve;
});
server.use(
rest.post(QUERY_RANGE_URL, async (_, res, ctx) => {
await pendingPromise;
return res(ctx.status(200), ctx.json(createLogsResponse({})));
}),
);
renderComponent();
expect(screen.getByText('pending_data_placeholder')).toBeInTheDocument();
act(() => {
resolveRequest!(true);
});
});
});
describe('empty state', () => {
it('should show no logs message when no logs are returned', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createEmptyLogsResponse())),
),
);
renderComponent();
await waitFor(() => {
expect(
screen.getByText(/No logs found for this host/i),
).toBeInTheDocument();
});
});
});
describe('error state', () => {
it('should show error state when API returns error', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
),
);
renderComponent();
await waitFor(() => {
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
});
});
});
describe('success state', () => {
it('should render logs when API returns data', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent();
await waitFor(() => {
expect(screen.getByText(/Log message 0/)).toBeInTheDocument();
});
});
it('should render initial expression in QuerySearch editor', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent();
await waitFor(() => {
const editorText =
document.querySelector('.query-where-clause-editor')?.textContent || '';
expect(editorText).toContain('host_name');
expect(editorText).toContain('test-host');
});
});
it('should render the filter section', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent();
await waitFor(() => {
expect(
document.querySelector('.code-mirror-where-clause'),
).toBeInTheDocument();
});
});
it('should render date time selection component', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent();
await waitFor(() => {
// DateTimeSelectionV2 renders a time picker button
expect(document.querySelector('.datetime-section')).toBeInTheDocument();
});
});
});
describe('pagination', () => {
it('should send correct offset for pagination', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
const querySpec = payload.compositeQuery?.queries?.[0]?.spec;
const offset = querySpec?.offset ?? 0;
return res(
ctx.status(200),
ctx.json(
createLogsResponse({
offset,
pageSize: 100,
hasMore: offset === 0,
}),
),
);
}),
);
renderComponent();
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
});
const firstPayload = requestPayloads[0];
const querySpec = firstPayload.compositeQuery?.queries?.[0]?.spec;
expect(querySpec?.offset).toBe(0);
});
it('should fetch next page when virtuoso endReached is triggered', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
const querySpec = payload.compositeQuery?.queries?.[0]?.spec;
const offset = querySpec?.offset ?? 0;
return res(
ctx.status(200),
ctx.json(
createLogsResponse({
offset,
pageSize: 100,
hasMore: offset === 0,
}),
),
);
}),
);
renderComponent();
await waitFor(() => {
expect(screen.getByText(/Log message 0/)).toBeInTheDocument();
});
expect(requestPayloads[0]?.compositeQuery?.queries?.[0]?.spec?.offset).toBe(
0,
);
await userEvent.click(screen.getByTestId('virtuoso-end-reached'));
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(2);
});
expect(requestPayloads[1]?.compositeQuery?.queries?.[0]?.spec?.offset).toBe(
100,
);
});
});
describe('filter expression', () => {
it('should include initial expression in the query', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
renderComponent();
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
});
const firstPayload = requestPayloads[0];
const querySpec = firstPayload.compositeQuery?.queries?.[0]?.spec;
expect(querySpec?.filter?.expression).toContain('host_name = "test-host"');
});
it('should load expression from URL and persist it in the query', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
const querySpec = payload.compositeQuery?.queries?.[0]?.spec;
const offset = querySpec?.offset ?? 0;
return res(
ctx.status(200),
ctx.json(
createLogsResponse({
offset,
pageSize: 100,
hasMore: offset === 0,
}),
),
);
}),
);
const urlExpression = 'service = "from-url"';
renderComponent(defaultProps, { hostMetricsLogsExpr: urlExpression });
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
});
expect(
requestPayloads[0]?.compositeQuery?.queries?.[0]?.spec?.filter?.expression,
).toContain(urlExpression);
await userEvent.click(screen.getByTestId('virtuoso-end-reached'));
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(2);
});
expect(
requestPayloads[1]?.compositeQuery?.queries?.[0]?.spec?.filter?.expression,
).toContain(urlExpression);
});
it('should use custom expression when provided', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
const customExpression = 'service = "custom-service"';
renderComponent({
...defaultProps,
initialExpression: customExpression,
});
// Wait for debounce and potential re-renders to settle
await waitFor(
() => {
const hasCustomExpression = requestPayloads.some((payload) => {
const querySpec = payload.compositeQuery?.queries?.[0]?.spec;
return querySpec?.filter?.expression?.includes('custom-service');
});
expect(hasCustomExpression).toBe(true);
},
{ timeout: 2000 },
);
});
});
describe('time range', () => {
it('should include correct time range in the query', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
const customTimeRange = {
startTime: 1700000000,
endTime: 1700003600,
};
renderComponent({
...defaultProps,
timeRange: customTimeRange,
});
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
});
const firstPayload = requestPayloads[0];
// V5 API expects milliseconds (seconds * 1000)
expect(firstPayload.start).toBe(customTimeRange.startTime * 1000);
expect(firstPayload.end).toBe(customTimeRange.endTime * 1000);
});
});
describe('query structure', () => {
it('should send correct query structure to the API', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
renderComponent();
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
});
const firstPayload = requestPayloads[0];
const querySpec = firstPayload.compositeQuery?.queries?.[0]?.spec;
expect(querySpec?.signal).toBe('logs');
expect(querySpec?.order).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ name: 'timestamp' }),
direction: 'desc',
}),
]),
);
});
it('should send request type as raw for logs list', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
renderComponent();
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
});
const firstPayload = requestPayloads[0];
expect(firstPayload.requestType).toBe('raw');
});
it('should include pageSize in the query', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
renderComponent();
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
});
const firstPayload = requestPayloads[0];
const querySpec = firstPayload.compositeQuery?.queries?.[0]?.spec;
// Should have a limit set for pagination
expect(querySpec?.limit).toBeDefined();
expect(typeof querySpec?.limit).toBe('number');
});
});
describe('component props', () => {
it('should render datetime section with isModalTimeSelection', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent({
...defaultProps,
isModalTimeSelection: true,
});
await waitFor(() => {
expect(document.querySelector('.datetime-section')).toBeInTheDocument();
});
});
it('should render component with handleTimeChange', async () => {
const mockHandleTimeChange = jest.fn();
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent({
...defaultProps,
handleTimeChange: mockHandleTimeChange,
});
await waitFor(() => {
expect(document.querySelector('.datetime-section')).toBeInTheDocument();
});
});
});
describe('log detail interactions', () => {
it('should open log detail drawer when clicking on a log', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent();
// Wait for logs to render
await waitFor(() => {
expect(screen.getByText(/Log message 0/)).toBeInTheDocument();
});
// Click on the first log
const logElement = screen.getByText(/Log message 0/);
await userEvent.click(logElement);
// Log detail drawer should open - it contains "Log details" title
await waitFor(() => {
expect(screen.getByText('Log details')).toBeInTheDocument();
});
});
it('should close log detail drawer when clicking on the same log again', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent();
// Wait for logs to render
await waitFor(() => {
expect(screen.getByText(/Log message 0/)).toBeInTheDocument();
});
// Click on the first log to open
const logElement = screen.getByText(/Log message 0/);
await userEvent.click(logElement);
// Wait for drawer to open
await waitFor(() => {
expect(screen.getByText('Log details')).toBeInTheDocument();
});
// Click on the same log to close (through the close button)
const closeButton = document.querySelector('.ant-drawer-close');
if (closeButton) {
await userEvent.click(closeButton);
}
// Drawer should close
await waitFor(() => {
expect(screen.queryByText('Log details')).not.toBeInTheDocument();
});
});
it('should display log body in detail drawer', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent();
// Wait for logs to render
await waitFor(() => {
expect(screen.getByText(/Log message 0/)).toBeInTheDocument();
});
// Click on the first log to open drawer
const logElement = screen.getByText(/Log message 0/);
await userEvent.click(logElement);
// Wait for drawer to open
await waitFor(() => {
expect(screen.getByText('Log details')).toBeInTheDocument();
});
// Verify the drawer tabs are displayed
// The drawer should show the Overview tab
await waitFor(() => {
expect(screen.getByText('Overview')).toBeInTheDocument();
});
// Verify other tabs are present
expect(screen.getByText('JSON')).toBeInTheDocument();
expect(screen.getByText('Context')).toBeInTheDocument();
});
});
describe('log detail filter actions', () => {
it('should apply filter-in from log detail and close the drawer', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
renderComponent();
await waitFor(() => {
expect(screen.getByText(/Log message 0/)).toBeInTheDocument();
});
await userEvent.click(screen.getByText(/Log message 0/));
await waitFor(() => {
expect(screen.getByText('Log details')).toBeInTheDocument();
});
const serviceRow = await waitFor(() => {
const attributeNameCells = Array.from(
document.querySelectorAll('.attribute-name'),
);
const serviceCell = attributeNameCells.find((cell) =>
(cell.textContent || '').toLowerCase().includes('service'),
);
const row = serviceCell?.closest('tr');
if (!row) {
throw new Error('Service attribute row not found');
}
return row;
});
const filterButtons = serviceRow.querySelectorAll('button.filter-btn');
expect(filterButtons?.length).toBeGreaterThanOrEqual(2);
await userEvent.click(filterButtons[0] as HTMLButtonElement);
await waitFor(() => {
expect(screen.queryByText('Log details')).not.toBeInTheDocument();
});
await waitFor(
() => {
const matched = requestPayloads.some((payload) => {
const expression =
payload.compositeQuery?.queries?.[0]?.spec?.filter?.expression || '';
return (
(expression.includes('attributes_string.service') ||
expression.includes('service')) &&
expression.includes("('frontend')") &&
expression.includes('IN')
);
});
expect(matched).toBe(true);
},
{ timeout: 2500 },
);
});
it('should apply filter-out from log detail and close the drawer', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
renderComponent();
await waitFor(() => {
expect(screen.getByText(/Log message 0/)).toBeInTheDocument();
});
await userEvent.click(screen.getByText(/Log message 0/));
await waitFor(() => {
expect(screen.getByText('Log details')).toBeInTheDocument();
});
const serviceRow = await waitFor(() => {
const attributeNameCells = Array.from(
document.querySelectorAll('.attribute-name'),
);
const serviceCell = attributeNameCells.find((cell) =>
(cell.textContent || '').toLowerCase().includes('service'),
);
const row = serviceCell?.closest('tr');
if (!row) {
throw new Error('Service attribute row not found');
}
return row;
});
const filterButtons = serviceRow.querySelectorAll('button.filter-btn');
expect(filterButtons?.length).toBeGreaterThanOrEqual(2);
// the second button that represents filter out
await userEvent.click(filterButtons[1] as HTMLButtonElement);
await waitFor(() => {
expect(screen.queryByText('Log details')).not.toBeInTheDocument();
});
await waitFor(
() => {
const matched = requestPayloads.some((payload) => {
const expression =
payload.compositeQuery?.queries?.[0]?.spec?.filter?.expression || '';
return (
(expression.includes('attributes_string.service') ||
expression.includes('service')) &&
expression.includes("('frontend')") &&
(expression.includes('NIN') || expression.includes('NOT_IN'))
);
});
expect(matched).toBe(true);
},
{ timeout: 2500 },
);
});
});
describe('time range change', () => {
it('should use different time ranges for different renders', async () => {
const requestPayloads: any[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const payload = await req.json();
requestPayloads.push(payload);
return res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 })));
}),
);
// First render with initial time range
const { unmount } = renderComponent();
// Wait for initial fetch
await waitFor(() => {
expect(requestPayloads.length).toBeGreaterThanOrEqual(1);
});
const firstStartTime = requestPayloads[0].start;
expect(firstStartTime).toBe(defaultProps.timeRange.startTime * 1000);
// Unmount and render again with different time range
unmount();
const newTimeRange = {
startTime: 1709000000,
endTime: 1709003600,
};
renderComponent({
...defaultProps,
timeRange: newTimeRange,
});
// Wait for fetch with new time range
await waitFor(() => {
const hasNewTimeRange = requestPayloads.some(
(p) => p.start === newTimeRange.startTime * 1000,
);
expect(hasNewTimeRange).toBe(true);
});
});
it('should call handleTimeChange callback when time picker is clicked', async () => {
const mockHandleTimeChange = jest.fn();
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
renderComponent({
...defaultProps,
handleTimeChange: mockHandleTimeChange,
});
// Wait for component to render
await waitFor(() => {
expect(screen.getByTestId('time-picker-btn')).toBeInTheDocument();
});
// Click the time picker button (from mock)
await userEvent.click(screen.getByTestId('time-picker-btn'));
// Verify the callback was called
expect(mockHandleTimeChange).toHaveBeenCalledWith('5m');
});
});
});

View File

@@ -1,311 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { useInfiniteHostMetricLogs } from '../hooks';
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const createLogsResponse = ({
offset = 0,
pageSize = 100,
hasMore = true,
}: {
offset?: number;
pageSize?: number;
hasMore?: boolean;
}): any => {
const itemsForThisPage = hasMore ? pageSize : pageSize / 2;
return {
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
rows: Array.from({ length: itemsForThisPage }, (_, index) => {
const cumulativeIndex = offset + index;
return {
timestamp: new Date(Date.now() - cumulativeIndex * 1000).toISOString(),
data: {
body: `Log message ${cumulativeIndex}`,
id: `log-${cumulativeIndex}`,
severity_text: 'INFO',
},
};
}),
},
],
},
},
};
};
const createEmptyResponse = (): any => ({
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
rows: [],
},
],
},
},
});
const createWrapper = (): React.FC<{ children: React.ReactNode }> => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
describe('useInfiniteHostMetricLogs', () => {
const defaultParams = {
expression: 'host_name = "test-host"',
startTime: 1708000000,
endTime: 1708003600,
};
describe('initial state', () => {
it('should return initial loading state', () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.delay(100), ctx.status(200), ctx.json(createLogsResponse({}))),
),
);
const { result } = renderHook(
() => useInfiniteHostMetricLogs(defaultParams),
{
wrapper: createWrapper(),
},
);
expect(result.current.isLoading).toBe(true);
expect(result.current.logs).toEqual([]);
});
});
describe('successful data fetching', () => {
it('should return logs after successful fetch', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createLogsResponse({ pageSize: 5 }))),
),
);
const { result } = renderHook(
() => useInfiniteHostMetricLogs(defaultParams),
{
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs.length).toBe(5);
expect(result.current.isError).toBe(false);
});
it('should set hasNextPage based on response size', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json(createLogsResponse({ pageSize: 100, hasMore: true })),
),
),
);
const { result } = renderHook(
() => useInfiniteHostMetricLogs(defaultParams),
{
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasNextPage).toBe(true);
});
it('should not have next page when response is smaller than page size', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json(createLogsResponse({ pageSize: 100, hasMore: false })),
),
),
);
const { result } = renderHook(
() => useInfiniteHostMetricLogs(defaultParams),
{
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasNextPage).toBe(false);
});
});
describe('empty state', () => {
it('should return empty logs array when no data', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(createEmptyResponse())),
),
);
const { result } = renderHook(
() => useInfiniteHostMetricLogs(defaultParams),
{
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs).toEqual([]);
expect(result.current.hasNextPage).toBe(false);
});
});
describe('error handling', () => {
it('should set isError on API failure', async () => {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
),
);
const { result } = renderHook(
() => useInfiniteHostMetricLogs(defaultParams),
{
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.logs).toEqual([]);
});
});
describe('query disabled state', () => {
it('should not fetch when expression is empty', async () => {
const requestCount = { count: 0 };
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) => {
requestCount.count += 1;
return res(ctx.status(200), ctx.json(createLogsResponse({})));
}),
);
const { result } = renderHook(
() =>
useInfiniteHostMetricLogs({
...defaultParams,
expression: '',
}),
{
wrapper: createWrapper(),
},
);
// Wait a bit to ensure no request is made
await new Promise((resolve) => {
setTimeout(resolve, 300);
});
expect(requestCount.count).toBe(0);
expect(result.current.isLoading).toBe(false);
});
});
describe('load more functionality', () => {
it('should fetch next page when loadMoreLogs is called', async () => {
const requestCount = { count: 0 };
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) => {
requestCount.count += 1;
if (requestCount.count === 1) {
return res(
ctx.status(200),
ctx.json(
createLogsResponse({ offset: 0, pageSize: 100, hasMore: true }),
),
);
}
return res(
ctx.status(200),
ctx.json(
createLogsResponse({ offset: 100, pageSize: 100, hasMore: false }),
),
);
}),
);
const { result } = renderHook(
() => useInfiniteHostMetricLogs(defaultParams),
{
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs.length).toBe(100);
expect(result.current.hasNextPage).toBe(true);
expect(requestCount.count).toBe(1);
act(() => {
result.current.loadMoreLogs();
});
await waitFor(() => {
expect(result.current.logs.length).toBe(150);
});
expect(result.current.hasNextPage).toBe(false);
expect(requestCount.count).toBe(2);
});
});
});

View File

@@ -1,5 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -7,82 +6,56 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';
export interface HostLogsQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
export const getHostLogsQueryPayload = ({
export const getHostLogsQueryPayload = (
start: number,
end: number,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps => ({
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [
{
dataSource: DataSource.LOGS,
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters,
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset: 0,
pageSize: 100,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PER_PAGE_VALUE,
}: HostLogsQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} => {
const queryData: IBuilderQuery = {
dataSource: DataSource.LOGS,
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
{
columnName: 'id',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
};
export const HOST_METRICS_LOGS_EXPR_QUERY_KEY = 'hostMetricsLogsExpr';
});

View File

@@ -1,93 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useInfiniteQuery } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { getHostLogsQueryPayload } from './constants';
export function useInfiniteHostMetricLogs({
expression,
startTime,
endTime,
}: {
expression: string;
startTime: number;
endTime: number;
}): {
logs: ILog[];
isLoading: boolean;
isFetching: boolean;
isFetchingNextPage: boolean;
isError: boolean;
hasNextPage: boolean;
loadMoreLogs: () => void;
} {
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
isError,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ['hostMetricsLogs', startTime, endTime, expression],
queryFn: async ({ pageParam = 0 }) => {
const { query } = getHostLogsQueryPayload({
start: startTime,
end: endTime,
expression,
offset: pageParam,
pageSize: DEFAULT_PER_PAGE_VALUE,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5);
},
getNextPageParam: (lastPage, allPages) => {
const list = lastPage?.payload?.data?.newResult?.data?.result?.[0]?.list;
if (!list || list.length < DEFAULT_PER_PAGE_VALUE) {
return undefined;
}
return allPages.length * DEFAULT_PER_PAGE_VALUE;
},
enabled: !!expression,
});
const logs = useMemo<ILog[]>(() => {
if (!data?.pages) {
return [];
}
return data.pages.flatMap((page) => {
const list = page?.payload?.data?.newResult?.data?.result?.[0]?.list;
if (!list) {
return [];
}
return list.map(
(item) =>
({
...item.data,
timestamp: item.timestamp,
} as ILog),
);
});
}, [data?.pages]);
const loadMoreLogs = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return {
logs,
isLoading,
isFetching,
isFetchingNextPage,
isError,
hasNextPage: !!hasNextPage,
loadMoreLogs,
};
}

View File

@@ -16,9 +16,9 @@ function AverageResolutionCard({
}: TotalTriggeredCardProps): JSX.Element {
return (
<StatsCard
displayValue={formatTime(currentAvgResolutionTime)}
totalCurrentCount={currentAvgResolutionTime}
totalPastCount={pastAvgResolutionTime}
displayValue={formatTime(+currentAvgResolutionTime)}
totalCurrentCount={+currentAvgResolutionTime}
totalPastCount={+pastAvgResolutionTime}
title="Avg. Resolution Time"
timeSeries={timeSeries}
/>

View File

@@ -48,6 +48,7 @@ import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import GridCard from './GridCard';
import { Card, CardContainer, ReactGridLayout } from './styles';
import {
applyRowCollapse,
hasColumnWidthsChanged,
removeUndefinedValuesFromLayout,
} from './utils';
@@ -268,13 +269,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
currentWidget.title = newTitle;
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
(e) => e.id !== currentSelectRowId,
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
);
updatedWidgets?.push(currentWidget);
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
data: {
@@ -316,88 +314,13 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
if (!selectedDashboard) {
return;
}
const rowProperties = { ...currentPanelMap[id] };
const updatedPanelMap = { ...currentPanelMap };
let updatedDashboardLayout = [...dashboardLayout];
if (rowProperties.collapsed === true) {
rowProperties.collapsed = false;
const widgetsInsideTheRow = rowProperties.widgets;
let maxY = 0;
widgetsInsideTheRow.forEach((w) => {
maxY = Math.max(maxY, w.y + w.h);
});
const currentRowWidget = dashboardLayout.find((w) => w.i === id);
if (currentRowWidget && widgetsInsideTheRow.length) {
maxY -= currentRowWidget.h + currentRowWidget.y;
}
const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id);
for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) {
updatedDashboardLayout[j].y += maxY;
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
updatedDashboardLayout[j].i
].widgets.map((w) => ({
...w,
y: w.y + maxY,
}));
}
}
updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow];
} else {
rowProperties.collapsed = true;
const currentIdx = dashboardLayout.findIndex((w) => w.i === id);
let widgetsInsideTheRow: Layout[] = [];
let isPanelMapUpdated = false;
for (let j = currentIdx + 1; j < dashboardLayout.length; j++) {
if (currentPanelMap[dashboardLayout[j].i]) {
rowProperties.widgets = widgetsInsideTheRow;
widgetsInsideTheRow = [];
isPanelMapUpdated = true;
break;
} else {
widgetsInsideTheRow.push(dashboardLayout[j]);
}
}
if (!isPanelMapUpdated) {
rowProperties.widgets = widgetsInsideTheRow;
}
let maxY = 0;
widgetsInsideTheRow.forEach((w) => {
maxY = Math.max(maxY, w.y + w.h);
});
const currentRowWidget = dashboardLayout[currentIdx];
if (currentRowWidget && widgetsInsideTheRow.length) {
maxY -= currentRowWidget.h + currentRowWidget.y;
}
for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) {
updatedDashboardLayout[j].y += maxY;
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
updatedDashboardLayout[j].i
].widgets.map((w) => ({
...w,
y: w.y + maxY,
}));
}
}
updatedDashboardLayout = updatedDashboardLayout.filter(
(widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i),
);
}
setCurrentPanelMap((prev) => ({
...prev,
...updatedPanelMap,
[id]: {
...rowProperties,
},
}));
setDashboardLayout(sortLayout(updatedDashboardLayout));
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
id,
dashboardLayout,
currentPanelMap,
);
setCurrentPanelMap((prev) => ({ ...prev, ...updatedPanelMap }));
setDashboardLayout(sortLayout(updatedLayout));
};
const handleDragStop: ItemCallback = (_, oldItem, newItem): void => {

View File

@@ -0,0 +1,181 @@
import { Layout } from 'react-grid-layout';
import { applyRowCollapse, PanelMap } from '../utils';
// Helper to produce deeply-frozen objects that mimic what zustand/immer returns.
function freeze<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj), (_, v) =>
typeof v === 'object' && v !== null ? Object.freeze(v) : v,
) as T;
}
// ─── fixtures ────────────────────────────────────────────────────────────────
const ROW_ID = 'row1';
/** A layout with one row followed by two widgets. */
function makeLayout(): Layout[] {
return [
{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 },
{ i: 'w1', x: 0, y: 1, w: 6, h: 4 },
{ i: 'w2', x: 6, y: 1, w: 6, h: 4 },
];
}
/** panelMap where the row is expanded (collapsed = false, widgets = []). */
function makeExpandedPanelMap(): PanelMap {
return {
[ROW_ID]: { collapsed: false, widgets: [] },
};
}
/** panelMap where the row is collapsed (widgets stored inside). */
function makeCollapsedPanelMap(): PanelMap {
return {
[ROW_ID]: {
collapsed: true,
widgets: [
{ i: 'w1', x: 0, y: 1, w: 6, h: 4 },
{ i: 'w2', x: 6, y: 1, w: 6, h: 4 },
],
},
};
}
// ─── frozen-input guard (regression for zustand/immer read-only bug) ──────────
describe('applyRowCollapse does not mutate frozen inputs', () => {
it('does not throw when collapsing a row with frozen layout + panelMap', () => {
expect(() =>
applyRowCollapse(
ROW_ID,
freeze(makeLayout()),
freeze(makeExpandedPanelMap()),
),
).not.toThrow();
});
it('does not throw when expanding a row with frozen layout + panelMap', () => {
// Collapsed layout only has the row item; widgets live in panelMap.
const collapsedLayout = freeze([{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }]);
expect(() =>
applyRowCollapse(ROW_ID, collapsedLayout, freeze(makeCollapsedPanelMap())),
).not.toThrow();
});
it('leaves the original layout array untouched after collapse', () => {
const layout = makeLayout();
const originalY = layout[1].y; // w1.y before collapse
applyRowCollapse(ROW_ID, layout, makeExpandedPanelMap());
expect(layout[1].y).toBe(originalY);
});
it('leaves the original panelMap untouched after collapse', () => {
const panelMap = makeExpandedPanelMap();
applyRowCollapse(ROW_ID, makeLayout(), panelMap);
expect(panelMap[ROW_ID].collapsed).toBe(false);
});
});
// ─── collapse behaviour ───────────────────────────────────────────────────────
describe('applyRowCollapse collapsing a row', () => {
it('sets collapsed = true on the row entry', () => {
const { updatedPanelMap } = applyRowCollapse(
ROW_ID,
makeLayout(),
makeExpandedPanelMap(),
);
expect(updatedPanelMap[ROW_ID].collapsed).toBe(true);
});
it('stores the child widgets inside the panelMap entry', () => {
const { updatedPanelMap } = applyRowCollapse(
ROW_ID,
makeLayout(),
makeExpandedPanelMap(),
);
const ids = updatedPanelMap[ROW_ID].widgets.map((w) => w.i);
expect(ids).toContain('w1');
expect(ids).toContain('w2');
});
it('removes child widgets from the returned layout', () => {
const { updatedLayout } = applyRowCollapse(
ROW_ID,
makeLayout(),
makeExpandedPanelMap(),
);
const ids = updatedLayout.map((l) => l.i);
expect(ids).not.toContain('w1');
expect(ids).not.toContain('w2');
expect(ids).toContain(ROW_ID);
});
});
// ─── expand behaviour ─────────────────────────────────────────────────────────
describe('applyRowCollapse expanding a row', () => {
it('sets collapsed = false on the row entry', () => {
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
const { updatedPanelMap } = applyRowCollapse(
ROW_ID,
collapsedLayout,
makeCollapsedPanelMap(),
);
expect(updatedPanelMap[ROW_ID].collapsed).toBe(false);
});
it('restores child widgets to the returned layout', () => {
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
const { updatedLayout } = applyRowCollapse(
ROW_ID,
collapsedLayout,
makeCollapsedPanelMap(),
);
const ids = updatedLayout.map((l) => l.i);
expect(ids).toContain('w1');
expect(ids).toContain('w2');
});
it('restored child widgets appear in both the layout and the panelMap entry', () => {
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
ROW_ID,
collapsedLayout,
makeCollapsedPanelMap(),
);
// The previously-stored widgets should now be back in the live layout.
expect(updatedLayout.map((l) => l.i)).toContain('w1');
// The panelMap entry still holds a reference to them (stale until next collapse).
expect(updatedPanelMap[ROW_ID].widgets.map((w) => w.i)).toContain('w1');
});
});
// ─── y-offset adjustment ──────────────────────────────────────────────────────
describe('applyRowCollapse y-offset adjustments for rows below', () => {
it('shifts items below a second row down when the first row expands', () => {
const ROW2 = 'row2';
// Layout: row1 (y=0,h=1) | w1 (y=1,h=4) | row2 (y=5,h=1) | w3 (y=6,h=2)
const layout: Layout[] = [
{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 },
{ i: 'w1', x: 0, y: 1, w: 12, h: 4 },
{ i: ROW2, x: 0, y: 5, w: 12, h: 1 },
{ i: 'w3', x: 0, y: 6, w: 12, h: 2 },
];
const panelMap: PanelMap = {
[ROW_ID]: {
collapsed: true,
widgets: [{ i: 'w1', x: 0, y: 1, w: 12, h: 4 }],
},
[ROW2]: { collapsed: false, widgets: [] },
};
// Expanding row1 should push row2 and w3 down by the height of w1 (4).
const collapsedLayout = layout.filter((l) => l.i !== 'w1');
const { updatedLayout } = applyRowCollapse(ROW_ID, collapsedLayout, panelMap);
const row2Item = updatedLayout.find((l) => l.i === ROW2);
expect(row2Item?.y).toBe(5 + 4); // shifted by maxY = 4
});
});

View File

@@ -4,6 +4,122 @@ import { isEmpty, isEqual } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
export type PanelMap = Record<
string,
{ widgets: Layout[]; collapsed: boolean }
>;
export interface RowCollapseResult {
updatedLayout: Layout[];
updatedPanelMap: PanelMap;
}
/**
* Pure function that computes the new layout and panelMap after toggling a
* row's collapsed state. All inputs are treated as immutable — no input object
* is mutated, so it is safe to pass frozen objects from the zustand store.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function applyRowCollapse(
id: string,
dashboardLayout: Layout[],
currentPanelMap: PanelMap,
): RowCollapseResult {
// Deep-copy the row's own properties so we can mutate our local copy.
const rowProperties = {
...currentPanelMap[id],
widgets: [...(currentPanelMap[id]?.widgets ?? [])],
};
// Shallow-copy each entry's widgets array so inner .map() calls are safe.
const updatedPanelMap: PanelMap = Object.fromEntries(
Object.entries(currentPanelMap).map(([k, v]) => [
k,
{ ...v, widgets: [...v.widgets] },
]),
);
let updatedDashboardLayout = [...dashboardLayout];
if (rowProperties.collapsed === true) {
// ── EXPAND ──────────────────────────────────────────────────────────────
rowProperties.collapsed = false;
const widgetsInsideTheRow = rowProperties.widgets;
let maxY = 0;
widgetsInsideTheRow.forEach((w) => {
maxY = Math.max(maxY, w.y + w.h);
});
const currentRowWidget = dashboardLayout.find((w) => w.i === id);
if (currentRowWidget && widgetsInsideTheRow.length) {
maxY -= currentRowWidget.h + currentRowWidget.y;
}
const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id);
for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) {
updatedDashboardLayout[j] = {
...updatedDashboardLayout[j],
y: updatedDashboardLayout[j].y + maxY,
};
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
updatedDashboardLayout[j].i
].widgets.map((w) => ({ ...w, y: w.y + maxY }));
}
}
updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow];
} else {
// ── COLLAPSE ─────────────────────────────────────────────────────────────
rowProperties.collapsed = true;
const currentIdx = dashboardLayout.findIndex((w) => w.i === id);
let widgetsInsideTheRow: Layout[] = [];
let isPanelMapUpdated = false;
for (let j = currentIdx + 1; j < dashboardLayout.length; j++) {
if (currentPanelMap[dashboardLayout[j].i]) {
rowProperties.widgets = widgetsInsideTheRow;
widgetsInsideTheRow = [];
isPanelMapUpdated = true;
break;
} else {
widgetsInsideTheRow.push(dashboardLayout[j]);
}
}
if (!isPanelMapUpdated) {
rowProperties.widgets = widgetsInsideTheRow;
}
let maxY = 0;
widgetsInsideTheRow.forEach((w) => {
maxY = Math.max(maxY, w.y + w.h);
});
const currentRowWidget = dashboardLayout[currentIdx];
if (currentRowWidget && widgetsInsideTheRow.length) {
maxY -= currentRowWidget.h + currentRowWidget.y;
}
for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) {
updatedDashboardLayout[j] = {
...updatedDashboardLayout[j],
y: updatedDashboardLayout[j].y + maxY,
};
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
updatedDashboardLayout[j].i
].widgets.map((w) => ({ ...w, y: w.y + maxY }));
}
}
updatedDashboardLayout = updatedDashboardLayout.filter(
(widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i),
);
}
updatedPanelMap[id] = { ...rowProperties };
return { updatedLayout: updatedDashboardLayout, updatedPanelMap };
}
export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] =>
layout.map((obj) =>
Object.fromEntries(

View File

@@ -89,7 +89,7 @@ function HostsList(): JSX.Element {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: filters?.items?.length ? filters : undefined,
filters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy,
@@ -97,6 +97,15 @@ function HostsList(): JSX.Element {
}, [pageSize, currentPage, filters, minTime, maxTime, orderBy]);
const queryKey = useMemo(() => {
if (selectedHostName) {
return [
'hostList',
String(pageSize),
String(currentPage),
JSON.stringify(filters),
JSON.stringify(orderBy),
];
}
return [
'hostList',
String(pageSize),
@@ -106,7 +115,15 @@ function HostsList(): JSX.Element {
String(minTime),
String(maxTime),
];
}, [pageSize, currentPage, filters, orderBy, minTime, maxTime]);
}, [
pageSize,
currentPage,
filters,
orderBy,
selectedHostName,
minTime,
maxTime,
]);
const { data, isFetching, isLoading, isError } = useGetHostList(
query as HostListPayload,

View File

@@ -4,7 +4,6 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render } from '@testing-library/react';
import * as useGetHostListHooks from 'hooks/infraMonitoring/useGetHostList';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import * as appContextHooks from 'providers/App/App';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
@@ -131,30 +130,26 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
describe('HostsList', () => {
it('renders hosts list table', () => {
const { container } = render(
<NuqsTestingAdapter>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</NuqsTestingAdapter>,
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
});
it('renders filters', () => {
const { container } = render(
<NuqsTestingAdapter>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</NuqsTestingAdapter>,
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
expect(container.querySelector('.filters')).toBeInTheDocument();
});

View File

@@ -92,7 +92,10 @@ function LogsActionsContainer({
/>
</div>
<div className="download-options-container">
<DownloadOptionsMenu dataSource={DataSource.LOGS} />
<DownloadOptionsMenu
dataSource={DataSource.LOGS}
selectedColumns={options?.selectColumns}
/>
</div>
<div className="format-options-container">
<LogsFormatOptionsMenu

View File

@@ -42,8 +42,15 @@ function LogsPanelComponent({
setPageSize(value);
setOffset(0);
setRequestData((prev) => {
const newQueryData = { ...prev.query };
newQueryData.builder.queryData[0].pageSize = value;
const newQueryData = {
...prev.query,
builder: {
...prev.query.builder,
queryData: prev.query.builder.queryData.map((qd, i) =>
i === 0 ? { ...qd, pageSize: value } : qd,
),
},
};
return {
...prev,
query: newQueryData,

View File

@@ -42,11 +42,19 @@ function Panel({
};
}
updatedQuery.builder.queryData[0].pageSize = 10;
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
const updatedQueryForList = {
...updatedQuery,
builder: {
...updatedQuery.builder,
queryData: updatedQuery.builder.queryData.map((qd, i) =>
i === 0 ? { ...qd, pageSize: 10 } : qd,
),
},
};
return {
query: updatedQuery,
query: updatedQueryForList,
graphType: PANEL_TYPES.LIST,
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
tableParams: {

View File

@@ -239,7 +239,10 @@ function ListView({
/>
</div>
<DownloadOptionsMenu dataSource={DataSource.TRACES} />
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}

View File

@@ -52,37 +52,44 @@ export const useGetQueryRange: UseGetQueryRange = (
!firstQueryData?.filters?.items.some((filter) => filter.key?.key === 'id') &&
firstQueryData?.orderBy[0].columnName === 'timestamp';
const modifiedRequestData = {
if (
isListWithSingleTimestampOrder &&
firstQueryData?.dataSource === DataSource.LOGS
) {
return {
...requestData,
graphType:
requestData.graphType === PANEL_TYPES.BAR
? PANEL_TYPES.TIME_SERIES
: requestData.graphType,
query: {
...requestData.query,
builder: {
...requestData.query.builder,
queryData: [
{
...firstQueryData,
orderBy: [
...(firstQueryData?.orderBy || []),
{
columnName: 'id',
order: firstQueryData?.orderBy[0]?.order,
},
],
},
],
},
},
};
}
return {
...requestData,
graphType:
requestData.graphType === PANEL_TYPES.BAR
? PANEL_TYPES.TIME_SERIES
: requestData.graphType,
};
// If the query is a list with a single timestamp order, we need to add the id column to the order by clause
if (
isListWithSingleTimestampOrder &&
firstQueryData?.dataSource === DataSource.LOGS
) {
modifiedRequestData.query.builder = {
...requestData.query.builder,
queryData: [
{
...firstQueryData,
orderBy: [
...(firstQueryData?.orderBy || []),
{
columnName: 'id',
order: firstQueryData?.orderBy[0]?.order,
},
],
},
],
};
}
return modifiedRequestData;
}, [requestData]);
const queryKey = useMemo(() => {

View File

@@ -3,7 +3,7 @@ import { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { message } from 'antd';
import { downloadExportData } from 'api/v1/download/downloadExportData';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { prepareQueryRangePayloadV5, TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
@@ -14,6 +14,7 @@ interface ExportOptions {
format: string;
rowLimit: number;
clearSelectColumns: boolean;
selectedColumns?: TelemetryFieldKey[];
}
interface UseExportRawDataProps {
@@ -42,6 +43,7 @@ export function useExportRawData({
format,
rowLimit,
clearSelectColumns,
selectedColumns,
}: ExportOptions): Promise<void> => {
if (!stagedQuery) {
return;
@@ -50,6 +52,12 @@ export function useExportRawData({
try {
setIsDownloading(true);
const selectColumnsOverride = clearSelectColumns
? {}
: selectedColumns?.length
? { selectColumns: selectedColumns }
: {};
const exportQuery = {
...stagedQuery,
builder: {
@@ -59,7 +67,7 @@ export function useExportRawData({
groupBy: [],
having: { expression: '' },
limit: rowLimit,
...(clearSelectColumns && { selectColumns: [] }),
...selectColumnsOverride,
})),
queryTraceOperator: (stagedQuery.builder.queryTraceOperator || []).map(
(traceOp) => ({
@@ -67,7 +75,7 @@ export function useExportRawData({
groupBy: [],
having: { expression: '' },
limit: rowLimit,
...(clearSelectColumns && { selectColumns: [] }),
...selectColumnsOverride,
}),
),
},

View File

@@ -56,8 +56,8 @@ export interface AlertRuleStats {
totalPastTriggers: number;
currentTriggersSeries: CurrentTriggersSeries;
pastTriggersSeries: CurrentTriggersSeries | null;
currentAvgResolutionTime: number;
pastAvgResolutionTime: number;
currentAvgResolutionTime: string;
pastAvgResolutionTime: string;
currentAvgResolutionTimeSeries: CurrentTriggersSeries;
pastAvgResolutionTimeSeries: any | null;
}

View File

@@ -112,6 +112,12 @@ export function formatEpochTimestamp(epoch: number): string {
*/
export function formatTime(seconds: number): string {
seconds = +seconds;
if (Number.isNaN(seconds)) {
return '-';
}
const days = seconds / 86400;
if (days >= 1) {

View File

@@ -1,6 +1,7 @@
package tracedetail
import (
"maps"
"slices"
"sort"
@@ -63,26 +64,22 @@ func findIndexForSelectedSpanFromPreOrder(spans []*model.Span, selectedSpanId st
return selectedSpanIndex
}
func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, uncollapsedSpans []string, isSelectedSpanIDUnCollapsed bool) (bool, []string) {
func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string) (bool, []string) {
spansFromRootToNode := []string{}
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
if node.SpanID == selectedSpanId {
if isSelectedSpanIDUnCollapsed && !slices.Contains(uncollapsedSpans, node.SpanID) {
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
}
return true, spansFromRootToNode
}
isPresentInSubtreeForTheNode := false
for _, child := range node.Children {
isPresentInThisSubtree, _spansFromRootToNode := getPathFromRootToSelectedSpanId(child, selectedSpanId, uncollapsedSpans, isSelectedSpanIDUnCollapsed)
isPresentInThisSubtree, _spansFromRootToNode := getPathFromRootToSelectedSpanId(child, selectedSpanId)
// if the interested node is present in the given subtree then add the span node to uncollapsed node list
if isPresentInThisSubtree {
if !slices.Contains(uncollapsedSpans, node.SpanID) {
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
}
isPresentInSubtreeForTheNode = true
spansFromRootToNode = append(spansFromRootToNode, _spansFromRootToNode...)
break
}
}
return isPresentInSubtreeForTheNode, spansFromRootToNode
@@ -92,7 +89,7 @@ func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, un
// throughout the recursion. Per-call state (level, isPartOfPreOrder, etc.)
// is passed as direct arguments.
type traverseOpts struct {
uncollapsedSpans []string
uncollapsedSpans map[string]struct{}
selectedSpanID string
}
@@ -134,7 +131,7 @@ func traverseTrace(span *model.Span, opts traverseOpts, level uint64, isPartOfPr
preOrderTraversal = append(preOrderTraversal, &nodeWithoutChildren)
}
isAlreadyUncollapsed := slices.Contains(opts.uncollapsedSpans, span.SpanID)
_, isAlreadyUncollapsed := opts.uncollapsedSpans[span.SpanID]
for index, child := range span.Children {
_childTraversal := traverseTrace(child, opts, level+1, isPartOfPreOrder && isAlreadyUncollapsed, index != (len(span.Children)-1))
preOrderTraversal = append(preOrderTraversal, _childTraversal...)
@@ -169,16 +166,28 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
var preOrderTraversal = make([]*model.Span, 0)
var rootServiceName, rootServiceEntryPoint string
updatedUncollapsedSpans := uncollapsedSpans
// create a map of uncollapsed spans for quick lookup
uncollapsedSpanMap := make(map[string]struct{})
for _, spanID := range uncollapsedSpans {
uncollapsedSpanMap[spanID] = struct{}{}
}
selectedSpanIndex := -1
for _, rootSpanID := range traceRoots {
if rootNode, exists := spanIdToSpanNodeMap[rootSpanID.SpanID]; exists {
_, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID, updatedUncollapsedSpans, isSelectedSpanIDUnCollapsed)
updatedUncollapsedSpans = append(updatedUncollapsedSpans, spansFromRootToNode...)
present, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID)
if present {
for _, spanID := range spansFromRootToNode {
if selectedSpanID == spanID && !isSelectedSpanIDUnCollapsed {
continue
}
uncollapsedSpanMap[spanID] = struct{}{}
}
}
opts := traverseOpts{
uncollapsedSpans: updatedUncollapsedSpans,
uncollapsedSpans: uncollapsedSpanMap,
selectedSpanID: selectedSpanID,
}
_preOrderTraversal := traverseTrace(rootNode, opts, 0, true, false)
@@ -223,5 +232,5 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
startIndex = 0
}
return preOrderTraversal[startIndex:endIndex], updatedUncollapsedSpans, rootServiceName, rootServiceEntryPoint
return preOrderTraversal[startIndex:endIndex], slices.Collect(maps.Keys(uncollapsedSpanMap)), rootServiceName, rootServiceEntryPoint
}

View File

@@ -183,7 +183,7 @@ func TestGetSelectedSpans_PathReturnedInUncollapsed(t *testing.T) {
spanMap := buildSpanMap(root)
spans, uncollapsed, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, false)
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent", "selected"}, spanIDs(spans))
}
@@ -206,7 +206,7 @@ func TestGetSelectedSpans_SiblingsNotExpanded(t *testing.T) {
// children of root sort alphabetically: parent < unrelated; unrelated-child stays hidden
assert.Equal(t, []string{"root", "parent", "selected", "unrelated"}, spanIDs(spans))
// only the path nodes are tracked as uncollapsed — unrelated is not
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
}
// An unknown selectedSpanID must not panic; returns a window from index 0.

View File

@@ -3,7 +3,9 @@ package telemetrylogs
import (
"fmt"
"slices"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -32,9 +34,25 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
// BuildCondition builds the full WHERE condition for body_v2 JSON paths
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
baseCond, err := c.emitPlannedCondition(operator, value, sb)
if err != nil {
return "", err
}
// path index
if operator.AddDefaultExistsFilter() {
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
return sb.And(baseCond, pathIndex), nil
}
return baseCond, nil
}
func (c *jsonConditionBuilder) emitPlannedCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
// Build traversal + terminal recursively per-hop
conditions := []string{}
for _, node := range c.key.JSONPlan {
condition, err := c.emitPlannedCondition(node, operator, value, sb)
condition, err := c.recurseArrayHops(node, operator, value, sb)
if err != nil {
return "", err
}
@@ -44,164 +62,7 @@ func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperato
return sb.Or(conditions...), nil
}
// emitPlannedCondition handles paths with array traversal
func (c *jsonConditionBuilder) emitPlannedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
// Build traversal + terminal recursively per-hop
compiled, err := c.recurseArrayHops(node, operator, value, sb)
if err != nil {
return "", err
}
return compiled, nil
}
// buildTerminalCondition creates the innermost condition
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if node.TerminalConfig.ElemType.IsArray {
conditions := []string{}
// if the value type is not an array
// TODO(piyush): Confirm the Query built for Array case and add testcases for it later
if !c.valueType.IsArray {
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
if operator.IsStringSearchOperator() {
formattedValue := querybuilder.FormatValueForContains(value)
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
if err != nil {
return "", err
}
conditions = append(conditions, arrayCond)
}
// switch operator for array membership checks
switch operator {
case qbtypes.FilterOperatorContains:
operator = qbtypes.FilterOperatorEqual
case qbtypes.FilterOperatorNotContains:
operator = qbtypes.FilterOperatorNotEqual
}
}
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, arrayCond)
// or the conditions together
return sb.Or(conditions...), nil
}
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
}
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
// it handles the data type collisions and utilizes indexes for the condition if available
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
fieldPath := node.FieldPath()
conditions := []string{}
var formattedValue any = value
if operator.IsStringSearchOperator() {
formattedValue = querybuilder.FormatValueForContains(value)
}
elemType := node.TerminalConfig.ElemType
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
// utilize indexes for the condition if available
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
return index.Type == elemType && index.ColumnExpression == fieldPath
})
if elemType.IndexSupported && indexed {
indexedExpr := assumeNotNull(fieldPath, elemType)
emptyValue := func() any {
switch elemType {
case telemetrytypes.String:
return ""
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
return 0
default:
return nil
}
}()
// switch the operator and value for exists and not exists
switch operator {
case qbtypes.FilterOperatorExists:
operator = qbtypes.FilterOperatorNotEqual
value = emptyValue
case qbtypes.FilterOperatorNotExists:
operator = qbtypes.FilterOperatorEqual
value = emptyValue
default:
// do nothing
}
indexedExpr, indexedComparisonValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, indexedExpr, operator)
cond, err := c.applyOperator(sb, indexedExpr, operator, indexedComparisonValue)
if err != nil {
return "", err
}
// if qb has a definitive value, we can skip adding a condition to
// check the existence of the path in the json column
if value != emptyValue {
return cond, nil
}
conditions = append(conditions, cond)
// Switch operator to EXISTS since indexed paths on assumedNotNull, indexes will always have a default value
// So we flip the operator to Exists and filter the rows that actually have the value
operator = qbtypes.FilterOperatorExists
}
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
if len(conditions) > 1 {
return sb.And(conditions...), nil
}
return conditions[0], nil
}
// buildArrayMembershipCondition handles array membership checks
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
arrayPath := node.FieldPath()
localKeyCopy := *node.TerminalConfig.Key
// create typed array out of a dynamic array
filteredDynamicExpr := func() string {
// Change the field data type from []dynamic to the value type
// since we've filtered the value type out of the dynamic array, we need to change the field data corresponding to the value type
localKeyCopy.FieldDataType = telemetrytypes.MappingJSONDataTypeToFieldDataType[telemetrytypes.ScalerTypeToArrayType[c.valueType]]
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
return fmt.Sprintf("arrayMap(x->dynamicElement(x, '%s'), arrayFilter(x->(dynamicType(x) = '%s'), %s))",
c.valueType.StringValue(),
c.valueType.StringValue(),
baseArrayDynamicExpr)
}
typedArrayExpr := func() string {
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
}
var arrayExpr string
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
arrayExpr = filteredDynamicExpr()
} else {
arrayExpr = typedArrayExpr()
}
key := "x"
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
op, err := c.applyOperator(sb, fieldExpr, operator, value)
if err != nil {
return "", err
}
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
}
// recurseArrayHops recursively builds array traversal conditions
// buildPlanCondition recursively traverses a single JSONPlan and builds condition
func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if current == nil {
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
@@ -215,6 +76,33 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
return terminalCond, nil
}
// apply NOT at top level arrayExists so that any subsequent arrayExists fails we count it as true (matching log)
yes, operator := applyNotCondition(operator)
condition, err := c.buildAccessNodeBranches(current, operator, value, sb)
if err != nil {
return "", err
}
if yes {
return sb.Not(condition), nil
}
return condition, nil
}
func applyNotCondition(operator qbtypes.FilterOperator) (bool, qbtypes.FilterOperator) {
if operator.IsNegativeOperator() {
return true, operator.Inverse()
}
return false, operator
}
// buildAccessNodeBranches builds conditions for each branch of the access node
func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if current == nil {
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
}
currAlias := current.Alias()
fieldPath := current.FieldPath()
// Determine availability of Array(JSON) and Array(Dynamic) at this hop
@@ -249,6 +137,213 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
return sb.Or(branches...), nil
}
// buildTerminalCondition creates the innermost condition
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if node.TerminalConfig.ElemType.IsArray {
// Note: here applyNotCondition will return true only if; top level path is an array; and operator is a negative operator
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
yes, operator := applyNotCondition(operator)
cond, err := c.buildTerminalArrayCondition(node, operator, value, sb)
if err != nil {
return "", err
}
if yes {
return sb.Not(cond), nil
}
return cond, nil
}
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
}
func getEmptyValue(elemType telemetrytypes.JSONDataType) any {
switch elemType {
case telemetrytypes.String:
return ""
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
return 0
default:
return nil
}
}
func (c *jsonConditionBuilder) terminalIndexedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
fieldPath := node.FieldPath()
if strings.Contains(fieldPath, telemetrytypes.ArraySepSuffix) {
return "", errors.NewInternalf(CodeArrayNavigationFailed, "can not build index condition for array field %s", fieldPath)
}
elemType := node.TerminalConfig.ElemType
dynamicExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
indexedExpr := assumeNotNull(dynamicExpr)
// switch the operator and value for exists and not exists
switch operator {
case qbtypes.FilterOperatorExists:
operator = qbtypes.FilterOperatorNotEqual
value = getEmptyValue(elemType)
case qbtypes.FilterOperatorNotExists:
operator = qbtypes.FilterOperatorEqual
value = getEmptyValue(elemType)
default:
// do nothing
}
indexedExpr, formattedValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, indexedExpr, operator)
cond, err := c.applyOperator(sb, indexedExpr, operator, formattedValue)
if err != nil {
return "", err
}
return cond, nil
}
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
// it handles the data type collisions and utilizes indexes for the condition if available
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
fieldPath := node.FieldPath()
conditions := []string{}
// utilize indexes for the condition if available
//
// Note: Indexing code doesn't get executed for Array Nested fields because they can not be indexed
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
return index.Type == node.TerminalConfig.ElemType && index.ColumnExpression == fieldPath
})
if node.TerminalConfig.ElemType.IndexSupported && indexed {
indexCond, err := c.terminalIndexedCondition(node, operator, value, sb)
if err != nil {
return "", err
}
// if qb has a definitive value, we can skip adding a condition to
// check the existence of the path in the json column
if value != nil && value != getEmptyValue(node.TerminalConfig.ElemType) {
return indexCond, nil
}
conditions = append(conditions, indexCond)
// Switch operator to EXISTS except when operator is NOT EXISTS since
// indexed paths on assumedNotNull, indexes will always have a default
// value so we flip the operator to Exists and filter the rows that
// actually have the value
if operator != qbtypes.FilterOperatorNotExists {
operator = qbtypes.FilterOperatorExists
}
}
var formattedValue any = value
if operator.IsStringSearchOperator() {
formattedValue = querybuilder.FormatValueForContains(value)
}
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue())
// if operator is negative and has a value comparison i.e. excluding EXISTS and NOT EXISTS, we need to assume that the field exists everywhere
//
// Note: here applyNotCondition will return true only if; top level path is being queried and operator is a negative operator
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
if node.IsNonNestedPath() {
yes, _ := applyNotCondition(operator)
if yes {
switch operator {
case qbtypes.FilterOperatorNotExists:
// skip
default:
fieldExpr = assumeNotNull(fieldExpr)
}
}
}
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
if len(conditions) > 1 {
return sb.And(conditions...), nil
}
return conditions[0], nil
}
func (c *jsonConditionBuilder) buildTerminalArrayCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
conditions := []string{}
// if the value type is not an array
// TODO(piyush): Confirm the Query built for Array case and add testcases for it later
if !c.valueType.IsArray {
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
if operator.IsStringSearchOperator() {
formattedValue := querybuilder.FormatValueForContains(value)
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
if err != nil {
return "", err
}
conditions = append(conditions, arrayCond)
}
// switch operator for array membership checks
switch operator {
case qbtypes.FilterOperatorContains:
operator = qbtypes.FilterOperatorEqual
case qbtypes.FilterOperatorNotContains:
operator = qbtypes.FilterOperatorNotEqual
}
}
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, arrayCond)
return sb.Or(conditions...), nil
}
// buildArrayMembershipCondition builds condition of the part where Arrays becomes primitive types
// e.g. [300, 404, 500], and value operations will work on the array elements
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
arrayPath := node.FieldPath()
localKeyCopy := *node.TerminalConfig.Key
// create typed array out of a dynamic array
filteredDynamicExpr := func() string {
// Change the field data type from []dynamic to the value type
// since we've filtered the value type out of the dynamic array, we need to change the field data corresponding to the value type
localKeyCopy.FieldDataType = telemetrytypes.MappingJSONDataTypeToFieldDataType[telemetrytypes.ScalerTypeToArrayType[c.valueType]]
primitiveType := c.valueType.StringValue()
// check if value is an array
if c.valueType.IsArray {
primitiveType = c.valueType.ScalerType
}
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
return fmt.Sprintf("arrayMap(x->dynamicElement(x, '%s'), arrayFilter(x->(dynamicType(x) = '%s'), %s))",
primitiveType,
primitiveType,
baseArrayDynamicExpr)
}
typedArrayExpr := func() string {
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
}
var arrayExpr string
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
arrayExpr = filteredDynamicExpr()
} else {
arrayExpr = typedArrayExpr()
}
key := "x"
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
op, err := c.applyOperator(sb, fieldExpr, operator, value)
if err != nil {
return "", err
}
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
}
func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, fieldExpr string, operator qbtypes.FilterOperator, value any) (string, error) {
switch operator {
case qbtypes.FilterOperatorEqual:
@@ -310,6 +405,6 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
}
}
func assumeNotNull(column string, elemType telemetrytypes.JSONDataType) string {
return fmt.Sprintf("assumeNotNull(dynamicElement(%s, '%s'))", column, elemType.StringValue())
func assumeNotNull(fieldExpr string) string {
return fmt.Sprintf("assumeNotNull(%s)", fieldExpr)
}

File diff suppressed because one or more lines are too long

View File

@@ -113,6 +113,29 @@ const (
FilterOperatorNotContains
)
var operatorInverseMapping = map[FilterOperator]FilterOperator{
FilterOperatorEqual: FilterOperatorNotEqual,
FilterOperatorNotEqual: FilterOperatorEqual,
FilterOperatorGreaterThan: FilterOperatorLessThanOrEq,
FilterOperatorGreaterThanOrEq: FilterOperatorLessThan,
FilterOperatorLessThan: FilterOperatorGreaterThanOrEq,
FilterOperatorLessThanOrEq: FilterOperatorGreaterThan,
FilterOperatorLike: FilterOperatorNotLike,
FilterOperatorNotLike: FilterOperatorLike,
FilterOperatorILike: FilterOperatorNotILike,
FilterOperatorNotILike: FilterOperatorILike,
FilterOperatorBetween: FilterOperatorNotBetween,
FilterOperatorNotBetween: FilterOperatorBetween,
FilterOperatorIn: FilterOperatorNotIn,
FilterOperatorNotIn: FilterOperatorIn,
FilterOperatorExists: FilterOperatorNotExists,
FilterOperatorNotExists: FilterOperatorExists,
FilterOperatorRegexp: FilterOperatorNotRegexp,
FilterOperatorNotRegexp: FilterOperatorRegexp,
FilterOperatorContains: FilterOperatorNotContains,
FilterOperatorNotContains: FilterOperatorContains,
}
// AddDefaultExistsFilter returns true if addl exists filter should be added to the query
// For the negative predicates, we don't want to add the exists filter. Why?
// Say for example, user adds a filter `service.name != "redis"`, we can't interpret it
@@ -162,6 +185,10 @@ func (f FilterOperator) IsNegativeOperator() bool {
return true
}
func (f FilterOperator) Inverse() FilterOperator {
return operatorInverseMapping[f]
}
func (f FilterOperator) IsComparisonOperator() bool {
switch f {
case FilterOperatorGreaterThan, FilterOperatorGreaterThanOrEq, FilterOperatorLessThan, FilterOperatorLessThanOrEq:

View File

@@ -102,6 +102,10 @@ func (fn FunctionName) Validate() error {
// ApplyFunction applies the given function to the result data
func ApplyFunction(fn Function, result *TimeSeries) *TimeSeries {
if len(result.Values) == 0 {
return result
}
// Extract the function name and arguments
name := fn.Name
args := fn.Args

View File

@@ -599,6 +599,14 @@ func TestApplyFunction(t *testing.T) {
values []float64
want []float64
}{
{
name: "test with empty series",
function: Function{
Name: FunctionNameRunningDiff,
},
values: []float64{},
want: []float64{},
},
{
name: "cutOffMin function",
function: Function{

View File

@@ -206,8 +206,11 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
return nil
}
// At least one aggregation required for non-disabled queries
if len(q.Aggregations) == 0 && !q.Disabled {
// At least one aggregation required for aggregation queries, even if
// they are disabled, usually because they are used in formula
// regardless of use in formula, it's invalid to have empty Aggregations
// for aggregation request
if len(q.Aggregations) == 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"at least one aggregation is required",

View File

@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -31,7 +32,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{
MetricName: "test",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
Signal: telemetrytypes.SignalMetrics,
},
},
{
@@ -39,7 +47,12 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
Signal: telemetrytypes.SignalLogs,
},
},
},
@@ -61,7 +74,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{
MetricName: "test",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
Signal: telemetrytypes.SignalMetrics,
},
},
{
@@ -194,7 +214,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{
MetricName: "test",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
Signal: telemetrytypes.SignalMetrics,
},
},
{
@@ -232,7 +259,12 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "sum(duration)",
},
},
Signal: telemetrytypes.SignalLogs,
},
},
},
@@ -366,7 +398,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
Signal: telemetrytypes.SignalLogs,
},
},
{
@@ -374,7 +411,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalTraces,
Aggregations: []TraceAggregation{
{
Expression: "count()",
},
},
Signal: telemetrytypes.SignalTraces,
},
},
},
@@ -396,7 +438,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Spec: QueryBuilderQuery[LogAggregation]{
Name: "X",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
Signal: telemetrytypes.SignalLogs,
},
},
{
@@ -404,7 +451,14 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "X",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{
MetricName: "test",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
Signal: telemetrytypes.SignalMetrics,
},
},
},
@@ -427,7 +481,9 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Name: "A",
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{Expression: "count()"},
{
Expression: "count()",
},
},
},
},
@@ -581,7 +637,9 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Name: "A",
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{Expression: "count()"},
{
Expression: "count()",
},
},
},
},

View File

@@ -90,6 +90,11 @@ func (n *JSONAccessNode) FieldPath() string {
return n.Parent.Alias() + "." + key
}
// Returns true if the current node is a non-nested path
func (n *JSONAccessNode) IsNonNestedPath() bool {
return !strings.Contains(n.FieldPath(), ArraySep)
}
func (n *JSONAccessNode) BranchesInOrder() []JSONAccessBranchType {
return slices.SortedFunc(maps.Keys(n.Branches), func(a, b JSONAccessBranchType) int {
return strings.Compare(b.StringValue(), a.StringValue())

View File

@@ -4,69 +4,235 @@ package telemetrytypes
// Test JSON Type Set Data Setup
// ============================================================================
// TestJSONTypeSet returns a map of path->types for testing
// This represents the type information available in the test JSON structure
// TestJSONTypeSet returns a map of path->types for testing.
// This represents the type information available in the test JSON structure.
//
// Structural patterns covered:
//
// Primitives message, count, duration, user.name, user.age, ...
// x.y service.name, response.code, user.address.city
// x[].y education[].name, http-events[].request-id
// x[].y.z education[].metadata.location, http-events[].request-info.host
// x[].y.z[] http-events[].request-info.headers (array behind non-array hop)
// x[].y.z.a[] http-events[].request-info.meta-data.entries (two non-array hops → array)
// x[].y[] education[].awards
// x[].y[].z education[].awards[].name
// x[].y[].z[] education[].awards[].participated
// x[].y[].z[].w education[].awards[].participated[].type
// x[].y[].z[].w[] education[].awards[].participated[].team
// x[].y[].z[].w[].v education[].awards[].participated[].team[].branch
func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
types := map[string][]JSONDataType{
"user.name": {String},
"user.permissions": {ArrayString},
"user.age": {Int64, String},
"user.height": {Float64},
"education": {ArrayJSON},
"education[].name": {String},
"education[].type": {String, Int64},
"education[].internal_type": {String},
"education[].metadata.location": {String},
"education[].parameters": {ArrayFloat64, ArrayDynamic},
"education[].duration": {String},
"education[].mode": {String},
"education[].year": {Int64},
"education[].field": {String},
"education[].awards": {ArrayDynamic, ArrayJSON},
"education[].awards[].name": {String},
"education[].awards[].rank": {Int64},
"education[].awards[].medal": {String},
"education[].awards[].type": {String},
"education[].awards[].semester": {Int64},
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
"education[].awards[].participated[].type": {String},
"education[].awards[].participated[].field": {String},
"education[].awards[].participated[].project_type": {String},
"education[].awards[].participated[].project_name": {String},
"education[].awards[].participated[].race_type": {String},
"education[].awards[].participated[].team_based": {Bool},
"education[].awards[].participated[].team_name": {String},
"education[].awards[].participated[].team": {ArrayJSON},
"education[].awards[].participated[].members": {ArrayString},
// ── user (primitives) ─────────────────────────────────────────────
"user.name": {String},
"user.permissions": {ArrayString},
"user.age": {Int64, String}, // Int64/String ambiguity
"user.height": {Float64},
"user.active": {Bool}, // Bool — not IndexSupported
"user.score": {Float64, Int64}, // numeric Float64/Int64 ambiguity
// Deeper non-array nesting (a.b.c — no array hops)
"user.address.city": {String},
"user.address.zip": {Int64},
"user.address.country": {String, Int64}, // deep + ambiguous
// ── education[] ───────────────────────────────────────────────────
// Pattern: x[].y
"education": {ArrayJSON},
"education[].name": {String},
"education[].type": {String, Int64},
"education[].internal_type": {String},
"education[].duration": {String},
"education[].mode": {String},
"education[].year": {Int64},
"education[].field": {String},
"education[].grades": {ArrayBool}, // bool array terminal
"education[].scores": {ArrayInt64}, // int array terminal
"education[].parameters": {ArrayFloat64, ArrayDynamic},
// Pattern: x[].y.z
"education[].metadata.location": {String},
// Pattern: x[].y[]
"education[].awards": {ArrayDynamic, ArrayJSON},
// Pattern: x[].y[].z
"education[].awards[].name": {String},
"education[].awards[].rank": {Int64},
"education[].awards[].medal": {String},
"education[].awards[].type": {String},
"education[].awards[].semester": {Int64},
// Pattern: x[].y[].z[]
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
// Pattern: x[].y[].z[].w
"education[].awards[].participated[].type": {String},
"education[].awards[].participated[].field": {String},
"education[].awards[].participated[].project_type": {String},
"education[].awards[].participated[].project_name": {String},
"education[].awards[].participated[].race_type": {String},
"education[].awards[].participated[].team_based": {Bool},
"education[].awards[].participated[].team_name": {String},
"education[].awards[].participated[].members": {ArrayString},
// Pattern: x[].y[].z[].w[]
"education[].awards[].participated[].team": {ArrayJSON},
// Pattern: x[].y[].z[].w[].v
"education[].awards[].participated[].team[].name": {String},
"education[].awards[].participated[].team[].branch": {String},
"education[].awards[].participated[].team[].semester": {Int64},
"interests": {ArrayJSON},
"interests[].type": {String},
"interests[].entities": {ArrayJSON},
"interests[].entities.application_date": {String},
"interests[].entities[].reviews": {ArrayJSON},
"interests[].entities[].reviews[].given_by": {String},
"interests[].entities[].reviews[].remarks": {String},
"interests[].entities[].reviews[].weight": {Float64},
"interests[].entities[].reviews[].passed": {Bool},
"interests[].entities[].reviews[].type": {String},
"interests[].entities[].reviews[].analysis_type": {Int64},
"interests[].entities[].reviews[].entries": {ArrayJSON},
"interests[].entities[].reviews[].entries[].subject": {String},
"interests[].entities[].reviews[].entries[].status": {String},
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
"interests[].entities[].reviews[].entries[].metadata[].company": {String},
"interests[].entities[].reviews[].entries[].metadata[].experience": {Int64},
"interests[].entities[].reviews[].entries[].metadata[].unit": {String},
// ── interests[] ───────────────────────────────────────────────────
"interests": {ArrayJSON},
"interests[].type": {String},
"interests[].entities": {ArrayJSON},
"interests[].entities.application_date": {String},
"interests[].entities[].reviews": {ArrayJSON},
"interests[].entities[].reviews[].given_by": {String},
"interests[].entities[].reviews[].remarks": {String},
"interests[].entities[].reviews[].weight": {Float64},
"interests[].entities[].reviews[].passed": {Bool},
"interests[].entities[].reviews[].type": {String},
"interests[].entities[].reviews[].analysis_type": {Int64},
"interests[].entities[].reviews[].score": {Float64}, // additional Float64 primitive
"interests[].entities[].reviews[].flags": {ArrayBool}, // bool array in deep nesting
"interests[].entities[].reviews[].entries": {ArrayJSON},
"interests[].entities[].reviews[].entries[].subject": {String},
"interests[].entities[].reviews[].entries[].status": {String},
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
"interests[].entities[].reviews[].entries[].metadata[].company": {String},
"interests[].entities[].reviews[].entries[].metadata[].experience": {Int64},
"interests[].entities[].reviews[].entries[].metadata[].unit": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions[].duration": {Int64, Float64},
"interests[].entities[].reviews[].entries[].metadata[].positions[].unit": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
"message": {String},
"tags": {ArrayString},
// ── http-events[] ─────────────────────────────────────────────────
// Segments use -, @, _ symbols. Root is a single ArrayJSON.
//
// Pattern: x[].y
"http-events": {ArrayJSON},
"http-events[].request-id": {String},
"http-events[].status-code": {Int64, String}, // ambiguous
"http-events[].@type": {String, Int64}, // @ symbol + ambiguous
// Pattern: x[].y.z (non-array intermediate, primitive terminal)
"http-events[].request-info.host": {String},
"http-events[].request-info.port": {Int64, Float64}, // numeric ambiguity
"http-events[].request-info.method": {String},
"http-events[].@context.version": {Int64}, // @ in intermediate segment
"http-events[].@context.format": {String},
// Pattern: x[].y.z[] (non-array intermediate, array terminal)
"http-events[].request-info.headers": {ArrayJSON, ArrayDynamic},
"http-events[].request-info.headers[].name": {String},
"http-events[].request-info.headers[].value": {String, Int64}, // ambiguous
"http-events[].@context.tags": {ArrayString}, // @ + simple array terminal
// Pattern: x[].y.z.a[] (two non-array hops then array)
"http-events[].request-info.meta-data.entries": {ArrayJSON, ArrayDynamic},
"http-events[].request-info.meta-data.entries[].key": {String},
"http-events[].request-info.meta-data.entries[].value": {String, Int64}, // ambiguous
"http-events[].request-info.meta-data.entries[]._score": {Float64}, // _ prefix
// ── top-level primitives ──────────────────────────────────────────
// Every IndexSupported scalar type, plus Bool and ambiguous pairs.
"message": {String},
"error": {Bool, String}, // Bool — not IndexSupported
"order-Id": {Float64, Int64}, // numeric Float64/Int64 ambiguity at root
// Special characters in root-level keys
"http-status": {Int64, String}, // hyphen in root key, ambiguous
"_internal": {String}, // underscore prefix
"@version": {String, Int64}, // @ at root level, ambiguous
// ── top-level nested objects (no array hops) ───────────────────────
"service.name": {String},
"service.version": {String, Int64}, // ambiguous
"service.weight": {Float64},
"service.active": {Bool},
"response.code": {Int64, String, ArrayString}, // ambiguous
"response.message": {String},
"response.time-taken": {Float64}, // hyphen inside nested key
// ── top-level arrays ──────────────────────────────────────────────
// One array of every type so every array terminal branch is reachable
// from a non-array root path.
"tags": {ArrayString},
}
return types, nil
}
// TestIndexedPathEntry is a path + JSON type pair representing a field
// backed by a ClickHouse skip index in the test data.
//
// Only non-array paths with IndexSupported types (String, Int64, Float64)
// are valid entries — arrays and Bool cannot carry a skip index.
//
// The ColumnExpression for each entry is computed at test-setup time from
// the access plan, since it depends on the column name (e.g. body_v2)
// which is unknown to this package.
type TestIndexedPathEntry struct {
Path string
Type JSONDataType
}
// TestIndexedPaths lists path+type pairs from TestJSONTypeSet that are
// backed by a JSON data type index. Test setup uses this to populate
// key.Indexes after calling SetJSONAccessPlan.
//
// Intentionally excluded:
// - user.active, service.active → Bool, IndexSupported=false
// - error (Bool variant) → Bool, IndexSupported=false
// - response.code (ArrayString) → array type, cannot be indexed
// - http-events[].* → inside an array hop; array-nested fields cannot be indexed
// - education[].* → same reason
// - tags → array type, cannot be indexed
var TestIndexedPaths = []TestIndexedPathEntry{
// root-level primitives
{Path: "message", Type: String},
{Path: "error", Type: String}, // String variant of the Bool/String ambiguous field
{Path: "order-Id", Type: Float64}, // hyphen in key, Float64 variant
{Path: "order-Id", Type: Int64}, // Int64 variant
// root-level with special characters
{Path: "http-status", Type: Int64},
{Path: "http-status", Type: String},
{Path: "_internal", Type: String},
{Path: "@version", Type: String},
{Path: "@version", Type: Int64},
// root-level nested objects (no array hops)
{Path: "service.name", Type: String},
{Path: "service.version", Type: String},
{Path: "service.version", Type: Int64},
{Path: "service.weight", Type: Float64},
{Path: "response.code", Type: Int64},
{Path: "response.code", Type: String},
{Path: "response.message", Type: String},
{Path: "response.time-taken", Type: Float64},
// user primitives — all IndexSupported types
{Path: "user.name", Type: String},
{Path: "user.age", Type: Int64},
{Path: "user.age", Type: String},
{Path: "user.height", Type: Float64},
{Path: "user.score", Type: Float64},
{Path: "user.score", Type: Int64},
// user.address — deeper non-array nesting
{Path: "user.address.city", Type: String},
{Path: "user.address.zip", Type: Int64},
{Path: "user.address.country", Type: String},
{Path: "user.address.country", Type: Int64},
}