Compare commits

..

7 Commits

Author SHA1 Message Date
Vinícius Lourenço
2314c6ab6c test(hosts-list): missing nuqs adapter 2026-03-26 15:01:43 -03:00
Vinícius Lourenço
64dab58fad fix(css): missing prettify the new css module 2026-03-26 13:51:10 -03:00
Vinícius Lourenço
d95a2264d6 fix(host-metrics-detail): not persisting the url query param on URL after f5 2026-03-26 12:12:32 -03:00
Vinícius Lourenço
a63e3408d6 fix(hosts-lists): selectedHostname was causing list query to fail to refresh when f5 2026-03-26 12:09:04 -03:00
Vinícius Lourenço
558de5a950 refactor(host-metric-logs): use css modules 2026-03-26 11:48:01 -03:00
Vinícius Lourenço
7f9ad2ac0a test(host-metrics-logs): add tests for new query range 2026-03-26 11:42:12 -03:00
Vinícius Lourenço
3cc9ab6265 feat(infra-monitoring-hosts): add support for query range v5 on logs tab 2026-03-25 19:35:35 -03:00
52 changed files with 2071 additions and 1725 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.1
image: signoz/signoz:v0.117.0
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.1
image: signoz/signoz:v0.117.0
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.1}
image: signoz/signoz:${VERSION:-v0.117.0}
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.1}
image: signoz/signoz:${VERSION:-v0.117.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -116,12 +116,7 @@ describe.each([
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
if (dataSource === DataSource.TRACES) {
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
} else {
expect(screen.getByText('Columns')).toBeInTheDocument();
}
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('allows changing export format', () => {
@@ -151,17 +146,6 @@ 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));
@@ -226,12 +210,7 @@ describe.each([
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
// 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.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
@@ -248,11 +227,6 @@ 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,6 +1,5 @@
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';
@@ -15,12 +14,10 @@ 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);
@@ -38,19 +35,9 @@ export default function DownloadOptionsMenu({
await handleExportRawData({
format: exportFormat,
rowLimit,
clearSelectColumns:
dataSource !== DataSource.TRACES &&
columnsScope === DownloadColumnsScopes.ALL,
selectedColumns,
clearSelectColumns: columnsScope === DownloadColumnsScopes.ALL,
});
}, [
exportFormat,
rowLimit,
columnsScope,
selectedColumns,
handleExportRawData,
dataSource,
]);
}, [exportFormat, rowLimit, columnsScope, handleExportRawData]);
const popoverContent = useMemo(
() => (
@@ -85,22 +72,18 @@ export default function DownloadOptionsMenu({
</Radio.Group>
</div>
{dataSource !== DataSource.TRACES && (
<>
<div className="horizontal-line" />
<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"
@@ -114,14 +97,7 @@ export default function DownloadOptionsMenu({
</Button>
</div>
),
[
exportFormat,
rowLimit,
columnsScope,
isDownloading,
handleExport,
dataSource,
],
[exportFormat, rowLimit, columnsScope, isDownloading, handleExport],
);
return (

View File

@@ -39,6 +39,7 @@ import {
ScrollText,
X,
} from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
@@ -52,15 +53,18 @@ 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 HostMetricLogsDetailedView from './HostMetricsLogs/HostMetricLogsDetailedView';
import { HOST_METRICS_LOGS_EXPR_QUERY_KEY } from './HostMetricsLogs/constants';
import HostMetricsLogs from './HostMetricsLogs/HostMetricsLogs';
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,
@@ -129,10 +133,6 @@ function HostMetricsDetails({
};
}, [host?.hostName, searchParams]);
const [logFilters, setLogFilters] = useState<IBuilderQuery['filters']>(
initialFilters,
);
const [tracesFilters, setTracesFilters] = useState<IBuilderQuery['filters']>(
initialFilters,
);
@@ -147,7 +147,6 @@ function HostMetricsDetails({
}, [host]);
useEffect(() => {
setLogFilters(initialFilters);
setTracesFilters(initialFilters);
}, [initialFilters]);
@@ -172,7 +171,6 @@ 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),
});
}
@@ -210,48 +208,30 @@ function HostMetricsDetails({
[],
);
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 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],
);
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 [hostMetricLogsExpr] = useQueryState(
HOST_METRICS_LOGS_EXPR_QUERY_KEY,
parseAsString,
);
const handleChangeTracesFilters = useCallback(
@@ -308,11 +288,6 @@ function HostMetricsDetails({
});
if (selectedView === VIEW_TYPES.LOGS) {
const filtersWithoutPagination = {
...logFilters,
items: logFilters?.items?.filter((item) => item.key?.key !== 'id') || [],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
@@ -322,7 +297,11 @@ function HostMetricsDetails({
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: filtersWithoutPagination,
filter: { expression: hostMetricLogsExpr },
expression: hostMetricLogsExpr,
having: {
expression: '',
},
},
],
},
@@ -564,12 +543,11 @@ function HostMetricsDetails({
/>
)}
{selectedView === VIEW_TYPES.LOGS && (
<HostMetricLogsDetailedView
<HostMetricsLogs
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logFilters}
initialExpression={initialLogsExpression}
selectedInterval={selectedInterval}
/>
)}

View File

@@ -0,0 +1,64 @@
.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

@@ -1,133 +0,0 @@
.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

@@ -1,100 +0,0 @@
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,88 +1,161 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery } from 'react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 { DEFAULT_ENTITY_VERSION } from 'constants/app';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { InfraMonitoringEvents } from 'constants/events';
import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import useDebounce from 'hooks/useDebounce';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { parseAsString, useQueryState } from 'nuqs';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import { getHostLogsQueryPayload } from './constants';
import {
getHostLogsQueryPayload,
HOST_METRICS_LOGS_EXPR_QUERY_KEY,
} from './constants';
import { useInfiniteHostMetricLogs } from './hooks';
import NoLogsContainer from './NoLogsContainer';
import './HostMetricLogs.styles.scss';
import styles from './HostMetricLogs.module.scss';
interface Props {
initialExpression: string;
timeRange: {
startTime: number;
endTime: number;
};
filters: IBuilderQuery['filters'];
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
}
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
const EXPRESSION_DEBOUNCE_TIME_MS = 300;
function HostMetricsLogs({
initialExpression,
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
}: 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 basePayload = getHostLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
filters,
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 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,
queryPayload,
} = useHandleLogsPagination({
timeRange,
filters,
excludeFilterKeys: ['host.name'],
basePayload,
hasNextPage,
isFetchingNextPage,
isLoading,
isFetching,
isError,
} = useInfiniteHostMetricLogs({
expression: debouncedFilterExpression,
startTime: timeRange.startTime,
endTime: timeRange.endTime,
});
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,
@@ -122,22 +195,21 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
const renderFooter = useCallback(
(): JSX.Element | null => (
<>
{isFetching ? (
<div className="logs-loading-skeleton"> Loading more logs ... </div>
) : hasReachedEndOfLogs ? (
<div className="logs-loading-skeleton"> *** End *** </div>
{isFetchingNextPage ? (
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
) : !hasNextPage && logs.length > 0 ? (
<div className={styles.logsLoadingSkeleton}> *** End *** </div>
) : null}
</>
),
[isFetching, hasReachedEndOfLogs],
[isFetchingNextPage, hasNextPage, logs.length],
);
const renderContent = useMemo(
() => (
<Card bordered={false} className="host-metrics-logs-list-card">
<Card bordered={false} className={styles.listCard}>
<OverlayScrollbar isVirtuoso>
<Virtuoso
className="host-metrics-logs-virtuoso"
key="host-metrics-logs-virtuoso"
ref={virtuosoRef}
data={logs}
@@ -155,32 +227,55 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
[logs, loadMoreLogs, getItemContent, renderFooter],
);
const showInitialLoading = isLoading || (isFetching && logs.length === 0);
return (
<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 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>
</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>
</>
);
}

View File

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

View File

@@ -0,0 +1,975 @@
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

@@ -0,0 +1,311 @@
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,4 +1,5 @@
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';
@@ -6,56 +7,82 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';
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,
},
export interface HostLogsQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
export const getHostLogsQueryPayload = ({
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

@@ -0,0 +1,93 @@
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

@@ -202,8 +202,19 @@ function InviteMembersModal({
onComplete?.();
} catch (err) {
const apiErr = err as APIError;
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(errorMessage, { richColors: true });
if (apiErr?.getHttpStatusCode() === 409) {
toast.error(
touchedRows.length === 1
? `${touchedRows[0].email} is already a member`
: 'Invite for one or more users already exists',
{ richColors: true },
);
} else {
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(`Failed to send invites: ${errorMessage}`, {
richColors: true,
});
}
} finally {
setIsSubmitting(false);
}

View File

@@ -1,18 +1,9 @@
import { toast } from '@signozhq/sonner';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { StatusCodes } from 'http-status-codes';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error';
import InviteMembersModal from '../InviteMembersModal';
const makeApiError = (message: string, code = StatusCodes.CONFLICT): APIError =>
new APIError({
httpStatusCode: code,
error: { code: 'already_exists', message, url: '', errors: [] },
});
jest.mock('api/v1/invite/create');
jest.mock('api/v1/invite/bulk/create');
jest.mock('@signozhq/sonner', () => ({
@@ -151,90 +142,6 @@ describe('InviteMembersModal', () => {
});
});
describe('error handling', () => {
it('shows BE message on single invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockSendInvite.mockRejectedValue(
makeApiError('An invite already exists for this email: single@signoz.io'),
);
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'single@signoz.io',
);
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: single@signoz.io',
expect.anything(),
);
});
});
it('shows BE message on bulk invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockInviteUsers.mockRejectedValue(
makeApiError('An invite already exists for this email: alice@signoz.io'),
);
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: alice@signoz.io',
expect.anything(),
);
});
});
it('shows BE message on generic error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockSendInvite.mockRejectedValue(
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
);
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'single@signoz.io',
);
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'Internal server error',
expect.anything(),
);
});
});
});
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onComplete = jest.fn();

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

@@ -464,10 +464,14 @@ function GeneralSettings({
onModalToggleHandler(type);
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const {
isCloudUser: isCloudUserVal,
isEnterpriseSelfHostedUser,
} = useGetTenantLicense();
const isAdmin = user.role === USER_ROLES.ADMIN;
const showCustomDomainSettings = isCloudUserVal && isAdmin;
const showCustomDomainSettings =
(isCloudUserVal || isEnterpriseSelfHostedUser) && isAdmin;
const renderConfig = [
{

View File

@@ -38,6 +38,7 @@ jest.mock('hooks/useComponentPermission', () => ({
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(() => ({
isCloudUser: false,
isEnterpriseSelfHostedUser: false,
})),
}));
@@ -388,6 +389,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
beforeEach(() => {
(useGetTenantLicense as jest.Mock).mockReturnValue({
isCloudUser: true,
isEnterpriseSelfHostedUser: false,
});
});
@@ -409,14 +411,15 @@ describe('GeneralSettings - S3 Logs Retention', () => {
});
});
describe('Non-cloud user rendering', () => {
describe('Enterprise Self-Hosted User Rendering', () => {
beforeEach(() => {
(useGetTenantLicense as jest.Mock).mockReturnValue({
isCloudUser: false,
isEnterpriseSelfHostedUser: true,
});
});
it('should not render CustomDomainSettings or GeneralSettingsCloud', () => {
it('should render CustomDomainSettings but not GeneralSettingsCloud', () => {
render(
<GeneralSettings
metricsTtlValuesPayload={mockMetricsRetention}
@@ -429,14 +432,12 @@ describe('GeneralSettings - S3 Logs Retention', () => {
/>,
);
expect(
screen.queryByTestId('custom-domain-settings'),
).not.toBeInTheDocument();
expect(screen.getByTestId('custom-domain-settings')).toBeInTheDocument();
expect(
screen.queryByTestId('general-settings-cloud'),
).not.toBeInTheDocument();
// Save buttons should be visible for non-cloud users (these are from retentions)
// Save buttons should be visible for self-hosted
const saveButtons = screen.getAllByRole('button', { name: /save/i });
expect(saveButtons.length).toBeGreaterThan(0);
});

View File

@@ -1,65 +0,0 @@
import APIError from 'types/api/error';
import { errorDetails } from '../utils';
function makeAPIError(
message: string,
code = 'SOME_CODE',
errors: { message: string }[] = [],
): APIError {
return new APIError({
httpStatusCode: 500,
error: { code, message, url: '', errors },
});
}
describe('errorDetails', () => {
describe('when passed an APIError', () => {
it('returns the error message', () => {
const error = makeAPIError('something went wrong');
expect(errorDetails(error)).toBe('something went wrong');
});
it('appends details when errors array is non-empty', () => {
const error = makeAPIError('query failed', 'QUERY_ERROR', [
{ message: 'field X is invalid' },
{ message: 'field Y is missing' },
]);
const result = errorDetails(error);
expect(result).toContain('query failed');
expect(result).toContain('field X is invalid');
expect(result).toContain('field Y is missing');
});
it('does not append details when errors array is empty', () => {
const error = makeAPIError('simple error', 'CODE', []);
const result = errorDetails(error);
expect(result).toBe('simple error');
expect(result).not.toContain('Details');
});
});
describe('when passed a plain Error (not an APIError)', () => {
it('does not throw', () => {
const error = new Error('timeout exceeded');
expect(() => errorDetails(error)).not.toThrow();
});
it('returns the plain error message', () => {
const error = new Error('timeout exceeded');
expect(errorDetails(error)).toBe('timeout exceeded');
});
it('returns fallback when plain Error has no message', () => {
const error = new Error('');
expect(errorDetails(error)).toBe('Unknown error occurred');
});
});
describe('fallback behaviour', () => {
it('returns "Unknown error occurred" when message is undefined', () => {
const error = makeAPIError('');
expect(errorDetails(error)).toBe('Unknown error occurred');
});
});
});

View File

@@ -249,14 +249,13 @@ export const handleGraphClick = async ({
}
};
export const errorDetails = (error: APIError | Error): string => {
const { message, errors } =
(error instanceof APIError ? error.getErrorDetails()?.error : null) || {};
export const errorDetails = (error: APIError): string => {
const { message, errors } = error.getErrorDetails()?.error || {};
const details =
errors && errors.length > 0
errors?.length > 0
? `\n\nDetails: ${errors.map((e) => e.message).join('\n')}`
: '';
const errorDetails = `${message ?? error.message} ${details}`;
return errorDetails.trim() || 'Unknown error occurred';
const errorDetails = `${message} ${details}`;
return errorDetails || 'Unknown error occurred';
};

View File

@@ -48,7 +48,6 @@ import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import GridCard from './GridCard';
import { Card, CardContainer, ReactGridLayout } from './styles';
import {
applyRowCollapse,
hasColumnWidthsChanged,
removeUndefinedValuesFromLayout,
} from './utils';
@@ -269,10 +268,13 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
currentWidget.title = newTitle;
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
(e) => e.id !== currentSelectRowId,
);
updatedWidgets?.push(currentWidget);
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
data: {
@@ -314,13 +316,88 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
if (!selectedDashboard) {
return;
}
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
id,
dashboardLayout,
currentPanelMap,
);
setCurrentPanelMap((prev) => ({ ...prev, ...updatedPanelMap }));
setDashboardLayout(sortLayout(updatedLayout));
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 handleDragStop: ItemCallback = (_, oldItem, newItem): void => {

View File

@@ -1,181 +0,0 @@
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,122 +4,6 @@ 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: filters?.items?.length ? filters : undefined,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy,
@@ -97,15 +97,6 @@ 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),
@@ -115,15 +106,7 @@ function HostsList(): JSX.Element {
String(minTime),
String(maxTime),
];
}, [
pageSize,
currentPage,
filters,
orderBy,
selectedHostName,
minTime,
maxTime,
]);
}, [pageSize, currentPage, filters, orderBy, minTime, maxTime]);
const { data, isFetching, isLoading, isError } = useGetHostList(
query as HostListPayload,

View File

@@ -4,6 +4,7 @@ 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';
@@ -130,26 +131,30 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
describe('HostsList', () => {
it('renders hosts list table', () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>,
<NuqsTestingAdapter>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</NuqsTestingAdapter>,
);
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
});
it('renders filters', () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>,
<NuqsTestingAdapter>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</NuqsTestingAdapter>,
);
expect(container.querySelector('.filters')).toBeInTheDocument();
});

View File

@@ -217,7 +217,7 @@ function K8sVolumesList({
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
K8sCategory.VOLUMES,
K8sCategory.NODES,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
@@ -228,7 +228,7 @@ function K8sVolumesList({
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true,
K8sCategory.VOLUMES,
K8sCategory.NODES,
);
const query = useMemo(() => {
@@ -597,7 +597,7 @@ function K8sVolumesList({
isLoadingGroupByFilters={isLoadingGroupByFilters}
handleGroupByChange={handleGroupByChange}
selectedGroupBy={groupBy}
entity={K8sCategory.VOLUMES}
entity={K8sCategory.NODES}
showAutoRefresh={!selectedVolumeData}
/>
{isError && <Typography>{data?.error || 'Something went wrong'}</Typography>}

View File

@@ -1,162 +0,0 @@
import setupCommonMocks from '../commonMocks';
setupCommonMocks();
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import K8sVolumesList from 'container/InfraMonitoringK8s/Volumes/K8sVolumesList';
import { rest, server } from 'mocks-server/server';
import { IAppContext, IUser } from 'providers/App/types';
import store from 'store';
import { LicenseResModel } from 'types/api/licensesV3/getActive';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const SERVER_URL = 'http://localhost/api';
describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
let requestsMade: Array<{
url: string;
params: URLSearchParams;
body?: any;
}> = [];
beforeEach(() => {
requestsMade = [];
queryClient.clear();
server.use(
rest.get(`${SERVER_URL}/v3/autocomplete/attribute_keys`, (req, res, ctx) => {
const url = req.url.toString();
const params = req.url.searchParams;
requestsMade.push({
url,
params,
});
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
attributeKeys: [],
},
}),
);
}),
rest.post(`${SERVER_URL}/v1/pvcs/list`, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
type: 'list',
records: [],
groups: null,
total: 0,
sentAnyHostMetricsData: false,
isSendingK8SAgentMetrics: false,
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('should call aggregate keys API with k8s_volume_capacity', async () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<K8sVolumesList
isFiltersVisible={false}
handleFilterVisibilityChange={jest.fn()}
quickFiltersLastUpdated={-1}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>,
);
await waitFor(() => {
expect(requestsMade.length).toBeGreaterThan(0);
});
// Find the attribute_keys request
const attributeKeysRequest = requestsMade.find((req) =>
req.url.includes('/autocomplete/attribute_keys'),
);
expect(attributeKeysRequest).toBeDefined();
const aggregateAttribute = attributeKeysRequest?.params.get(
'aggregateAttribute',
);
expect(aggregateAttribute).toBe('k8s_volume_capacity');
});
it('should call aggregate keys API with k8s.volume.capacity when dotMetrics enabled', async () => {
jest
.spyOn(await import('providers/App/App'), 'useAppContext')
.mockReturnValue({
featureFlags: [
{
name: FeatureKeys.DOT_METRICS_ENABLED,
active: true,
usage: 0,
usage_limit: 0,
route: '',
},
],
user: { role: 'ADMIN' } as IUser,
activeLicense: (null as unknown) as LicenseResModel,
} as IAppContext);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<K8sVolumesList
isFiltersVisible={false}
handleFilterVisibilityChange={jest.fn()}
quickFiltersLastUpdated={-1}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>,
);
await waitFor(() => {
expect(requestsMade.length).toBeGreaterThan(0);
});
const attributeKeysRequest = requestsMade.find((req) =>
req.url.includes('/autocomplete/attribute_keys'),
);
expect(attributeKeysRequest).toBeDefined();
const aggregateAttribute = attributeKeysRequest?.params.get(
'aggregateAttribute',
);
expect(aggregateAttribute).toBe('k8s.volume.capacity');
});
});

View File

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

View File

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

View File

@@ -42,19 +42,11 @@ 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: updatedQueryForList,
query: updatedQuery,
graphType: PANEL_TYPES.LIST,
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
tableParams: {

View File

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

View File

@@ -137,7 +137,7 @@ describe('useIsPanelWaitingOnVariable', () => {
expect(result.current).toBe(false);
});
it('should return false for DYNAMIC variable with allSelected=true that is loading but has a selectedValue', () => {
it('should return true for DYNAMIC variable with allSelected=true that is loading', () => {
setFetchStates({ dyn: 'loading' });
setDashboardVariables({
variables: {
@@ -152,10 +152,10 @@ describe('useIsPanelWaitingOnVariable', () => {
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
expect(result.current).toBe(true);
});
it('should return false for DYNAMIC variable with allSelected=true that is waiting but has a selectedValue', () => {
it('should return true for DYNAMIC variable with allSelected=true that is waiting', () => {
setFetchStates({ dyn: 'waiting' });
setDashboardVariables({
variables: {
@@ -170,7 +170,7 @@ describe('useIsPanelWaitingOnVariable', () => {
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
expect(result.current).toBe(true);
});
it('should return false for DYNAMIC variable with allSelected=true that is idle', () => {
@@ -313,39 +313,4 @@ describe('useIsPanelWaitingOnVariable', () => {
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should find variable by name when store key differs from variable name', () => {
setFetchStates({ myVar: 'loading' });
setDashboardVariables({
variables: {
'uuid-abc-123': makeVariable({
id: 'uuid-abc-123',
name: 'myVar',
selectedValue: undefined,
}),
},
variableTypes: { myVar: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
expect(result.current).toBe(true);
});
it('should respect selectedValue when store key differs from variable name', () => {
// When the variable has a value, it should not block even if loading
setFetchStates({ myVar: 'loading' });
setDashboardVariables({
variables: {
'uuid-abc-123': makeVariable({
id: 'uuid-abc-123',
name: 'myVar',
selectedValue: 'production',
}),
},
variableTypes: { myVar: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
expect(result.current).toBe(false);
});
});

View File

@@ -226,41 +226,6 @@ describe('useVariablesFromUrl', () => {
expect(urlVariables.undefinedVar).toBeUndefined();
});
it('should return empty object when URL param contains a bare % that makes decodeURIComponent throw', () => {
// Simulate a URL where the variables param is a raw JSON string containing a literal %
// (e.g. a metric value like "cpu_usage_50%"). URLSearchParams.get() returns the value
// as-is when it was set directly; if it contains a bare %, decodeURIComponent throws a URIError.
const rawJson = JSON.stringify({ threshold: '50%' });
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=${rawJson}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
// Should parse successfully via the raw fallback, not throw or return {}
const vars = result.current.getUrlVariables();
expect(vars).toEqual({ threshold: '50%' });
});
it('should return empty object when URL param is completely unparseable', () => {
// A value that fails both decodeURIComponent and JSON.parse
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=not-json-%ZZ`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
expect(result.current.getUrlVariables()).toEqual({});
});
it('should update variables with array values correctly', () => {
const history = createMemoryHistory({
initialEntries: ['/'],

View File

@@ -133,19 +133,21 @@ export function useVariableFetchState(
export function useIsPanelWaitingOnVariable(variableNames: string[]): boolean {
const states = useVariableFetchSelector((s) => s.states);
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
const variableTypesMap = useDashboardVariablesSelector((s) => s.variableTypes);
return variableNames.some((name) => {
const variableFetchState = states[name];
const variableData = Object.values(dashboardVariables).find(
(v) => v.name === name,
);
const { selectedValue } = variableData || {};
const { selectedValue, allSelected } = dashboardVariables?.[name] || {};
const isVariableInFetchingOrWaitingState =
variableFetchState === 'loading' ||
variableFetchState === 'revalidating' ||
variableFetchState === 'waiting';
if (variableTypesMap[name] === 'DYNAMIC' && allSelected) {
return isVariableInFetchingOrWaitingState;
}
return isEmpty(selectedValue) ? isVariableInFetchingOrWaitingState : false;
});
}

View File

@@ -32,14 +32,7 @@ const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
}
try {
const decoded = ((): string => {
try {
return decodeURIComponent(variablesParam);
} catch {
return variablesParam;
}
})();
return JSON.parse(decoded);
return JSON.parse(decodeURIComponent(variablesParam));
} catch (error) {
Sentry.captureEvent({
message: `Failed to parse dashboard variables from URL: ${error}`,

View File

@@ -52,44 +52,37 @@ export const useGetQueryRange: UseGetQueryRange = (
!firstQueryData?.filters?.items.some((filter) => filter.key?.key === 'id') &&
firstQueryData?.orderBy[0].columnName === 'timestamp';
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 {
const modifiedRequestData = {
...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, TelemetryFieldKey } from 'api/v5/v5';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
@@ -14,7 +14,6 @@ interface ExportOptions {
format: string;
rowLimit: number;
clearSelectColumns: boolean;
selectedColumns?: TelemetryFieldKey[];
}
interface UseExportRawDataProps {
@@ -43,7 +42,6 @@ export function useExportRawData({
format,
rowLimit,
clearSelectColumns,
selectedColumns,
}: ExportOptions): Promise<void> => {
if (!stagedQuery) {
return;
@@ -52,12 +50,6 @@ export function useExportRawData({
try {
setIsDownloading(true);
const selectColumnsOverride = clearSelectColumns
? {}
: selectedColumns?.length
? { selectColumns: selectedColumns }
: {};
const exportQuery = {
...stagedQuery,
builder: {
@@ -67,7 +59,7 @@ export function useExportRawData({
groupBy: [],
having: { expression: '' },
limit: rowLimit,
...selectColumnsOverride,
...(clearSelectColumns && { selectColumns: [] }),
})),
queryTraceOperator: (stagedQuery.builder.queryTraceOperator || []).map(
(traceOp) => ({
@@ -75,7 +67,7 @@ export function useExportRawData({
groupBy: [],
having: { expression: '' },
limit: rowLimit,
...selectColumnsOverride,
...(clearSelectColumns && { selectColumns: [] }),
}),
),
},

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import * as Sentry from '@sentry/react';
import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon';
import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon';
import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon';
@@ -7,50 +5,34 @@ import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon';
import './AlertSeverity.styles.scss';
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
critical: {
text: 'Critical',
className: 'alert-severity--critical',
icon: <SeverityCriticalIcon />,
},
error: {
text: 'Error',
className: 'alert-severity--error',
icon: <SeverityErrorIcon />,
},
warning: {
text: 'Warning',
className: 'alert-severity--warning',
icon: <SeverityWarningIcon />,
},
info: {
text: 'Info',
className: 'alert-severity--info',
icon: <SeverityInfoIcon />,
},
};
export default function AlertSeverity({
severity,
}: {
severity: string;
}): JSX.Element {
const severityDetails = useMemo(() => {
if (severityConfig[severity]) {
return severityConfig[severity];
}
Sentry.captureEvent({
message: `Received unknown severity on Alert Details: ${severity}`,
level: 'error',
});
return {
text: severity,
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
critical: {
text: 'Critical',
className: 'alert-severity--critical',
icon: <SeverityCriticalIcon />,
},
error: {
text: 'Error',
className: 'alert-severity--error',
icon: <SeverityErrorIcon />,
},
warning: {
text: 'Warning',
className: 'alert-severity--warning',
icon: <SeverityWarningIcon />,
},
info: {
text: 'Info',
className: 'alert-severity--info',
icon: <SeverityInfoIcon />,
};
}, [severity]);
},
};
const severityDetails = severityConfig[severity];
return (
<div className={`alert-severity ${severityDetails.className}`}>
<div className="alert-severity__icon">{severityDetails.icon}</div>

View File

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

View File

@@ -112,12 +112,6 @@ 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

@@ -1090,21 +1090,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
}
processingPostCache := time.Now()
// When req.Limit is 0 (not set by the client), selectAllSpans is set to false
// preserving the old paged behaviour for backward compatibility
limit := min(req.Limit, tracedetail.MaxLimitToSelectAllSpans)
selectAllSpans := totalSpans <= uint64(limit)
var (
selectedSpans []*model.Span
uncollapsedSpans []string
rootServiceName, rootServiceEntryPoint string
)
if selectAllSpans {
selectedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetAllSpans(traceRoots)
} else {
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetSelectedSpans(req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIdToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed)
}
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint := tracedetail.GetSelectedSpans(req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIdToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed)
r.logger.Info("getWaterfallSpansForTraceWithMetadata: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID)
// convert start timestamp to millis because right now frontend is expecting it in millis
@@ -1117,7 +1103,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
}
response.Spans = selectedSpans
response.UncollapsedSpans = uncollapsedSpans // ignoring if all spans are returning
response.UncollapsedSpans = uncollapsedSpans
response.StartTimestampMillis = startTime / 1000000
response.EndTimestampMillis = endTime / 1000000
response.TotalSpansCount = totalSpans
@@ -1126,7 +1112,6 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
response.RootServiceEntryPoint = rootServiceEntryPoint
response.ServiceNameToTotalDurationMap = serviceNameToTotalDurationMap
response.HasMissingSpans = hasMissingSpans
response.HasMore = !selectAllSpans
return response, nil
}
@@ -3386,8 +3371,8 @@ func (r *ClickHouseReader) GetMetricAggregateAttributes(ctx context.Context, org
// Query all relevant metric names from time_series_v4, but leave metadata retrieval to cache/db
query := fmt.Sprintf(
`SELECT DISTINCT metric_name
FROM %s.%s
`SELECT DISTINCT metric_name
FROM %s.%s
WHERE metric_name ILIKE $1 AND __normalized = $2`,
signozMetricDBName, signozTSTableNameV41Day)
@@ -3464,8 +3449,8 @@ func (r *ClickHouseReader) GetMeterAggregateAttributes(ctx context.Context, orgI
var response v3.AggregateAttributeResponse
// Query all relevant metric names from time_series_v4, but leave metadata retrieval to cache/db
query := fmt.Sprintf(
`SELECT metric_name,type,temporality,is_monotonic
FROM %s.%s
`SELECT metric_name,type,temporality,is_monotonic
FROM %s.%s
WHERE metric_name ILIKE $1
GROUP BY metric_name,type,temporality,is_monotonic`,
signozMeterDBName, signozMeterSamplesName)
@@ -5161,7 +5146,7 @@ func (r *ClickHouseReader) GetOverallStateTransitions(ctx context.Context, ruleI
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5172,7 +5157,7 @@ resolution_events AS (
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5293,7 +5278,7 @@ WITH firing_events AS (
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5304,7 +5289,7 @@ resolution_events AS (
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5350,7 +5335,7 @@ WITH firing_events AS (
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5361,7 +5346,7 @@ resolution_events AS (
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5623,7 +5608,7 @@ func (r *ClickHouseReader) GetMetricsDataPoints(ctx context.Context, metricName
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetMetricsDataPoints",
})
query := fmt.Sprintf(`SELECT
query := fmt.Sprintf(`SELECT
sum(count) as data_points
FROM %s.%s
WHERE metric_name = ?
@@ -5643,7 +5628,7 @@ func (r *ClickHouseReader) GetMetricsLastReceived(ctx context.Context, metricNam
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetMetricsLastReceived",
})
query := fmt.Sprintf(`SELECT
query := fmt.Sprintf(`SELECT
MAX(unix_milli) AS last_received_time
FROM %s.%s
WHERE metric_name = ?
@@ -5654,7 +5639,7 @@ WHERE metric_name = ?
if err != nil {
return 0, &model.ApiError{Typ: "ClickHouseError", Err: err}
}
query = fmt.Sprintf(`SELECT
query = fmt.Sprintf(`SELECT
MAX(unix_milli) AS last_received_time
FROM %s.%s
WHERE metric_name = ? and unix_milli > ?
@@ -5673,7 +5658,7 @@ func (r *ClickHouseReader) GetTotalTimeSeriesForMetricName(ctx context.Context,
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetTotalTimeSeriesForMetricName",
})
query := fmt.Sprintf(`SELECT
query := fmt.Sprintf(`SELECT
uniq(fingerprint) AS timeSeriesCount
FROM %s.%s
WHERE metric_name = ?;`, signozMetricDBName, signozTSTableNameV41Week)
@@ -5705,7 +5690,7 @@ func (r *ClickHouseReader) GetAttributesForMetricName(ctx context.Context, metri
}
const baseQueryTemplate = `
SELECT
SELECT
kv.1 AS key,
arrayMap(x -> trim(BOTH '"' FROM x), groupUniqArray(1000)(kv.2)) AS values,
length(groupUniqArray(10000)(kv.2)) AS valueCount
@@ -5814,7 +5799,7 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.
sampleTable, countExp := utils.WhichSampleTableToUse(req.Start, req.End)
metricsQuery := fmt.Sprintf(
`SELECT
`SELECT
t.metric_name AS metric_name,
ANY_VALUE(t.description) AS description,
ANY_VALUE(t.type) AS metric_type,
@@ -5879,11 +5864,11 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.
if whereClause != "" {
sb.WriteString(fmt.Sprintf(
`SELECT
`SELECT
s.samples,
s.metric_name
FROM (
SELECT
SELECT
dm.metric_name,
%s AS samples
FROM %s.%s AS dm
@@ -5913,11 +5898,11 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.
} else {
// If no filters, it is a simpler query.
sb.WriteString(fmt.Sprintf(
`SELECT
`SELECT
s.samples,
s.metric_name
FROM (
SELECT
SELECT
metric_name,
%s AS samples
FROM %s.%s
@@ -6022,16 +6007,16 @@ func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, r
// Construct the query without backticks
query := fmt.Sprintf(`
SELECT
SELECT
metric_name,
total_value,
(total_value * 100.0 / total_time_series) AS percentage
FROM (
SELECT
SELECT
metric_name,
uniq(fingerprint) AS total_value,
(SELECT uniq(fingerprint)
FROM %s.%s
(SELECT uniq(fingerprint)
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ? AND __normalized = ?) AS total_time_series
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? %s
@@ -6107,7 +6092,7 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
queryLimit := 50 + req.Limit
metricsQuery := fmt.Sprintf(
`SELECT
`SELECT
ts.metric_name AS metric_name,
uniq(ts.fingerprint) AS timeSeries
FROM %s.%s AS ts
@@ -6164,13 +6149,13 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ?
)
SELECT
SELECT
s.samples,
s.metric_name,
COALESCE((s.samples * 100.0 / t.total_samples), 0) AS percentage
FROM
FROM
(
SELECT
SELECT
dm.metric_name,
%s AS samples
FROM %s.%s AS dm`,
@@ -6191,7 +6176,7 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
if whereClause != "" {
sb.WriteString(fmt.Sprintf(
` AND dm.fingerprint IN (
SELECT ts.fingerprint
SELECT ts.fingerprint
FROM %s.%s AS ts
WHERE ts.metric_name IN (%s)
AND unix_milli BETWEEN ? AND ?
@@ -6262,7 +6247,7 @@ func (r *ClickHouseReader) GetNameSimilarity(ctx context.Context, req *metrics_e
}
query := fmt.Sprintf(`
SELECT
SELECT
metric_name,
any(type) as type,
any(temporality) as temporality,
@@ -6321,7 +6306,7 @@ func (r *ClickHouseReader) GetAttributeSimilarity(ctx context.Context, req *metr
// Get target labels
extractedLabelsQuery := fmt.Sprintf(`
SELECT
SELECT
kv.1 AS label_key,
topK(10)(JSONExtractString(kv.2)) AS label_values
FROM %s.%s
@@ -6365,12 +6350,12 @@ func (r *ClickHouseReader) GetAttributeSimilarity(ctx context.Context, req *metr
priorityListString := strings.Join(priorityList, ", ")
candidateLabelsQuery := fmt.Sprintf(`
WITH
arrayDistinct([%s]) AS filter_keys,
WITH
arrayDistinct([%s]) AS filter_keys,
arrayDistinct([%s]) AS filter_values,
[%s] AS priority_pairs_input,
%d AS priority_multiplier
SELECT
SELECT
metric_name,
any(type) as type,
any(temporality) as temporality,
@@ -6476,17 +6461,17 @@ func (r *ClickHouseReader) GetMetricsAllResourceAttributes(ctx context.Context,
instrumentationtypes.CodeFunctionName: "GetMetricsAllResourceAttributes",
})
start, end, attTable, _ := utils.WhichAttributesTableToUse(start, end)
query := fmt.Sprintf(`SELECT
key,
query := fmt.Sprintf(`SELECT
key,
count(distinct value) AS distinct_value_count
FROM (
SELECT key, value
FROM %s.%s
ARRAY JOIN
ARRAY JOIN
arrayConcat(mapKeys(resource_attributes)) AS key,
arrayConcat(mapValues(resource_attributes)) AS value
WHERE unix_milli between ? and ?
)
)
GROUP BY key
ORDER BY distinct_value_count DESC;`, signozMetadataDbName, attTable)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
@@ -6633,11 +6618,11 @@ func (r *ClickHouseReader) GetInspectMetricsFingerprints(ctx context.Context, at
start, end, tsTable, _ := utils.WhichTSTableToUse(req.Start, req.End)
query := fmt.Sprintf(`
SELECT
SELECT
arrayDistinct(groupArray(toString(fingerprint))) AS fingerprints
FROM
(
SELECT
SELECT
metric_name, labels, fingerprint,
%s
FROM %s.%s
@@ -6808,14 +6793,14 @@ func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID
var stillMissing []string
if len(missingMetrics) > 0 {
metricList := "'" + strings.Join(missingMetrics, "', '") + "'"
query := fmt.Sprintf(`SELECT
query := fmt.Sprintf(`SELECT
metric_name,
argMax(type, created_at) AS type,
argMax(description, created_at) AS description,
argMax(temporality, created_at) AS temporality,
argMax(is_monotonic, created_at) AS is_monotonic,
argMax(unit, created_at) AS unit
FROM %s.%s
FROM %s.%s
WHERE metric_name IN (%s)
GROUP BY metric_name;`,
signozMetricDBName,
@@ -6863,7 +6848,7 @@ func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID
if len(stillMissing) > 0 {
metricList := "'" + strings.Join(stillMissing, "', '") + "'"
query := fmt.Sprintf(`SELECT DISTINCT metric_name, type, description, temporality, is_monotonic, unit
FROM %s.%s
FROM %s.%s
WHERE metric_name IN (%s)`, signozMetricDBName, signozTSTableNameV4, metricList)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
rows, err := r.db.Query(valueCtx, query)

View File

@@ -1,7 +1,6 @@
package tracedetail
import (
"maps"
"slices"
"sort"
@@ -10,9 +9,6 @@ import (
var (
SPAN_LIMIT_PER_REQUEST_FOR_WATERFALL float64 = 500
maxDepthForSelectedSpanChildren int = 5
MaxLimitToSelectAllSpans uint = 10_000
)
type Interval struct {
@@ -67,22 +63,26 @@ func findIndexForSelectedSpanFromPreOrder(spans []*model.Span, selectedSpanId st
return selectedSpanIndex
}
func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string) (bool, []string) {
func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, uncollapsedSpans []string, isSelectedSpanIDUnCollapsed bool) (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)
isPresentInThisSubtree, _spansFromRootToNode := getPathFromRootToSelectedSpanId(child, selectedSpanId, uncollapsedSpans, isSelectedSpanIDUnCollapsed)
// 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,23 +92,12 @@ func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string) (b
// throughout the recursion. Per-call state (level, isPartOfPreOrder, etc.)
// is passed as direct arguments.
type traverseOpts struct {
uncollapsedSpans map[string]struct{}
selectedSpanID string
isSelectedSpanUncollapsed bool
selectAll bool
uncollapsedSpans []string
selectedSpanID string
}
func traverseTrace(
span *model.Span,
opts traverseOpts,
level uint64,
isPartOfPreOrder bool,
hasSibling bool,
autoExpandDepth int,
) ([]*model.Span, []string) {
func traverseTrace(span *model.Span, opts traverseOpts, level uint64, isPartOfPreOrder bool, hasSibling bool) []*model.Span {
preOrderTraversal := []*model.Span{}
autoExpandedSpans := []string{}
// sort the children to maintain the order across requests
sort.Slice(span.Children, func(i, j int) bool {
@@ -145,36 +134,16 @@ func traverseTrace(
preOrderTraversal = append(preOrderTraversal, &nodeWithoutChildren)
}
remainingAutoExpandDepth := 0
if span.SpanID == opts.selectedSpanID && opts.isSelectedSpanUncollapsed {
remainingAutoExpandDepth = maxDepthForSelectedSpanChildren
} else if autoExpandDepth > 0 {
remainingAutoExpandDepth = autoExpandDepth - 1
}
_, isAlreadyUncollapsed := opts.uncollapsedSpans[span.SpanID]
isAlreadyUncollapsed := slices.Contains(opts.uncollapsedSpans, span.SpanID)
for index, child := range span.Children {
// A child is included in the pre-order output if its parent is uncollapsed
// OR if the child falls within MAX_DEPTH_FOR_SELECTED_SPAN_CHILDREN levels
// below the selected span.
isChildWithinMaxDepth := remainingAutoExpandDepth > 0
childIsPartOfPreOrder := opts.selectAll || (isPartOfPreOrder && (isAlreadyUncollapsed || isChildWithinMaxDepth))
if isPartOfPreOrder && isChildWithinMaxDepth && !isAlreadyUncollapsed {
if !slices.Contains(autoExpandedSpans, span.SpanID) {
autoExpandedSpans = append(autoExpandedSpans, span.SpanID)
}
}
_childTraversal, _autoExpanded := traverseTrace(child, opts, level+1, childIsPartOfPreOrder, index != (len(span.Children)-1), remainingAutoExpandDepth)
_childTraversal := traverseTrace(child, opts, level+1, isPartOfPreOrder && isAlreadyUncollapsed, index != (len(span.Children)-1))
preOrderTraversal = append(preOrderTraversal, _childTraversal...)
autoExpandedSpans = append(autoExpandedSpans, _autoExpanded...)
nodeWithoutChildren.SubTreeNodeCount += child.SubTreeNodeCount + 1
span.SubTreeNodeCount += child.SubTreeNodeCount + 1
}
nodeWithoutChildren.SubTreeNodeCount += 1
return preOrderTraversal, autoExpandedSpans
return preOrderTraversal
}
@@ -200,36 +169,19 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
var preOrderTraversal = make([]*model.Span, 0)
var rootServiceName, rootServiceEntryPoint string
// create a map of uncollapsed spans for quick lookup
uncollapsedSpanMap := make(map[string]struct{})
for _, spanID := range uncollapsedSpans {
uncollapsedSpanMap[spanID] = struct{}{}
}
updatedUncollapsedSpans := uncollapsedSpans
selectedSpanIndex := -1
for _, rootSpanID := range traceRoots {
if rootNode, exists := spanIdToSpanNodeMap[rootSpanID.SpanID]; exists {
present, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID)
if present {
for _, spanID := range spansFromRootToNode {
if selectedSpanID == spanID && !isSelectedSpanIDUnCollapsed {
continue
}
uncollapsedSpanMap[spanID] = struct{}{}
}
}
_, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID, updatedUncollapsedSpans, isSelectedSpanIDUnCollapsed)
updatedUncollapsedSpans = append(updatedUncollapsedSpans, spansFromRootToNode...)
opts := traverseOpts{
uncollapsedSpans: uncollapsedSpanMap,
selectedSpanID: selectedSpanID,
isSelectedSpanUncollapsed: isSelectedSpanIDUnCollapsed,
}
_preOrderTraversal, _autoExpanded := traverseTrace(rootNode, opts, 0, true, false, 0)
// Merge auto-expanded spans into updatedUncollapsedSpans for returning in response
for _, spanID := range _autoExpanded {
uncollapsedSpanMap[spanID] = struct{}{}
uncollapsedSpans: updatedUncollapsedSpans,
selectedSpanID: selectedSpanID,
}
_preOrderTraversal := traverseTrace(rootNode, opts, 0, true, false)
_selectedSpanIndex := findIndexForSelectedSpanFromPreOrder(_preOrderTraversal, selectedSpanID)
if _selectedSpanIndex != -1 {
@@ -271,17 +223,5 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
startIndex = 0
}
return preOrderTraversal[startIndex:endIndex], slices.Collect(maps.Keys(uncollapsedSpanMap)), rootServiceName, rootServiceEntryPoint
}
func GetAllSpans(traceRoots []*model.Span) (spans []*model.Span, rootServiceName, rootEntryPoint string) {
if len(traceRoots) > 0 {
rootServiceName = traceRoots[0].ServiceName
rootEntryPoint = traceRoots[0].Name
}
for _, root := range traceRoots {
childSpans, _ := traverseTrace(root, traverseOpts{selectAll: true}, 0, true, false, 0)
spans = append(spans, childSpans...)
}
return
return preOrderTraversal[startIndex:endIndex], updatedUncollapsedSpans, rootServiceName, rootServiceEntryPoint
}

View File

@@ -81,6 +81,28 @@ func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
assert.Equal(t, "root1-op", entryPoint, "metadata comes from first root")
}
// isSelectedSpanIDUnCollapsed=true opens only the selected span's direct children,
// not deeper descendants.
//
// root → selected (expanded)
// ├─ child1 ✓
// │ └─ grandchild ✗ (only one level opened)
// └─ child2 ✓
func TestGetSelectedSpans_ExpandedSelectedSpan(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("selected", "svc",
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
mkSpan("child2", "svc"),
),
)
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, true)
// root and selected are on the auto-uncollapsed path; child1/child2 are direct
// children of the expanded selected span; grandchild stays hidden.
assert.Equal(t, []string{"root", "selected", "child1", "child2"}, spanIDs(spans))
}
// Multiple spans uncollapsed simultaneously: children of all uncollapsed spans
// are visible at once.
//
@@ -161,7 +183,7 @@ func TestGetSelectedSpans_PathReturnedInUncollapsed(t *testing.T) {
spanMap := buildSpanMap(root)
spans, uncollapsed, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, false)
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent", "selected"}, spanIDs(spans))
}
@@ -184,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.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
}
// An unknown selectedSpanID must not panic; returns a window from index 0.
@@ -298,119 +320,6 @@ func TestGetSelectedSpans_WindowShiftsAtStart(t *testing.T) {
assert.Equal(t, "span10", spans[10].SpanID, "selected span still in window")
}
// Auto-expanded span IDs from ALL branches are returned in
// updatedUncollapsedSpans. Only internal nodes (spans with children) are
// tracked — leaf spans are never added.
//
// root (selected)
// ├─ childA (internal ✓)
// │ └─ grandchildA (internal ✓)
// │ └─ leafA (leaf ✗)
// └─ childB (internal ✓)
// └─ grandchildB (internal ✓)
// └─ leafB (leaf ✗)
func TestGetSelectedSpans_AutoExpandedSpansReturnedInUncollapsed(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc",
mkSpan("grandchildA", "svc",
mkSpan("leafA", "svc"),
),
),
mkSpan("childB", "svc",
mkSpan("grandchildB", "svc",
mkSpan("leafB", "svc"),
),
),
)
spanMap := buildSpanMap(root)
_, uncollapsed, _, _ := GetSelectedSpans([]string{}, "root", []*model.Span{root}, spanMap, true)
// all internal nodes across both branches must be tracked
assert.Contains(t, uncollapsed, "root")
assert.Contains(t, uncollapsed, "childA", "internal node depth 1, branch A")
assert.Contains(t, uncollapsed, "childB", "internal node depth 1, branch B")
assert.Contains(t, uncollapsed, "grandchildA", "internal node depth 2, branch A")
assert.Contains(t, uncollapsed, "grandchildB", "internal node depth 2, branch B")
// leaves have no children to show — never added to uncollapsedSpans
assert.NotContains(t, uncollapsed, "leafA", "leaf spans are never added to uncollapsedSpans")
assert.NotContains(t, uncollapsed, "leafB", "leaf spans are never added to uncollapsedSpans")
}
// ─────────────────────────────────────────────────────────────────────────────
// maxDepthForSelectedSpanChildren boundary tests
// ─────────────────────────────────────────────────────────────────────────────
// Depth is measured from the selected span, not the trace root.
// Ancestors appear via the path-to-root logic, not the depth limit.
// Each depth level has two children to confirm the limit is enforced on all
// branches, not just the first.
//
// root
// └─ A ancestor ✓ (path-to-root)
// └─ selected
// ├─ d1a depth 1 ✓
// │ ├─ d2a depth 2 ✓
// │ │ ├─ d3a depth 3 ✓
// │ │ │ ├─ d4a depth 4 ✓
// │ │ │ │ ├─ d5a depth 5 ✓
// │ │ │ │ │ └─ d6a depth 6 ✗
// │ │ │ │ └─ d5b depth 5 ✓
// │ │ │ └─ d4b depth 4 ✓
// │ │ └─ d3b depth 3 ✓
// │ └─ d2b depth 2 ✓
// └─ d1b depth 1 ✓
func TestGetSelectedSpans_DepthCountedFromSelectedSpan(t *testing.T) {
selected := mkSpan("selected", "svc",
mkSpan("d1a", "svc",
mkSpan("d2a", "svc",
mkSpan("d3a", "svc",
mkSpan("d4a", "svc",
mkSpan("d5a", "svc",
mkSpan("d6a", "svc"), // depth 6 — excluded
),
mkSpan("d5b", "svc"), // depth 5 — included
),
mkSpan("d4b", "svc"), // depth 4 — included
),
mkSpan("d3b", "svc"), // depth 3 — included
),
mkSpan("d2b", "svc"), // depth 2 — included
),
mkSpan("d1b", "svc"), // depth 1 — included
)
root := mkSpan("root", "svc", mkSpan("A", "svc", selected))
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, true)
ids := spanIDs(spans)
assert.Contains(t, ids, "root", "ancestor shown via path-to-root")
assert.Contains(t, ids, "A", "ancestor shown via path-to-root")
for _, id := range []string{"d1a", "d1b", "d2a", "d2b", "d3a", "d3b", "d4a", "d4b", "d5a", "d5b"} {
assert.Contains(t, ids, id, "depth ≤ 5 — must be included")
}
assert.NotContains(t, ids, "d6a", "depth 6 > limit — excluded")
}
func TestGetAllSpans(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc",
mkSpan("grandchildA", "svc",
mkSpan("leafA", "svc2"),
),
),
mkSpan("childB", "svc3",
mkSpan("grandchildB", "svc",
mkSpan("leafB", "svc2"),
),
),
)
spans, rootServiceName, rootEntryPoint := GetAllSpans([]*model.Span{root})
assert.ElementsMatch(t, spanIDs(spans), []string{"root", "childA", "grandchildA", "leafA", "childB", "grandchildB", "leafB"})
assert.Equal(t, rootServiceName, "svc")
assert.Equal(t, rootEntryPoint, "root-op")
}
func mkSpan(id, service string, children ...*model.Span) *model.Span {
return &model.Span{
SpanID: id,

View File

@@ -333,7 +333,6 @@ type GetWaterfallSpansForTraceWithMetadataParams struct {
SelectedSpanID string `json:"selectedSpanId"`
IsSelectedSpanIDUnCollapsed bool `json:"isSelectedSpanIDUnCollapsed"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
Limit uint `json:"limit"`
}
type GetFlamegraphSpansForTraceParams struct {

View File

@@ -329,7 +329,6 @@ type GetWaterfallSpansForTraceWithMetadataResponse struct {
HasMissingSpans bool `json:"hasMissingSpans"`
// this is needed for frontend and query service sync
UncollapsedSpans []string `json:"uncollapsedSpans"`
HasMore bool `json:"hasMore"`
}
type GetFlamegraphSpansForTraceResponse struct {

View File

@@ -102,10 +102,6 @@ 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,14 +599,6 @@ 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,11 +206,8 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
return nil
}
// 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 {
// At least one aggregation required for non-disabled queries
if len(q.Aggregations) == 0 && !q.Disabled {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"at least one aggregation is required",

View File

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