mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-21 15:50:27 +01:00
Compare commits
16 Commits
SIG-2879
...
evaluation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c90440f3 | ||
|
|
4e93d8df57 | ||
|
|
c9568be5d8 | ||
|
|
1c257f3e14 | ||
|
|
ff8ac96d37 | ||
|
|
e8035b7dd2 | ||
|
|
cc77b829af | ||
|
|
49306cbe3d | ||
|
|
233a8e4cc3 | ||
|
|
629378bbec | ||
|
|
d96073f478 | ||
|
|
ba8a49929a | ||
|
|
a90904951e | ||
|
|
6c57735a81 | ||
|
|
4851527840 | ||
|
|
c5051128fa |
@@ -42,7 +42,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.129.4
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
image: signoz/signoz-schema-migrator:v0.129.4
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.94.1
|
||||
image: signoz/signoz:v0.95.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -209,7 +209,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.4
|
||||
image: signoz/signoz-otel-collector:v0.129.5
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -233,7 +233,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.4
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.94.1
|
||||
image: signoz/signoz:v0.95.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -150,7 +150,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.4
|
||||
image: signoz/signoz-otel-collector:v0.129.5
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.4
|
||||
image: signoz/signoz-schema-migrator:v0.129.5
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.94.1}
|
||||
image: signoz/signoz:${VERSION:-v0.95.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -239,7 +239,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -250,7 +250,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.94.1}
|
||||
image: signoz/signoz:${VERSION:-v0.95.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -144,7 +144,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -166,7 +166,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -178,7 +178,7 @@ services:
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -44,11 +44,13 @@
|
||||
"@sentry/react": "8.41.0",
|
||||
"@sentry/webpack-plugin": "2.22.6",
|
||||
"@signozhq/badge": "0.0.2",
|
||||
"@signozhq/button": "0.0.2",
|
||||
"@signozhq/calendar": "0.0.0",
|
||||
"@signozhq/callout": "0.0.2",
|
||||
"@signozhq/design-tokens": "1.1.4",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
"@signozhq/resizable": "0.0.0",
|
||||
"@signozhq/sonner": "0.1.0",
|
||||
"@signozhq/table": "0.3.7",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
@@ -137,6 +139,7 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
"style-loader": "1.3.0",
|
||||
"styled-components": "^5.3.11",
|
||||
|
||||
64
frontend/src/api/v1/download/downloadExportData.ts
Normal file
64
frontend/src/api/v1/download/downloadExportData.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
|
||||
|
||||
export const downloadExportData = async (
|
||||
props: ExportRawDataProps,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
queryParams.append('start', String(props.start));
|
||||
queryParams.append('end', String(props.end));
|
||||
queryParams.append('filter', props.filter);
|
||||
props.columns.forEach((col) => {
|
||||
queryParams.append('columns', col);
|
||||
});
|
||||
queryParams.append('order_by', props.orderBy);
|
||||
queryParams.append('limit', String(props.limit));
|
||||
queryParams.append('format', props.format);
|
||||
|
||||
const response = await axios.get<Blob>(`export_raw_data?${queryParams}`, {
|
||||
responseType: 'blob', // Important: tell axios to handle response as blob
|
||||
decompress: true, // Enable automatic decompression
|
||||
headers: {
|
||||
Accept: 'application/octet-stream', // Tell server we expect binary data
|
||||
},
|
||||
timeout: 0,
|
||||
});
|
||||
|
||||
// Only proceed if the response status is 200
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to download data: server returned status ${response.status}`,
|
||||
);
|
||||
}
|
||||
// Create blob URL from response data
|
||||
const blob = new Blob([response.data], { type: 'application/octet-stream' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Create and configure download link
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Get filename from Content-Disposition header or generate timestamped default
|
||||
const filename =
|
||||
response.headers['content-disposition']
|
||||
?.split('filename=')[1]
|
||||
?.replace(/["']/g, '') || `exported_data.${props.format || 'txt'}`;
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default downloadExportData;
|
||||
@@ -119,7 +119,9 @@ const filterAndSortTimezones = (
|
||||
return createTimezoneEntry(normalizedTz, offset);
|
||||
});
|
||||
|
||||
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
|
||||
export const generateTimezoneData = (
|
||||
includeEtcTimezones = false,
|
||||
): Timezone[] => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
|
||||
const timezones: Timezone[] = [];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import './RawLogView.styles.scss';
|
||||
|
||||
import { DrawerProps } from 'antd';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DrawerProps, Tooltip } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -26,7 +25,7 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from '../LogStateIndicator/utils';
|
||||
// styles
|
||||
import { RawLogContent, RawLogViewContainer } from './styles';
|
||||
import { InfoIconWrapper, RawLogContent, RawLogViewContainer } from './styles';
|
||||
import { RawLogViewProps } from './types';
|
||||
|
||||
function RawLogView({
|
||||
@@ -35,12 +34,17 @@ function RawLogView({
|
||||
data,
|
||||
linesPerRow,
|
||||
isTextOverflowEllipsisDisabled,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
selectedFields = [],
|
||||
fontSize,
|
||||
onLogClick,
|
||||
}: RawLogViewProps): JSX.Element {
|
||||
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
|
||||
data.id,
|
||||
);
|
||||
const {
|
||||
isHighlighted: isUrlHighlighted,
|
||||
isLogsExplorerPage,
|
||||
onLogCopy,
|
||||
} = useCopyLogLink(data.id);
|
||||
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
|
||||
|
||||
const {
|
||||
@@ -126,12 +130,20 @@ function RawLogView({
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
]);
|
||||
|
||||
const handleClickExpand = useCallback(() => {
|
||||
if (activeContextLog || isReadOnly) return;
|
||||
const handleClickExpand = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (activeContextLog || isReadOnly) return;
|
||||
|
||||
onSetActiveLog(data);
|
||||
setSelectedTab(VIEW_TYPES.OVERVIEW);
|
||||
}, [activeContextLog, isReadOnly, data, onSetActiveLog]);
|
||||
// Use custom click handler if provided, otherwise use default behavior
|
||||
if (onLogClick) {
|
||||
onLogClick(data, event);
|
||||
} else {
|
||||
onSetActiveLog(data);
|
||||
setSelectedTab(VIEW_TYPES.OVERVIEW);
|
||||
}
|
||||
},
|
||||
[activeContextLog, isReadOnly, data, onSetActiveLog, onLogClick],
|
||||
);
|
||||
|
||||
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
|
||||
(
|
||||
@@ -183,10 +195,11 @@ function RawLogView({
|
||||
align="middle"
|
||||
$isDarkMode={isDarkMode}
|
||||
$isReadOnly={isReadOnly}
|
||||
$isHightlightedLog={isHighlighted}
|
||||
$isHightlightedLog={isUrlHighlighted}
|
||||
$isActiveLog={
|
||||
activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog
|
||||
}
|
||||
$isCustomHighlighted={isHighlighted}
|
||||
$logType={logType}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
@@ -197,6 +210,15 @@ function RawLogView({
|
||||
severityText={data.severity_text}
|
||||
severityNumber={data.severity_number}
|
||||
/>
|
||||
{helpTooltip && (
|
||||
<Tooltip title={helpTooltip} placement="top" mouseEnterDelay={0.5}>
|
||||
<InfoIconWrapper
|
||||
size={14}
|
||||
className="help-tooltip-icon"
|
||||
color={Color.BG_VANILLA_400}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<RawLogContent
|
||||
className="raw-log-content"
|
||||
@@ -240,6 +262,7 @@ RawLogView.defaultProps = {
|
||||
isActiveLog: false,
|
||||
isReadOnly: false,
|
||||
isTextOverflowEllipsisDisabled: false,
|
||||
isHighlighted: false,
|
||||
};
|
||||
|
||||
export default RawLogView;
|
||||
|
||||
@@ -3,8 +3,13 @@ import { blue } from '@ant-design/colors';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Col, Row, Space } from 'antd';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { Info } from 'lucide-react';
|
||||
import styled from 'styled-components';
|
||||
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
|
||||
import {
|
||||
getActiveLogBackground,
|
||||
getCustomHighlightBackground,
|
||||
getDefaultLogBackground,
|
||||
} from 'utils/logs';
|
||||
|
||||
import { RawLogContentProps } from './types';
|
||||
|
||||
@@ -13,6 +18,7 @@ export const RawLogViewContainer = styled(Row)<{
|
||||
$isReadOnly?: boolean;
|
||||
$isActiveLog?: boolean;
|
||||
$isHightlightedLog: boolean;
|
||||
$isCustomHighlighted?: boolean;
|
||||
$logType: string;
|
||||
fontSize: FontSize;
|
||||
}>`
|
||||
@@ -50,6 +56,18 @@ export const RawLogViewContainer = styled(Row)<{
|
||||
};
|
||||
transition: background-color 2s ease-in;`
|
||||
: ''}
|
||||
|
||||
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
|
||||
`;
|
||||
|
||||
export const InfoIconWrapper = styled(Info)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
`;
|
||||
|
||||
export const ExpandIconWrapper = styled(Col)`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { MouseEvent } from 'react';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
@@ -6,10 +7,13 @@ export interface RawLogViewProps {
|
||||
isActiveLog?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isTextOverflowEllipsisDisabled?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
helpTooltip?: string;
|
||||
data: ILog;
|
||||
linesPerRow: number;
|
||||
fontSize: FontSize;
|
||||
selectedFields?: IField[];
|
||||
onLogClick?: (log: ILog, event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export interface RawLogContentProps {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
.logs-download-popover {
|
||||
.ant-popover-inner {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
var(--bg-ink-400) 0%,
|
||||
var(--bg-ink-500) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0 8px 12px 8px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.export-options-container {
|
||||
width: 240px;
|
||||
border-radius: 4px;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
color: var(--bg-slate-50);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.export-format,
|
||||
.row-limit,
|
||||
.columns-scope {
|
||||
padding: 12px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.ant-radio-wrapper) {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal-line {
|
||||
height: 1px;
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.logs-download-popover {
|
||||
.ant-popover-inner {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
var(--bg-vanilla-100) 0%,
|
||||
var(--bg-vanilla-300) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.export-options-container {
|
||||
.title {
|
||||
color: var(--bg-ink-200);
|
||||
}
|
||||
|
||||
:global(.ant-radio-wrapper) {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.horizontal-line {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { message } from 'antd';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { DownloadFormats, DownloadRowCounts } from './constants';
|
||||
import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu';
|
||||
|
||||
// Mock antd message
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const TEST_IDS = {
|
||||
DOWNLOAD_BUTTON: 'periscope-btn-download-options',
|
||||
} as const;
|
||||
|
||||
interface TestProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
filter: string;
|
||||
columns: TelemetryFieldKey[];
|
||||
orderBy: string;
|
||||
}
|
||||
|
||||
const createTestProps = (): TestProps => ({
|
||||
startTime: 1631234567890,
|
||||
endTime: 1631234567999,
|
||||
filter: 'status = 200',
|
||||
columns: [
|
||||
{
|
||||
name: 'http.status',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'int64',
|
||||
} as TelemetryFieldKey,
|
||||
],
|
||||
orderBy: 'timestamp:desc',
|
||||
});
|
||||
|
||||
const testRenderContent = (props: TestProps): void => {
|
||||
render(
|
||||
<LogsDownloadOptionsMenu
|
||||
startTime={props.startTime}
|
||||
endTime={props.endTime}
|
||||
filter={props.filter}
|
||||
columns={props.columns}
|
||||
orderBy={props.orderBy}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const testSuccessResponse = (res: any, ctx: any): any =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/octet-stream'),
|
||||
ctx.set('Content-Disposition', 'attachment; filename="export.csv"'),
|
||||
ctx.body('id,value\n1,2\n'),
|
||||
);
|
||||
|
||||
describe('LogsDownloadOptionsMenu', () => {
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`;
|
||||
let requestSpy: jest.Mock<any, any>;
|
||||
const setupDefaultServer = (): void => {
|
||||
server.use(
|
||||
rest.get(EXPORT_URL, (req, res, ctx) => {
|
||||
const params = req.url.searchParams;
|
||||
const payload = {
|
||||
start: Number(params.get('start')),
|
||||
end: Number(params.get('end')),
|
||||
filter: params.get('filter'),
|
||||
columns: params.getAll('columns'),
|
||||
order_by: params.get('order_by'),
|
||||
limit: Number(params.get('limit')),
|
||||
format: params.get('format'),
|
||||
};
|
||||
requestSpy(payload);
|
||||
return testSuccessResponse(res, ctx);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Mock URL.createObjectURL used by download logic
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
requestSpy = jest.fn();
|
||||
setupDefaultServer();
|
||||
(message.success as jest.Mock).mockReset();
|
||||
(message.error as jest.Mock).mockReset();
|
||||
// jsdom doesn't implement it by default
|
||||
((URL as unknown) as {
|
||||
createObjectURL: (b: Blob) => string;
|
||||
}).createObjectURL = jest.fn(() => 'blob:mock');
|
||||
((URL as unknown) as {
|
||||
revokeObjectURL: (u: string) => void;
|
||||
}).revokeObjectURL = jest.fn();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
// restore
|
||||
URL.createObjectURL = originalCreateObjectURL;
|
||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
});
|
||||
|
||||
it('renders download button', () => {
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
|
||||
const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON);
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('periscope-btn', 'ghost');
|
||||
});
|
||||
|
||||
it('shows popover with export options when download button is clicked', () => {
|
||||
const props = createTestProps();
|
||||
render(
|
||||
<LogsDownloadOptionsMenu
|
||||
startTime={props.startTime}
|
||||
endTime={props.endTime}
|
||||
filter={props.filter}
|
||||
columns={props.columns}
|
||||
orderBy={props.orderBy}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('FORMAT')).toBeInTheDocument();
|
||||
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows changing export format', () => {
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
|
||||
const csvRadio = screen.getByRole('radio', { name: 'csv' });
|
||||
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
|
||||
|
||||
expect(csvRadio).toBeChecked();
|
||||
fireEvent.click(jsonlRadio);
|
||||
expect(jsonlRadio).toBeChecked();
|
||||
expect(csvRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('allows changing row limit', () => {
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
|
||||
const tenKRadio = screen.getByRole('radio', { name: '10k' });
|
||||
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
|
||||
|
||||
expect(tenKRadio).toBeChecked();
|
||||
fireEvent.click(fiftyKRadio);
|
||||
expect(fiftyKRadio).toBeChecked();
|
||||
expect(tenKRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('allows changing columns scope', () => {
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
|
||||
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
|
||||
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
|
||||
|
||||
expect(allColumnsRadio).toBeChecked();
|
||||
fireEvent.click(selectedColumnsRadio);
|
||||
expect(selectedColumnsRadio).toBeChecked();
|
||||
expect(allColumnsRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => {
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
start: props.startTime,
|
||||
end: props.endTime,
|
||||
columns: ['attribute.http.status:int64'],
|
||||
filter: props.filter,
|
||||
order_by: props.orderBy,
|
||||
format: DownloadFormats.CSV,
|
||||
limit: DownloadRowCounts.TEN_K,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls downloadExportData with correct parameters when export button is clicked', async () => {
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
start: props.startTime,
|
||||
end: props.endTime,
|
||||
columns: [],
|
||||
filter: props.filter,
|
||||
order_by: props.orderBy,
|
||||
format: DownloadFormats.CSV,
|
||||
limit: DownloadRowCounts.TEN_K,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles successful export with success message', async () => {
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(message.success).toHaveBeenCalledWith(
|
||||
'Export completed successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles export failure with error message', async () => {
|
||||
// Override handler to return 500 for this test
|
||||
server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500))));
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(message.error).toHaveBeenCalledWith(
|
||||
'Failed to export logs. Please try again.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles UI state correctly during export process', async () => {
|
||||
server.use(
|
||||
rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)),
|
||||
);
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
// Start export
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
// Check button is disabled during export
|
||||
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled();
|
||||
|
||||
// Check popover is closed immediately after export starts
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
// Wait for export to complete and verify button is enabled again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses filename from Content-Disposition and triggers download click', async () => {
|
||||
server.use(
|
||||
rest.get(EXPORT_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/octet-stream'),
|
||||
ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'),
|
||||
ctx.body('row\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const anchorEl = originalCreateElement('a') as HTMLAnchorElement;
|
||||
const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute');
|
||||
const clickSpy = jest.spyOn(anchorEl, 'click');
|
||||
const removeSpy = jest.spyOn(anchorEl, 'remove');
|
||||
const createElSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tagName: any): any =>
|
||||
tagName === 'a' ? anchorEl : originalCreateElement(tagName),
|
||||
);
|
||||
const appendSpy = jest.spyOn(document.body, 'appendChild');
|
||||
|
||||
const props = createTestProps();
|
||||
testRenderContent(props);
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(appendSpy).toHaveBeenCalledWith(anchorEl);
|
||||
expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl');
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(anchorEl.getAttribute('download')).toBe('report.jsonl');
|
||||
|
||||
createElSpy.mockRestore();
|
||||
appendSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import './LogsDownloadOptionsMenu.styles.scss';
|
||||
|
||||
import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd';
|
||||
import { downloadExportData } from 'api/v1/download/downloadExportData';
|
||||
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
DownloadColumnsScopes,
|
||||
DownloadFormats,
|
||||
DownloadRowCounts,
|
||||
} from './constants';
|
||||
|
||||
function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string {
|
||||
const prefix = key.fieldContext ? `${key.fieldContext}.` : '';
|
||||
const suffix = key.fieldDataType ? `:${key.fieldDataType}` : '';
|
||||
return `${prefix}${key.name}${suffix}`;
|
||||
}
|
||||
|
||||
interface LogsDownloadOptionsMenuProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
filter: string;
|
||||
columns: TelemetryFieldKey[];
|
||||
orderBy: string;
|
||||
}
|
||||
|
||||
export default function LogsDownloadOptionsMenu({
|
||||
startTime,
|
||||
endTime,
|
||||
filter,
|
||||
columns,
|
||||
orderBy,
|
||||
}: LogsDownloadOptionsMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
|
||||
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
|
||||
const [columnsScope, setColumnsScope] = useState<string>(
|
||||
DownloadColumnsScopes.ALL,
|
||||
);
|
||||
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const handleExportRawData = useCallback(async (): Promise<void> => {
|
||||
setIsPopoverOpen(false);
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
const downloadOptions = {
|
||||
source: 'logs',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
columns:
|
||||
columnsScope === DownloadColumnsScopes.SELECTED
|
||||
? columns.map((col) => convertTelemetryFieldKeyToText(col))
|
||||
: [],
|
||||
filter,
|
||||
orderBy,
|
||||
format: exportFormat,
|
||||
limit: rowLimit,
|
||||
};
|
||||
|
||||
await downloadExportData(downloadOptions);
|
||||
message.success('Export completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error exporting logs:', error);
|
||||
message.error('Failed to export logs. Please try again.');
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [
|
||||
startTime,
|
||||
endTime,
|
||||
columnsScope,
|
||||
columns,
|
||||
filter,
|
||||
orderBy,
|
||||
exportFormat,
|
||||
rowLimit,
|
||||
setIsDownloading,
|
||||
setIsPopoverOpen,
|
||||
]);
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() => (
|
||||
<div
|
||||
className="export-options-container"
|
||||
role="dialog"
|
||||
aria-label="Export options"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<Radio.Group
|
||||
value={exportFormat}
|
||||
onChange={(e): void => setExportFormat(e.target.value)}
|
||||
>
|
||||
<Radio value={DownloadFormats.CSV}>csv</Radio>
|
||||
<Radio value={DownloadFormats.JSONL}>jsonl</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div className="horizontal-line" />
|
||||
|
||||
<div className="row-limit">
|
||||
<Typography.Text className="title">Number of Rows</Typography.Text>
|
||||
<Radio.Group
|
||||
value={rowLimit}
|
||||
onChange={(e): void => setRowLimit(e.target.value)}
|
||||
>
|
||||
<Radio value={DownloadRowCounts.TEN_K}>10k</Radio>
|
||||
<Radio value={DownloadRowCounts.THIRTY_K}>30k</Radio>
|
||||
<Radio value={DownloadRowCounts.FIFTY_K}>50k</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Download size={16} />}
|
||||
onClick={handleExportRawData}
|
||||
className="export-button"
|
||||
disabled={isDownloading}
|
||||
loading={isDownloading}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
[exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
rootClassName="logs-download-popover"
|
||||
>
|
||||
<Tooltip title="Download" placement="top">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={
|
||||
isDownloading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<DownloadIcon size={15} />
|
||||
)
|
||||
}
|
||||
data-testid="periscope-btn-download-options"
|
||||
disabled={isDownloading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/LogsDownloadOptionsMenu/constants.ts
Normal file
15
frontend/src/components/LogsDownloadOptionsMenu/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const DownloadFormats = {
|
||||
CSV: 'csv',
|
||||
JSONL: 'jsonl',
|
||||
};
|
||||
|
||||
export const DownloadColumnsScopes = {
|
||||
ALL: 'all',
|
||||
SELECTED: 'selected',
|
||||
};
|
||||
|
||||
export const DownloadRowCounts = {
|
||||
TEN_K: 10_000,
|
||||
THIRTY_K: 30_000,
|
||||
FIFTY_K: 50_000,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,24 +3,30 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import './LogsFormatOptionsMenu.styles.scss';
|
||||
|
||||
import { Button, Input, InputNumber, Tooltip, Typography } from 'antd';
|
||||
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import cx from 'classnames';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Check, ChevronLeft, ChevronRight, Minus, Plus, X } from 'lucide-react';
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Minus,
|
||||
Plus,
|
||||
Sliders,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface LogsFormatOptionsMenuProps {
|
||||
title: string;
|
||||
items: any;
|
||||
selectedOptionFormat: any;
|
||||
config: OptionsMenuConfig;
|
||||
}
|
||||
|
||||
export default function LogsFormatOptionsMenu({
|
||||
title,
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
@@ -43,6 +49,7 @@ export default function LogsFormatOptionsMenu({
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const initialMouseEnterRef = useRef<boolean>(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const onChange = useCallback(
|
||||
(key: LogViewMode) => {
|
||||
@@ -202,7 +209,7 @@ export default function LogsFormatOptionsMenu({
|
||||
};
|
||||
}, [selectedValue]);
|
||||
|
||||
return (
|
||||
const popoverContent = (
|
||||
<div
|
||||
className={cx(
|
||||
'nested-menu-container',
|
||||
@@ -344,7 +351,7 @@ export default function LogsFormatOptionsMenu({
|
||||
</div>
|
||||
<div className="horizontal-line" />
|
||||
<div className="menu-container">
|
||||
<div className="title"> {title} </div>
|
||||
<div className="title">FORMAT</div>
|
||||
|
||||
<div className="menu-items">
|
||||
{items.map(
|
||||
@@ -440,4 +447,21 @@ export default function LogsFormatOptionsMenu({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
rootClassName="format-options-popover"
|
||||
>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Sliders size={14} />}
|
||||
data-testid="periscope-btn-format-options"
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RadioChangeEvent } from 'antd/es/radio';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
label: string | React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,4 +83,7 @@ export const REACT_QUERY_KEY = {
|
||||
// Quick Filters Query Keys
|
||||
GET_CUSTOM_FILTERS: 'GET_CUSTOM_FILTERS',
|
||||
GET_OTHER_FILTERS: 'GET_OTHER_FILTERS',
|
||||
SPAN_LOGS: 'SPAN_LOGS',
|
||||
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
|
||||
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/* eslint-disable jsx-a11y/interactive-supports-focus */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
} from './constants';
|
||||
import TimeInput from './TimeInput';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
IEvaluationWindowDetailsProps,
|
||||
IEvaluationWindowPopoverProps,
|
||||
RollingWindowTimeframes,
|
||||
} from './types';
|
||||
import { TIMEZONE_DATA } from './utils';
|
||||
|
||||
function EvaluationWindowDetails({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowDetailsProps): JSX.Element {
|
||||
const currentHourOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const currentMonthOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 1; i <= 31; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const isCurrentHour =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentHour';
|
||||
const isCurrentDay =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentDay';
|
||||
const isCurrentMonth =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentMonth';
|
||||
|
||||
const handleNumberChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: value,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: value,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimezoneChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isCurrentHour) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING AT MINUTE</Typography.Text>
|
||||
<Select
|
||||
options={currentHourOptions}
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentDay) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationWindow.startingAt.time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>SELECT TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentMonth) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON DAY</Typography.Text>
|
||||
<Select
|
||||
options={currentMonthOptions}
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationWindow.startingAt.time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>SELECT TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div />;
|
||||
}
|
||||
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const renderEvaluationWindowContent = (
|
||||
label: string,
|
||||
contentOptions: Array<{ label: string; value: string }>,
|
||||
currentValue: string,
|
||||
onChange: (value: string) => void,
|
||||
): JSX.Element => (
|
||||
<div className="evaluation-window-content-item">
|
||||
<Typography.Text className="evaluation-window-content-item-label">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<div className="evaluation-window-content-list">
|
||||
{contentOptions.map((option) => (
|
||||
<div
|
||||
className={classNames('evaluation-window-content-list-item', {
|
||||
active: currentValue === option.value,
|
||||
})}
|
||||
key={option.value}
|
||||
role="button"
|
||||
onClick={(): void => onChange(option.value)}
|
||||
>
|
||||
<Typography.Text>{option.label}</Typography.Text>
|
||||
{currentValue === option.value && <Check size={12} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectionContent = (): JSX.Element => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
A Rolling Window has a fixed size and shifts its starting point over time
|
||||
based on when the rules are evaluated.
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
!evaluationWindow.timeframe
|
||||
) {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
A Cumulative Window has a fixed starting point and expands over time.
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-window-popover">
|
||||
<div className="evaluation-window-content">
|
||||
{renderEvaluationWindowContent(
|
||||
'EVALUATION WINDOW',
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
evaluationWindow.windowType,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
}),
|
||||
)}
|
||||
{renderEvaluationWindowContent(
|
||||
'TIMEFRAME',
|
||||
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
|
||||
evaluationWindow.timeframe,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
}),
|
||||
)}
|
||||
{renderSelectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@@ -0,0 +1,51 @@
|
||||
.time-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
|
||||
.time-input-field {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import './TimeInput.scss';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface TimeInputProps {
|
||||
value?: string; // Format: "HH:MM:SS"
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function TimeInput({
|
||||
value = '00:00:00',
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: TimeInputProps): JSX.Element {
|
||||
const [hours, setHours] = useState('00');
|
||||
const [minutes, setMinutes] = useState('00');
|
||||
const [seconds, setSeconds] = useState('00');
|
||||
|
||||
// Parse initial value
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const timeParts = value.split(':');
|
||||
if (timeParts.length === 3) {
|
||||
setHours(timeParts[0].padStart(2, '0'));
|
||||
setMinutes(timeParts[1].padStart(2, '0'));
|
||||
setSeconds(timeParts[2].padStart(2, '0'));
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Format time value
|
||||
const formatTimeValue = (h: string, m: string, s: string): string =>
|
||||
`${h.padStart(2, '0')}:${m.padStart(2, '0')}:${s.padStart(2, '0')}`;
|
||||
|
||||
// Handle input change
|
||||
const handleTimeChange = (
|
||||
newHours: string,
|
||||
newMinutes: string,
|
||||
newSeconds: string,
|
||||
): void => {
|
||||
const formattedValue = formatTimeValue(newHours, newMinutes, newSeconds);
|
||||
onChange?.(formattedValue);
|
||||
};
|
||||
|
||||
// Handle hours change
|
||||
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newHours = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
setHours(newHours);
|
||||
handleTimeChange(newHours, minutes, seconds);
|
||||
};
|
||||
|
||||
// Handle minutes change
|
||||
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newMinutes = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
setMinutes(newMinutes);
|
||||
handleTimeChange(hours, newMinutes, seconds);
|
||||
};
|
||||
|
||||
// Handle seconds change
|
||||
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newSeconds = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
setSeconds(newSeconds);
|
||||
handleTimeChange(hours, minutes, newSeconds);
|
||||
};
|
||||
|
||||
// Helper functions for field navigation
|
||||
const getNextField = (current: string): string => {
|
||||
switch (current) {
|
||||
case 'hours':
|
||||
return 'minutes';
|
||||
case 'minutes':
|
||||
return 'seconds';
|
||||
default:
|
||||
return 'hours';
|
||||
}
|
||||
};
|
||||
|
||||
const getPrevField = (current: string): string => {
|
||||
switch (current) {
|
||||
case 'seconds':
|
||||
return 'minutes';
|
||||
case 'minutes':
|
||||
return 'hours';
|
||||
default:
|
||||
return 'seconds';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle key navigation
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
currentField: 'hours' | 'minutes' | 'seconds',
|
||||
): void => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const nextField = document.querySelector(
|
||||
`[data-field="${getNextField(currentField)}"]`,
|
||||
) as HTMLInputElement;
|
||||
nextField?.focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prevField = document.querySelector(
|
||||
`[data-field="${getPrevField(currentField)}"]`,
|
||||
) as HTMLInputElement;
|
||||
prevField?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`time-input-container ${className}`}>
|
||||
<Input
|
||||
data-field="hours"
|
||||
value={hours}
|
||||
onChange={handleHoursChange}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
data-field="minutes"
|
||||
value={minutes}
|
||||
onChange={handleMinutesChange}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
data-field="seconds"
|
||||
value={seconds}
|
||||
onChange={handleSecondsChange}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TimeInput.defaultProps = {
|
||||
value: '00:00:00',
|
||||
onChange: undefined,
|
||||
disabled: false,
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default TimeInput;
|
||||
@@ -0,0 +1,3 @@
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
export default TimeInput;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import TimeInput from '../TimeInput/TimeInput';
|
||||
|
||||
describe('TimeInput', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with default value', () => {
|
||||
render(<TimeInput />);
|
||||
|
||||
expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds
|
||||
});
|
||||
|
||||
it('should render with provided value', () => {
|
||||
render(<TimeInput value="12:34:56" />);
|
||||
|
||||
expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours
|
||||
expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes
|
||||
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
|
||||
});
|
||||
|
||||
it('should handle value changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '12' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should handle minutes changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
fireEvent.change(minutesInput, { target: { value: '30' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:30:00');
|
||||
});
|
||||
|
||||
it('should handle seconds changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
fireEvent.change(secondsInput, { target: { value: '45' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
|
||||
});
|
||||
|
||||
it('should pad single digits with zeros', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '5' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('05:00:00');
|
||||
});
|
||||
|
||||
it('should filter non-numeric characters', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '1a2b3c' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should limit input to 2 characters', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '123456' } });
|
||||
|
||||
expect(hoursInput).toHaveValue('12');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with ArrowRight', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with ArrowLeft', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(minutesInput);
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(hoursInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle Tab navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{Tab}');
|
||||
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should wrap around navigation from seconds to hours', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
|
||||
await user.click(secondsInput);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(hoursInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should wrap around navigation from hours to seconds', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(secondsInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<TimeInput className="custom-class" />);
|
||||
|
||||
expect(container.firstChild).toHaveClass(
|
||||
'time-input-container',
|
||||
'custom-class',
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable inputs when disabled prop is true', () => {
|
||||
render(<TimeInput disabled />);
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update internal state when value prop changes', () => {
|
||||
const { rerender } = render(<TimeInput value="01:02:03" />);
|
||||
|
||||
expect(screen.getByDisplayValue('01')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('02')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('03')).toBeInTheDocument();
|
||||
|
||||
rerender(<TimeInput value="04:05:06" />);
|
||||
|
||||
expect(screen.getByDisplayValue('04')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('05')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('06')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle malformed time values gracefully', () => {
|
||||
render(<TimeInput value="invalid:time:format" />);
|
||||
|
||||
// Should show the invalid values as they are
|
||||
expect(screen.getByDisplayValue('invalid')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('time')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle partial time values', () => {
|
||||
render(<TimeInput value="12:34" />);
|
||||
|
||||
// Should fall back to default values for incomplete format
|
||||
expect(screen.getAllByDisplayValue('00')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { rrulestr } from 'rrule';
|
||||
|
||||
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from '../types';
|
||||
import {
|
||||
buildAlertScheduleFromCustomSchedule,
|
||||
buildAlertScheduleFromRRule,
|
||||
getCumulativeWindowTimeframeText,
|
||||
getEvaluationWindowTypeText,
|
||||
getRollingWindowTimeframeText,
|
||||
getTimeframeText,
|
||||
isValidRRule,
|
||||
} from '../utils';
|
||||
|
||||
const MOCK_DATE_STRING = '2024-01-15T10:30:00Z';
|
||||
const FREQ_DAILY = 'FREQ=DAILY';
|
||||
const TEN_THIRTY_TIME = '10:30:00';
|
||||
const NINE_AM_TIME = '09:00:00';
|
||||
|
||||
// Mock dayjs
|
||||
jest.mock('dayjs', () => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = jest.fn((date?: string | Date) => {
|
||||
if (date) {
|
||||
return originalDayjs(date);
|
||||
}
|
||||
return originalDayjs(MOCK_DATE_STRING);
|
||||
});
|
||||
Object.keys(originalDayjs).forEach((key) => {
|
||||
((mockDayjs as unknown) as Record<string, unknown>)[key] = originalDayjs[key];
|
||||
});
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
jest.mock('rrule', () => ({
|
||||
rrulestr: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('components/CustomTimePicker/timezoneUtils', () => ({
|
||||
generateTimezoneData: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
describe('utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getEvaluationWindowTypeText', () => {
|
||||
it('should return correct text for rolling window', () => {
|
||||
expect(getEvaluationWindowTypeText('rolling')).toBe('Rolling');
|
||||
});
|
||||
|
||||
it('should return correct text for cumulative window', () => {
|
||||
expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative');
|
||||
});
|
||||
|
||||
it('should default to Rolling for unknown type', () => {
|
||||
expect(
|
||||
getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'),
|
||||
).toBe('Rolling');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCumulativeWindowTimeframeText', () => {
|
||||
it('should return correct text for current hour', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_HOUR),
|
||||
).toBe('Current hour');
|
||||
});
|
||||
|
||||
it('should return correct text for current day', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_DAY),
|
||||
).toBe('Current day');
|
||||
});
|
||||
|
||||
it('should return correct text for current month', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_MONTH),
|
||||
).toBe('Current month');
|
||||
});
|
||||
|
||||
it('should default to Current hour for unknown timeframe', () => {
|
||||
expect(getCumulativeWindowTimeframeText('unknown')).toBe('Current hour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRollingWindowTimeframeText', () => {
|
||||
it('should return correct text for last 5 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_5_MINUTES),
|
||||
).toBe('Last 5 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 10 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_10_MINUTES),
|
||||
).toBe('Last 10 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 15 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_15_MINUTES),
|
||||
).toBe('Last 15 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 30 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_30_MINUTES),
|
||||
).toBe('Last 30 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 1 hour', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_1_HOUR),
|
||||
).toBe('Last 1 hour');
|
||||
});
|
||||
|
||||
it('should return correct text for last 2 hours', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_2_HOURS),
|
||||
).toBe('Last 2 hours');
|
||||
});
|
||||
|
||||
it('should return correct text for last 4 hours', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_4_HOURS),
|
||||
).toBe('Last 4 hours');
|
||||
});
|
||||
|
||||
it('should default to Last 5 minutes for unknown timeframe', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText('unknown' as RollingWindowTimeframes),
|
||||
).toBe('Last 5 minutes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeframeText', () => {
|
||||
it('should return rolling window text for rolling type', () => {
|
||||
expect(
|
||||
getTimeframeText('rolling', RollingWindowTimeframes.LAST_1_HOUR),
|
||||
).toBe('Last 1 hour');
|
||||
});
|
||||
|
||||
it('should return cumulative window text for cumulative type', () => {
|
||||
expect(
|
||||
getTimeframeText('cumulative', CumulativeWindowTimeframes.CURRENT_DAY),
|
||||
).toBe('Current day');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertScheduleFromRRule', () => {
|
||||
const mockRRule = {
|
||||
all: jest.fn((callback) => {
|
||||
const dates = [
|
||||
new Date(MOCK_DATE_STRING),
|
||||
new Date('2024-01-16T10:30:00Z'),
|
||||
new Date('2024-01-17T10:30:00Z'),
|
||||
];
|
||||
dates.forEach((date, index) => callback(date, index));
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(rrulestr as jest.Mock).mockReturnValue(mockRRule);
|
||||
});
|
||||
|
||||
it('should return null for empty rrule string', () => {
|
||||
const result = buildAlertScheduleFromRRule('', null, '10:30:00');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should build schedule from valid rrule string', () => {
|
||||
const result = buildAlertScheduleFromRRule(
|
||||
FREQ_DAILY,
|
||||
null,
|
||||
TEN_THIRTY_TIME,
|
||||
);
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
expect(result).toEqual([
|
||||
new Date(MOCK_DATE_STRING),
|
||||
new Date('2024-01-16T10:30:00Z'),
|
||||
new Date('2024-01-17T10:30:00Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle rrule with DTSTART', () => {
|
||||
const date = dayjs('2024-01-20');
|
||||
buildAlertScheduleFromRRule(FREQ_DAILY, date, NINE_AM_TIME);
|
||||
|
||||
// When date is provided, DTSTART is automatically added to the rrule string
|
||||
expect(rrulestr).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/DTSTART:20240120T\d{6}Z/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rrule without DTSTART', () => {
|
||||
// Test with no date provided - should use original rrule string
|
||||
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, NINE_AM_TIME);
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle escaped newlines', () => {
|
||||
buildAlertScheduleFromRRule('FREQ=DAILY\\nINTERVAL=1', null, '10:30:00');
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
|
||||
});
|
||||
|
||||
it('should limit occurrences to maxOccurrences', () => {
|
||||
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, '10:30:00', 2);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return null on error', () => {
|
||||
(rrulestr as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Invalid rrule');
|
||||
});
|
||||
|
||||
const result = buildAlertScheduleFromRRule('INVALID', null, '10:30:00');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertScheduleFromCustomSchedule', () => {
|
||||
beforeEach(() => {
|
||||
// Mock dayjs timezone methods
|
||||
((dayjs as unknown) as { tz: jest.Mock }).tz = jest.fn(
|
||||
(date?: string | Date) => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = originalDayjs(date || MOCK_DATE_STRING);
|
||||
mockDayjs.startOf = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.add = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.date = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.hour = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.minute = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.second = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.daysInMonth = jest.fn().mockReturnValue(31);
|
||||
mockDayjs.day = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.isAfter = jest.fn().mockReturnValue(true);
|
||||
mockDayjs.toDate = jest.fn().mockReturnValue(new Date(MOCK_DATE_STRING));
|
||||
return mockDayjs;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for missing required parameters', () => {
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('', [], '10:30:00', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', [], '10:30:00', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', ['monday'], '', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', ['monday'], '10:30:00', ''),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should generate monthly occurrences', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'month',
|
||||
['1', '15'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate weekly occurrences', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'week',
|
||||
['monday', 'friday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter invalid days for monthly schedule', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'month',
|
||||
['1', 'invalid', '15'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter invalid weekdays for weekly schedule', () => {
|
||||
buildAlertScheduleFromCustomSchedule(
|
||||
'week',
|
||||
['monday', 'invalid', 'friday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
// Function should handle invalid weekdays gracefully
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should return null on error', () => {
|
||||
// Test with invalid parameters that should cause an error
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'invalid_repeat_type',
|
||||
['monday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
// Should return empty array, not null, for invalid repeat type
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidRRule', () => {
|
||||
beforeEach(() => {
|
||||
(rrulestr as jest.Mock).mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should return true for valid rrule', () => {
|
||||
expect(isValidRRule(FREQ_DAILY)).toBe(true);
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
});
|
||||
|
||||
it('should handle escaped newlines', () => {
|
||||
expect(isValidRRule('FREQ=DAILY\\nINTERVAL=1')).toBe(true);
|
||||
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
|
||||
});
|
||||
|
||||
it('should return false for invalid rrule', () => {
|
||||
(rrulestr as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Invalid rrule');
|
||||
});
|
||||
|
||||
expect(isValidRRule('INVALID')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
export const EVALUATION_WINDOW_TYPE = [
|
||||
{ label: 'Rolling', value: 'rolling' },
|
||||
{ label: 'Cumulative', value: 'cumulative' },
|
||||
];
|
||||
|
||||
export const EVALUATION_WINDOW_TIMEFRAME = {
|
||||
rolling: [
|
||||
{ label: 'Last 5 minutes', value: '5m0s' },
|
||||
{ label: 'Last 10 minutes', value: '10m0s' },
|
||||
{ label: 'Last 15 minutes', value: '15m0s' },
|
||||
{ label: 'Last 30 minutes', value: '30m0s' },
|
||||
{ label: 'Last 1 hour', value: '1h0m0s' },
|
||||
{ label: 'Last 2 hours', value: '2h0m0s' },
|
||||
{ label: 'Last 4 hours', value: '4h0m0s' },
|
||||
],
|
||||
cumulative: [
|
||||
{ label: 'Current hour', value: 'currentHour' },
|
||||
{ label: 'Current day', value: 'currentDay' },
|
||||
{ label: 'Current month', value: 'currentMonth' },
|
||||
],
|
||||
};
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
|
||||
{ label: 'WEEK', value: 'week' },
|
||||
{ label: 'MONTH', value: 'month' },
|
||||
];
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [
|
||||
{ label: 'SUNDAY', value: 'sunday' },
|
||||
{ label: 'MONDAY', value: 'monday' },
|
||||
{ label: 'TUESDAY', value: 'tuesday' },
|
||||
{ label: 'WEDNESDAY', value: 'wednesday' },
|
||||
{ label: 'THURSDAY', value: 'thursday' },
|
||||
{ label: 'FRIDAY', value: 'friday' },
|
||||
{ label: 'SATURDAY', value: 'saturday' },
|
||||
];
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => {
|
||||
const value = String(i + 1);
|
||||
return { label: value, value };
|
||||
},
|
||||
);
|
||||
|
||||
export const WEEKDAY_MAP: { [key: string]: number } = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
};
|
||||
@@ -0,0 +1,677 @@
|
||||
.evaluation-settings-container {
|
||||
margin: 16px;
|
||||
|
||||
.evaluate-alert-conditions-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background-color: var(--bg-ink-400);
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-separator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
border-top: 1px dashed var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 240px;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 8px;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--bg-slate-400);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-options-container {
|
||||
.ant-collapse {
|
||||
.ant-collapse-item {
|
||||
.ant-collapse-header {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
|
||||
.ant-collapse-header-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
.ant-collapse-content-box {
|
||||
background-color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
.advanced-option-item-left-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.advanced-option-item-input {
|
||||
margin-top: 16px;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&::placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-right-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.advanced-option-item-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-ink-200);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-popover-arrow {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-popover-content {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
margin: 10px;
|
||||
|
||||
.ant-popover-inner {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
.evaluation-window-popover {
|
||||
min-width: 500px;
|
||||
|
||||
.evaluation-window-content {
|
||||
display: flex;
|
||||
|
||||
.evaluation-window-content-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
padding: 12px 16px;
|
||||
min-width: 250px;
|
||||
min-height: 300px;
|
||||
|
||||
.evaluation-window-content-item-label {
|
||||
color: var(--bg-slate-50);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.evaluation-window-content-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.evaluation-window-content-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 -16px;
|
||||
padding: 4px 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--bg-slate-500);
|
||||
border-left: 2px solid var(--bg-robin-500);
|
||||
.ant-typography {
|
||||
font-weight: 500;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selection-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 400px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-window-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-ink-300);
|
||||
border-top: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
background-color: var(--bg-ink-200);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-window-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 400px;
|
||||
min-height: 300px;
|
||||
padding: 16px;
|
||||
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-50);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.time-select-group {
|
||||
.ant-input-group {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
.ant-select {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 60%;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-container {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
.evaluation-cadence-item {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.edit-custom-schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
|
||||
.highlight {
|
||||
background-color: var(--bg-slate-500);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 500;
|
||||
margin: 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn-group {
|
||||
.ant-btn {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details {
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
|
||||
.evaluation-cadence-details-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding-left: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.query-section-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.query-section-query-actions {
|
||||
display: flex;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
flex-direction: row;
|
||||
border-bottom: none;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.explorer-view-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
border: none;
|
||||
padding: 9px;
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
border-left: 0.5px solid var(--bg-slate-400);
|
||||
border-bottom: 0.5px solid var(--bg-slate-400);
|
||||
width: 120px;
|
||||
height: 36px;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-ink-500);
|
||||
border-bottom: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-ink-300);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
border-left: 1px solid transparent !important;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details-content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
|
||||
.evaluation-cadence-details-content-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
height: 500px;
|
||||
overflow-y: scroll;
|
||||
padding-right: 16px;
|
||||
|
||||
.editor-view,
|
||||
.rrule-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
textarea {
|
||||
height: 200px;
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
font-family: 'Space Mono';
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-picker-input {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.no-schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
|
||||
.schedule-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.schedule-preview-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
|
||||
.schedule-preview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
|
||||
.schedule-preview-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 20px;
|
||||
|
||||
.schedule-preview-timeline-line {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
|
||||
.schedule-preview-date {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-preview-separator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
border-top: 1px dashed var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.schedule-preview-timezone {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker-date-panel {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-layout {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-header {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
} from '../context/types';
|
||||
|
||||
export interface IAdvancedOptionItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
input: JSX.Element;
|
||||
}
|
||||
|
||||
export enum RollingWindowTimeframes {
|
||||
'LAST_5_MINUTES' = '5m0s',
|
||||
'LAST_10_MINUTES' = '10m0s',
|
||||
'LAST_15_MINUTES' = '15m0s',
|
||||
'LAST_30_MINUTES' = '30m0s',
|
||||
'LAST_1_HOUR' = '1h0m0s',
|
||||
'LAST_2_HOURS' = '2h0m0s',
|
||||
'LAST_4_HOURS' = '4h0m0s',
|
||||
}
|
||||
|
||||
export enum CumulativeWindowTimeframes {
|
||||
'CURRENT_HOUR' = 'currentHour',
|
||||
'CURRENT_DAY' = 'currentDay',
|
||||
'CURRENT_MONTH' = 'currentMonth',
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowPopoverProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowDetailsProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
}
|
||||
|
||||
export interface IEvaluationCadenceDetailsProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface TimeInputProps {
|
||||
value?: string; // Format: "HH:MM:SS"
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { rrulestr } from 'rrule';
|
||||
|
||||
import { WEEKDAY_MAP } from './constants';
|
||||
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from './types';
|
||||
|
||||
// Extend dayjs with timezone plugins
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export const getEvaluationWindowTypeText = (
|
||||
windowType: 'rolling' | 'cumulative',
|
||||
): string => {
|
||||
switch (windowType) {
|
||||
case 'rolling':
|
||||
return 'Rolling';
|
||||
case 'cumulative':
|
||||
return 'Cumulative';
|
||||
default:
|
||||
return 'Rolling';
|
||||
}
|
||||
};
|
||||
|
||||
export const getCumulativeWindowTimeframeText = (timeframe: string): string => {
|
||||
switch (timeframe) {
|
||||
case CumulativeWindowTimeframes.CURRENT_HOUR:
|
||||
return 'Current hour';
|
||||
case CumulativeWindowTimeframes.CURRENT_DAY:
|
||||
return 'Current day';
|
||||
case CumulativeWindowTimeframes.CURRENT_MONTH:
|
||||
return 'Current month';
|
||||
default:
|
||||
return 'Current hour';
|
||||
}
|
||||
};
|
||||
|
||||
export const getRollingWindowTimeframeText = (
|
||||
timeframe: RollingWindowTimeframes,
|
||||
): string => {
|
||||
switch (timeframe) {
|
||||
case RollingWindowTimeframes.LAST_5_MINUTES:
|
||||
return 'Last 5 minutes';
|
||||
case RollingWindowTimeframes.LAST_10_MINUTES:
|
||||
return 'Last 10 minutes';
|
||||
case RollingWindowTimeframes.LAST_15_MINUTES:
|
||||
return 'Last 15 minutes';
|
||||
case RollingWindowTimeframes.LAST_30_MINUTES:
|
||||
return 'Last 30 minutes';
|
||||
case RollingWindowTimeframes.LAST_1_HOUR:
|
||||
return 'Last 1 hour';
|
||||
case RollingWindowTimeframes.LAST_2_HOURS:
|
||||
return 'Last 2 hours';
|
||||
case RollingWindowTimeframes.LAST_4_HOURS:
|
||||
return 'Last 4 hours';
|
||||
default:
|
||||
return 'Last 5 minutes';
|
||||
}
|
||||
};
|
||||
|
||||
export const getTimeframeText = (
|
||||
windowType: 'rolling' | 'cumulative',
|
||||
timeframe: string,
|
||||
): string => {
|
||||
if (windowType === 'rolling') {
|
||||
return getRollingWindowTimeframeText(timeframe as RollingWindowTimeframes);
|
||||
}
|
||||
return getCumulativeWindowTimeframeText(timeframe);
|
||||
};
|
||||
|
||||
export function buildAlertScheduleFromRRule(
|
||||
rruleString: string,
|
||||
date: Dayjs | null,
|
||||
startAt: string,
|
||||
maxOccurrences = 10,
|
||||
): Date[] | null {
|
||||
try {
|
||||
if (!rruleString) return null;
|
||||
|
||||
// Handle literal \n in string
|
||||
let finalRRuleString = rruleString.replace(/\\n/g, '\n');
|
||||
|
||||
if (date) {
|
||||
const dt = dayjs(date);
|
||||
if (!dt.isValid()) throw new Error('Invalid date provided');
|
||||
|
||||
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
|
||||
|
||||
const dtWithTime = dt
|
||||
.set('hour', hours)
|
||||
.set('minute', minutes)
|
||||
.set('second', seconds)
|
||||
.set('millisecond', 0);
|
||||
|
||||
const dtStartStr = dtWithTime
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
if (!/DTSTART/i.test(finalRRuleString)) {
|
||||
finalRRuleString = `DTSTART:${dtStartStr}\n${finalRRuleString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const rruleObj = rrulestr(finalRRuleString);
|
||||
const occurrences: Date[] = [];
|
||||
rruleObj.all((date, index) => {
|
||||
if (index >= maxOccurrences) return false;
|
||||
occurrences.push(date);
|
||||
return true;
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
} catch (error) {
|
||||
console.error('Error building RRULE:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateMonthlyOccurrences(
|
||||
targetDays: number[],
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
timezone: string,
|
||||
maxOccurrences: number,
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const currentMonth = dayjs().tz(timezone).startOf('month');
|
||||
|
||||
Array.from({ length: maxOccurrences }).forEach((_, monthOffset) => {
|
||||
const monthDate = currentMonth.add(monthOffset, 'month');
|
||||
targetDays.forEach((day) => {
|
||||
if (occurrences.length >= maxOccurrences) return;
|
||||
|
||||
const daysInMonth = monthDate.daysInMonth();
|
||||
if (day <= daysInMonth) {
|
||||
const targetDate = monthDate
|
||||
.date(day)
|
||||
.hour(hours)
|
||||
.minute(minutes)
|
||||
.second(seconds);
|
||||
if (targetDate.isAfter(dayjs().tz(timezone))) {
|
||||
occurrences.push(targetDate.toDate());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
function generateWeeklyOccurrences(
|
||||
targetWeekdays: number[],
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
timezone: string,
|
||||
maxOccurrences: number,
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const currentWeek = dayjs().tz(timezone).startOf('week');
|
||||
|
||||
Array.from({ length: maxOccurrences }).forEach((_, weekOffset) => {
|
||||
const weekDate = currentWeek.add(weekOffset, 'week');
|
||||
targetWeekdays.forEach((weekday) => {
|
||||
if (occurrences.length >= maxOccurrences) return;
|
||||
|
||||
const targetDate = weekDate
|
||||
.day(weekday)
|
||||
.hour(hours)
|
||||
.minute(minutes)
|
||||
.second(seconds);
|
||||
if (targetDate.isAfter(dayjs().tz(timezone))) {
|
||||
occurrences.push(targetDate.toDate());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
export function buildAlertScheduleFromCustomSchedule(
|
||||
repeatEvery: string,
|
||||
occurence: string[],
|
||||
startAt: string,
|
||||
timezone: string,
|
||||
maxOccurrences = 10,
|
||||
): Date[] | null {
|
||||
try {
|
||||
if (!repeatEvery || !occurence.length || !startAt || !timezone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
|
||||
let occurrences: Date[] = [];
|
||||
|
||||
if (repeatEvery === 'month') {
|
||||
const targetDays = occurence
|
||||
.map((day) => parseInt(day, 10))
|
||||
.filter((day) => !Number.isNaN(day));
|
||||
occurrences = generateMonthlyOccurrences(
|
||||
targetDays,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
timezone,
|
||||
maxOccurrences,
|
||||
);
|
||||
} else if (repeatEvery === 'week') {
|
||||
const targetWeekdays = occurence
|
||||
.map((day) => WEEKDAY_MAP[day.toLowerCase()])
|
||||
.filter((day) => day !== undefined);
|
||||
occurrences = generateWeeklyOccurrences(
|
||||
targetWeekdays,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
timezone,
|
||||
maxOccurrences,
|
||||
);
|
||||
}
|
||||
|
||||
occurrences.sort((a, b) => a.getTime() - b.getTime());
|
||||
return occurrences.slice(0, maxOccurrences);
|
||||
} catch (error) {
|
||||
console.error('Error building custom schedule:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
label: `${timezone.name} (${timezone.offset})`,
|
||||
value: timezone.value,
|
||||
}));
|
||||
|
||||
export function isValidRRule(rruleString: string): boolean {
|
||||
try {
|
||||
// normalize escaped \n
|
||||
const finalRRuleString = rruleString.replace(/\\n/g, '\n');
|
||||
rrulestr(finalRRuleString); // will throw if invalid
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import getRandomColor from 'lib/getRandomColor';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
AlertThresholdState,
|
||||
Algorithm,
|
||||
EvaluationWindowState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
@@ -70,6 +74,48 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
|
||||
thresholds: [INITIAL_CRITICAL_THRESHOLD],
|
||||
};
|
||||
|
||||
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: 0,
|
||||
timeUnit: '',
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: 0,
|
||||
},
|
||||
delayEvaluation: {
|
||||
delay: 0,
|
||||
timeUnit: '',
|
||||
},
|
||||
evaluationCadence: {
|
||||
mode: 'default',
|
||||
default: {
|
||||
value: 1,
|
||||
timeUnit: 'm',
|
||||
},
|
||||
custom: {
|
||||
repeatEvery: 'week',
|
||||
startAt: '00:00:00',
|
||||
occurence: [],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
rrule: {
|
||||
date: dayjs(),
|
||||
startAt: '00:00:00',
|
||||
rrule: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
|
||||
windowType: 'rolling',
|
||||
timeframe: '5m0s',
|
||||
startingAt: {
|
||||
time: '00:00:00',
|
||||
number: '1',
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
};
|
||||
|
||||
export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||
@@ -115,3 +161,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
|
||||
{ value: Seasonality.DAILY, label: 'Daily' },
|
||||
{ value: Seasonality.WEEKLY, label: 'Weekly' },
|
||||
];
|
||||
|
||||
export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
|
||||
{ value: 's', label: 'Second' },
|
||||
{ value: 'm', label: 'Minute' },
|
||||
{ value: 'h', label: 'Hours' },
|
||||
{ value: 'd', label: 'Day' },
|
||||
];
|
||||
|
||||
@@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
alertThresholdReducer,
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
getInitialAlertTypeFromURL,
|
||||
} from './utils';
|
||||
|
||||
@@ -80,6 +84,16 @@ export function CreateAlertProvider(
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
);
|
||||
|
||||
const [evaluationWindow, setEvaluationWindow] = useReducer(
|
||||
evaluationWindowReducer,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
);
|
||||
|
||||
const [advancedOptions, setAdvancedOptions] = useReducer(
|
||||
advancedOptionsReducer,
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
@@ -94,8 +108,19 @@ export function CreateAlertProvider(
|
||||
setAlertType: handleAlertTypeChange,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
}),
|
||||
[alertState, alertType, handleAlertTypeChange, thresholdState],
|
||||
[
|
||||
alertState,
|
||||
alertType,
|
||||
handleAlertTypeChange,
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { Dispatch } from 'react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
@@ -9,6 +10,10 @@ export interface ICreateAlertContextProps {
|
||||
setAlertType: Dispatch<AlertTypes>;
|
||||
thresholdState: AlertThresholdState;
|
||||
setThresholdState: Dispatch<AlertThresholdAction>;
|
||||
advancedOptions: AdvancedOptionsState;
|
||||
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
@@ -101,3 +106,86 @@ export type AlertThresholdAction =
|
||||
| { type: 'SET_SEASONALITY'; payload: string }
|
||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface AdvancedOptionsState {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: number;
|
||||
};
|
||||
delayEvaluation: {
|
||||
delay: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
evaluationCadence: {
|
||||
mode: EvaluationCadenceMode;
|
||||
default: {
|
||||
value: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
custom: {
|
||||
repeatEvery: string;
|
||||
startAt: string;
|
||||
occurence: string[];
|
||||
timezone: string;
|
||||
};
|
||||
rrule: {
|
||||
date: Dayjs | null;
|
||||
startAt: string;
|
||||
rrule: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type AdvancedOptionsAction =
|
||||
| {
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||
payload: { toleranceLimit: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
|
||||
payload: { minimumDatapoints: number };
|
||||
}
|
||||
| {
|
||||
type: 'SET_DELAY_EVALUATION';
|
||||
payload: { delay: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'SET_EVALUATION_CADENCE';
|
||||
payload: {
|
||||
default: { value: number; timeUnit: string };
|
||||
custom: {
|
||||
repeatEvery: string;
|
||||
startAt: string;
|
||||
timezone: string;
|
||||
occurence: string[];
|
||||
};
|
||||
rrule: { date: Dayjs | null; startAt: string; rrule: string };
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface EvaluationWindowState {
|
||||
windowType: 'rolling' | 'cumulative';
|
||||
timeframe: string;
|
||||
startingAt: {
|
||||
time: string;
|
||||
number: string;
|
||||
timezone: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type EvaluationWindowAction =
|
||||
| { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' }
|
||||
| { type: 'SET_TIMEFRAME'; payload: string }
|
||||
| {
|
||||
type: 'SET_STARTING_AT';
|
||||
payload: { time: string; number: string; timezone: string };
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
|
||||
@@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdAction,
|
||||
AlertThresholdState,
|
||||
CreateAlertAction,
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
} from './types';
|
||||
|
||||
export const alertCreationReducer = (
|
||||
@@ -110,3 +118,57 @@ export const alertThresholdReducer = (
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const advancedOptionsReducer = (
|
||||
state: AdvancedOptionsState,
|
||||
action: AdvancedOptionsAction,
|
||||
): AdvancedOptionsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
|
||||
return { ...state, sendNotificationIfDataIsMissing: action.payload };
|
||||
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
|
||||
return { ...state, enforceMinimumDatapoints: action.payload };
|
||||
case 'SET_DELAY_EVALUATION':
|
||||
return { ...state, delayEvaluation: action.payload };
|
||||
case 'SET_EVALUATION_CADENCE':
|
||||
return {
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, ...action.payload },
|
||||
};
|
||||
case 'SET_EVALUATION_CADENCE_MODE':
|
||||
return {
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_ADVANCED_OPTIONS_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluationWindowReducer = (
|
||||
state: EvaluationWindowState,
|
||||
action: EvaluationWindowAction,
|
||||
): EvaluationWindowState => {
|
||||
switch (action.type) {
|
||||
case 'SET_WINDOW_TYPE':
|
||||
return {
|
||||
...state,
|
||||
windowType: action.payload,
|
||||
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
timeframe:
|
||||
action.payload === 'rolling'
|
||||
? INITIAL_EVALUATION_WINDOW_STATE.timeframe
|
||||
: 'currentHour',
|
||||
};
|
||||
case 'SET_TIMEFRAME':
|
||||
return { ...state, timeframe: action.payload };
|
||||
case 'SET_STARTING_AT':
|
||||
return { ...state, startingAt: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
|
||||
canRemove: false,
|
||||
},
|
||||
{
|
||||
label: 'Mem Usage',
|
||||
label: 'Mem Usage (WSS)',
|
||||
value: 'memory',
|
||||
id: 'memory',
|
||||
canRemove: false,
|
||||
@@ -105,7 +105,7 @@ const columnsConfig = [
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: <div className="column-header-left">Memory Utilization (bytes)</div>,
|
||||
title: <div className="column-header-left">Memory Utilization (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 80,
|
||||
@@ -113,7 +113,7 @@ const columnsConfig = [
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: <div className="column-header-left">Memory Allocatable (bytes)</div>,
|
||||
title: <div className="column-header-left">Memory Allocatable</div>,
|
||||
dataIndex: 'memory_allocatable',
|
||||
key: 'memory_allocatable',
|
||||
width: 80,
|
||||
|
||||
@@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
|
||||
canRemove: false,
|
||||
},
|
||||
{
|
||||
label: 'Mem Usage',
|
||||
label: 'Mem Usage (WSS)',
|
||||
value: 'memory',
|
||||
id: 'memory',
|
||||
canRemove: false,
|
||||
@@ -211,10 +211,10 @@ const columnsConfig = [
|
||||
className: `column ${columnProgressBarClassName}`,
|
||||
},
|
||||
{
|
||||
title: <div className="column-header small-col">Mem Usage</div>,
|
||||
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 80,
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
|
||||
@@ -203,10 +203,10 @@ const columnsConfig = [
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: <div className="column-header-left small-col">Mem Usage</div>,
|
||||
title: <div className="column-header-left small-col">Mem Usage (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 80,
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
},
|
||||
|
||||
@@ -84,7 +84,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
|
||||
canRemove: false,
|
||||
},
|
||||
{
|
||||
label: 'Mem Usage',
|
||||
label: 'Mem Usage (WSS)',
|
||||
value: 'memory',
|
||||
id: 'memory',
|
||||
canRemove: false,
|
||||
@@ -238,10 +238,10 @@ const columnsConfig = [
|
||||
className: `column ${columnProgressBarClassName}`,
|
||||
},
|
||||
{
|
||||
title: <div className="column-header small-col">Mem Usage</div>,
|
||||
title: <div className="column-header small-col">Mem Usage (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 80,
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
|
||||
@@ -99,10 +99,10 @@ const columnsConfig = [
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: <div className="column-header-left">Mem Usage</div>,
|
||||
title: <div className="column-header-left">Mem Usage (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 80,
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
|
||||
canRemove: false,
|
||||
},
|
||||
{
|
||||
label: 'Memory Usage (bytes)',
|
||||
label: 'Memory Usage (WSS)',
|
||||
value: 'memory',
|
||||
id: 'memory',
|
||||
canRemove: false,
|
||||
@@ -121,7 +121,7 @@ const columnsConfig = [
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: <div className="column-header-left">Memory Usage (bytes)</div>,
|
||||
title: <div className="column-header-left">Memory Usage (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 80,
|
||||
@@ -129,7 +129,7 @@ const columnsConfig = [
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: <div className="column-header-left">Memory Alloc (bytes)</div>,
|
||||
title: <div className="column-header-left">Memory Allocatable</div>,
|
||||
dataIndex: 'memory_allocatable',
|
||||
key: 'memory_allocatable',
|
||||
width: 80,
|
||||
|
||||
@@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
|
||||
canRemove: false,
|
||||
},
|
||||
{
|
||||
label: 'Mem Usage',
|
||||
label: 'Mem Usage (WSS)',
|
||||
value: 'memory',
|
||||
id: 'memory',
|
||||
canRemove: false,
|
||||
@@ -211,10 +211,10 @@ const columnsConfig = [
|
||||
className: `column ${columnProgressBarClassName}`,
|
||||
},
|
||||
{
|
||||
title: <div className="column-header small-col">Mem Usage</div>,
|
||||
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 80,
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
|
||||
@@ -69,7 +69,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
|
||||
canRemove: false,
|
||||
},
|
||||
{
|
||||
label: 'Mem Usage',
|
||||
label: 'Mem Usage (WSS)',
|
||||
value: 'memory',
|
||||
id: 'memory',
|
||||
canRemove: false,
|
||||
@@ -207,10 +207,10 @@ const columnsConfig = [
|
||||
className: `column ${columnProgressBarClassName}`,
|
||||
},
|
||||
{
|
||||
title: <div className="column-header">Mem Usage</div>,
|
||||
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 80,
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
|
||||
@@ -240,7 +240,6 @@ function LiveLogsContainer(): JSX.Element {
|
||||
|
||||
{showFormatMenuItems && (
|
||||
<LogsFormatOptionsMenu
|
||||
title="FORMAT"
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Button, Switch, Typography } from 'antd';
|
||||
import { Switch, Typography } from 'antd';
|
||||
import { WsDataEvent } from 'api/common/getQueryStats';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import LogsDownloadOptionsMenu from 'components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Download from 'container/DownloadV2/DownloadV2';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import { ArrowUp10, Minus, Sliders } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ArrowUp10, Minus } from 'lucide-react';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
@@ -22,11 +20,12 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
flattenLogData,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
@@ -34,16 +33,14 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
flattenLogData: any;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
queryStats: WsDataEvent | undefined;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
}): JSX.Element {
|
||||
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
@@ -71,18 +68,6 @@ function LogsActionsContainer({
|
||||
},
|
||||
];
|
||||
|
||||
const handleToggleShowFormatOptions = (): void =>
|
||||
setShowFormatMenuItems(!showFormatMenuItems);
|
||||
|
||||
useClickOutside({
|
||||
ref: menuRef,
|
||||
onClickOutside: () => {
|
||||
if (showFormatMenuItems) {
|
||||
setShowFormatMenuItems(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="logs-actions-container">
|
||||
<div className="tab-options">
|
||||
@@ -114,27 +99,21 @@ function LogsActionsContainer({
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<Download
|
||||
data={flattenLogData}
|
||||
isLoading={isFetching}
|
||||
fileName="log_data"
|
||||
/>
|
||||
<div className="format-options-container" ref={menuRef}>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
onClick={handleToggleShowFormatOptions}
|
||||
icon={<Sliders size={14} />}
|
||||
data-testid="periscope-btn"
|
||||
<div className="download-options-container">
|
||||
<LogsDownloadOptionsMenu
|
||||
startTime={minTime}
|
||||
endTime={maxTime}
|
||||
filter={listQuery?.filter?.expression || ''}
|
||||
columns={config.addColumn?.value || []}
|
||||
orderBy={orderBy}
|
||||
/>
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
/>
|
||||
|
||||
{showFormatMenuItems && (
|
||||
<LogsFormatOptionsMenu
|
||||
title="FORMAT"
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,6 @@ import setToLocalstorage from 'api/browser/localstorage/set';
|
||||
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -25,7 +24,6 @@ import LogsExplorerChart from 'container/LogsExplorerChart';
|
||||
import LogsExplorerList from 'container/LogsExplorerList';
|
||||
import LogsExplorerTable from 'container/LogsExplorerTable';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
@@ -33,19 +31,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { getPaginationQueryDataV2 } from 'lib/newQueryBuilder/getPaginationQueryData';
|
||||
import {
|
||||
cloneDeep,
|
||||
defaultTo,
|
||||
isEmpty,
|
||||
isUndefined,
|
||||
omit,
|
||||
set,
|
||||
} from 'lodash-es';
|
||||
import { cloneDeep, defaultTo, isEmpty, isUndefined, set } from 'lodash-es';
|
||||
import LiveLogs from 'pages/LiveLogs';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
@@ -607,29 +596,6 @@ function LogsExplorerViewsContainer({
|
||||
setIsLoadingQueries,
|
||||
]);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const flattenLogData = useMemo(
|
||||
() =>
|
||||
logs.map((log) => {
|
||||
const timestamp =
|
||||
typeof log.timestamp === 'string'
|
||||
? dayjs(log.timestamp)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: dayjs(log.timestamp / 1e6)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
|
||||
|
||||
return FlatLogData({
|
||||
timestamp,
|
||||
body: log.body,
|
||||
...omit(log, 'timestamp', 'body'),
|
||||
});
|
||||
}),
|
||||
[logs, timezone.value],
|
||||
);
|
||||
|
||||
const handleToggleFrequencyChart = useCallback(() => {
|
||||
const newShowFrequencyChart = !showFrequencyChart;
|
||||
|
||||
@@ -654,11 +620,12 @@ function LogsExplorerViewsContainer({
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
flattenLogData={flattenLogData}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isSuccess={isSuccess}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -185,13 +185,16 @@ describe('LogsExplorerViews -', () => {
|
||||
lodsQueryServerRequest();
|
||||
const { queryByTestId } = renderer();
|
||||
|
||||
const periscopeButtonTestId = 'periscope-btn';
|
||||
const periscopeDownloadButtonTestId = 'periscope-btn-download-options';
|
||||
const periscopeFormatButtonTestId = 'periscope-btn-format-options';
|
||||
|
||||
// Test that the periscope button is present
|
||||
expect(queryByTestId(periscopeButtonTestId)).toBeInTheDocument();
|
||||
expect(queryByTestId(periscopeDownloadButtonTestId)).toBeInTheDocument();
|
||||
expect(queryByTestId(periscopeFormatButtonTestId)).toBeInTheDocument();
|
||||
|
||||
// Test that the menu opens when clicked
|
||||
fireEvent.click(queryByTestId(periscopeButtonTestId) as HTMLElement);
|
||||
fireEvent.click(queryByTestId(periscopeDownloadButtonTestId) as HTMLElement);
|
||||
fireEvent.click(queryByTestId(periscopeFormatButtonTestId) as HTMLElement);
|
||||
expect(document.querySelector('.menu-container')).toBeInTheDocument();
|
||||
|
||||
// Test that the menu items are present
|
||||
@@ -200,7 +203,8 @@ describe('LogsExplorerViews -', () => {
|
||||
expect(menuItems.length).toBe(expectedMenuItemsCount);
|
||||
|
||||
// Test that the component renders without crashing
|
||||
expect(queryByTestId(periscopeButtonTestId)).toBeInTheDocument();
|
||||
expect(queryByTestId(periscopeDownloadButtonTestId)).toBeInTheDocument();
|
||||
expect(queryByTestId(periscopeFormatButtonTestId)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check isLoading state', async () => {
|
||||
|
||||
@@ -163,12 +163,11 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValues: SelectOption<string, string>[] = query.groupBy.map(
|
||||
(item) => ({
|
||||
const currentValues: SelectOption<string, string>[] =
|
||||
query.groupBy?.map((item) => ({
|
||||
label: `${item.key}`,
|
||||
value: `${item.id}`,
|
||||
}),
|
||||
);
|
||||
})) || [];
|
||||
|
||||
setLocalValues(currentValues);
|
||||
}, [query]);
|
||||
@@ -191,7 +190,7 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
data-testid="group-by"
|
||||
placeholder={localValues.length === 0 ? 'Everything (no breakdown)' : ''}
|
||||
placeholder={localValues?.length === 0 ? 'Everything (no breakdown)' : ''}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
.span-details-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 450px;
|
||||
height: calc(100vh - 44px); //44px -> trace details top bar
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
overflow-y: auto;
|
||||
&:not(&-docked) {
|
||||
min-width: 450px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
@@ -124,24 +127,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.related-logs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
padding: 5px 12px;
|
||||
margin: 10px 12px;
|
||||
box-shadow: none;
|
||||
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.attributes-events {
|
||||
.details-drawer-tabs {
|
||||
.ant-tabs-extra-content {
|
||||
@@ -217,11 +202,21 @@
|
||||
|
||||
.span-details-drawer-docked {
|
||||
width: 48px;
|
||||
flex: 0 48px !important;
|
||||
|
||||
.header {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.resizable-handle {
|
||||
box-sizing: border-box;
|
||||
border: 2px solid transparent;
|
||||
&:hover,
|
||||
&[data-resize-handle-state='drag'],
|
||||
&[data-resize-handle-state='hover'] {
|
||||
border-color: rgba(35, 196, 248, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.span-details-drawer {
|
||||
@@ -268,10 +263,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.related-logs {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.attributes-events {
|
||||
.details-drawer-tabs {
|
||||
.ant-tabs-nav::before {
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import './SpanDetailsDrawer.styles.scss';
|
||||
|
||||
import { Button, Tabs, TabsProps, Tooltip, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { getTraceToLogsQuery } from 'container/TraceDetail/SelectedSpanDetails/config';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { Anvil, Bookmark, Link2, PanelRight, Search } from 'lucide-react';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { formatEpochTimestamp } from 'utils/timeUtils';
|
||||
|
||||
import Attributes from './Attributes/Attributes';
|
||||
import { RelatedSignalsViews } from './constants';
|
||||
import Events from './Events/Events';
|
||||
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
||||
import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals';
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
interface ISpanDetailsDrawerProps {
|
||||
isSpanDetailsDocked: boolean;
|
||||
setIsSpanDetailsDocked: Dispatch<SetStateAction<boolean>>;
|
||||
selectedSpan: Span | undefined;
|
||||
traceID: string;
|
||||
traceStartTime: number;
|
||||
traceEndTime: number;
|
||||
}
|
||||
@@ -35,16 +32,31 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
setIsSpanDetailsDocked,
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceID,
|
||||
traceEndTime,
|
||||
} = props;
|
||||
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||
const [isRelatedSignalsOpen, setIsRelatedSignalsOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [activeDrawerView, setActiveDrawerView] = useState<RelatedSignalsViews>(
|
||||
RelatedSignalsViews.LOGS,
|
||||
);
|
||||
const color = generateColor(
|
||||
selectedSpan?.serviceName || '',
|
||||
themeColors.traceDetailColors,
|
||||
);
|
||||
|
||||
const handleRelatedSignalsChange = useCallback((e: RadioChangeEvent): void => {
|
||||
const selectedView = e.target.value as RelatedSignalsViews;
|
||||
setActiveDrawerView(selectedView);
|
||||
setIsRelatedSignalsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRelatedSignalsClose = useCallback((): void => {
|
||||
setIsRelatedSignalsOpen(false);
|
||||
}, []);
|
||||
|
||||
function getItems(span: Span, startTime: number): TabsProps['items'] {
|
||||
return [
|
||||
{
|
||||
@@ -101,27 +113,9 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
},
|
||||
];
|
||||
}
|
||||
const onLogsHandler = (): void => {
|
||||
const query = getTraceToLogsQuery(traceID, traceStartTime, traceEndTime);
|
||||
|
||||
history.push(
|
||||
`${ROUTES.LOGS_EXPLORER}?${createQueryParams({
|
||||
[QueryParams.compositeQuery]: JSON.stringify(query),
|
||||
// we subtract 5 minutes from the start time to handle the cases when the trace duration is in nanoseconds
|
||||
[QueryParams.startTime]: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
// we add 5 minutes to the end time for nano second duration traces
|
||||
[QueryParams.endTime]: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
})}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'span-details-drawer',
|
||||
isSpanDetailsDocked ? 'span-details-drawer-docked' : '',
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<section className="header">
|
||||
{!isSpanDetailsDocked && (
|
||||
<div className="heading">
|
||||
@@ -216,12 +210,31 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="item">
|
||||
<Typography.Text className="attribute-key">
|
||||
related signals
|
||||
</Typography.Text>
|
||||
<div className="related-signals-section">
|
||||
<SignozRadioGroup
|
||||
value=""
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<LogsIcon width={14} height={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.LOGS,
|
||||
},
|
||||
]}
|
||||
onChange={handleRelatedSignalsChange}
|
||||
className="related-signals-radio"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button onClick={onLogsHandler} className="related-logs">
|
||||
Go to related logs
|
||||
</Button>
|
||||
|
||||
<section className="attributes-events">
|
||||
<Tabs
|
||||
items={getItems(selectedSpan, traceStartTime)}
|
||||
@@ -240,7 +253,19 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSpan && (
|
||||
<SpanRelatedSignals
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
isOpen={isRelatedSignalsOpen}
|
||||
onClose={handleRelatedSignalsClose}
|
||||
initialView={activeDrawerView}
|
||||
key={activeDrawerView}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
277
frontend/src/container/SpanDetailsDrawer/SpanLogs/SpanLogs.tsx
Normal file
277
frontend/src/container/SpanDetailsDrawer/SpanLogs/SpanLogs.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import './spanLogs.styles.scss';
|
||||
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
OPERATORS,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Compass } from 'lucide-react';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useSpanContextLogs } from './useSpanContextLogs';
|
||||
|
||||
interface SpanLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
handleExplorerPageRedirect: () => void;
|
||||
}
|
||||
|
||||
function SpanLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
handleExplorerPageRedirect,
|
||||
}: SpanLogsProps): JSX.Element {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
} = useSpanContextLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
});
|
||||
|
||||
// Create trace_id and span_id filters for logs explorer navigation
|
||||
const createLogsFilter = useCallback(
|
||||
(targetSpanId: string): TagFilter => {
|
||||
const traceIdKey: BaseAutocompleteData = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
};
|
||||
|
||||
const spanIdKey: BaseAutocompleteData = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'span_id',
|
||||
};
|
||||
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: targetSpanId,
|
||||
key: spanIdKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
},
|
||||
[traceId],
|
||||
);
|
||||
|
||||
// Navigate to logs explorer with trace_id and span_id filters
|
||||
const handleLogClick = useCallback(
|
||||
(log: ILog): void => {
|
||||
// Determine if this is a span log or context log
|
||||
const isSpanLog = isLogSpanRelated(log.id);
|
||||
|
||||
// Extract log's span_id (handles both spanID and span_id properties)
|
||||
const logSpanId = log.spanID || log.span_id || '';
|
||||
|
||||
// Use appropriate span ID: current span for span logs, individual log's span for context logs
|
||||
const targetSpanId = isSpanLog ? spanId : logSpanId;
|
||||
const filters = createLogsFilter(targetSpanId);
|
||||
|
||||
// Create base query
|
||||
const baseQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
// Add appropriate filters to the query
|
||||
const updatedQuery = {
|
||||
...baseQuery,
|
||||
builder: {
|
||||
...baseQuery.builder,
|
||||
queryData: baseQuery.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
filters,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const queryParams = {
|
||||
[QueryParams.activeLogId]: `"${log.id}"`,
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
|
||||
};
|
||||
|
||||
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
|
||||
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
[
|
||||
isLogSpanRelated,
|
||||
createLogsFilter,
|
||||
spanId,
|
||||
updateAllQueriesOperators,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
);
|
||||
|
||||
// Footer rendering for pagination
|
||||
const hasReachedEndOfLogs = false;
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, logToRender: ILog): JSX.Element => {
|
||||
const getIsSpanRelated = (log: ILog, currentSpanId: string): boolean => {
|
||||
if (log.spanID) {
|
||||
return log.spanID === currentSpanId;
|
||||
}
|
||||
return log.span_id === currentSpanId;
|
||||
};
|
||||
|
||||
const isSpanRelated = getIsSpanRelated(logToRender, spanId);
|
||||
|
||||
return (
|
||||
<RawLogView
|
||||
key={logToRender.id}
|
||||
data={logToRender}
|
||||
linesPerRow={1}
|
||||
fontSize={FontSize.MEDIUM}
|
||||
onLogClick={handleLogClick}
|
||||
isHighlighted={isSpanRelated}
|
||||
helpTooltip={
|
||||
isSpanRelated ? 'This log belongs to the current span' : undefined
|
||||
}
|
||||
selectedFields={[
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleLogClick, spanId],
|
||||
);
|
||||
|
||||
const renderFooter = useCallback((): JSX.Element | null => {
|
||||
if (isFetching) {
|
||||
return <div className="logs-loading-skeleton"> Loading more logs ... </div>;
|
||||
}
|
||||
|
||||
if (hasReachedEndOfLogs) {
|
||||
return <div className="logs-loading-skeleton"> *** End *** </div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [isFetching, hasReachedEndOfLogs]);
|
||||
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<div className="span-logs-list-container">
|
||||
<PreferenceContextProvider>
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className="span-logs-virtuoso"
|
||||
key="span-logs-virtuoso"
|
||||
style={
|
||||
logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}
|
||||
}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
overscan={200}
|
||||
components={{
|
||||
Footer: renderFooter,
|
||||
}}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</PreferenceContextProvider>
|
||||
</div>
|
||||
),
|
||||
[logs, getItemContent, renderFooter],
|
||||
);
|
||||
|
||||
const renderNoLogsFound = (): JSX.Element => (
|
||||
<div className="span-logs-empty-content">
|
||||
<section className="description">
|
||||
<img src="/Icons/no-data.svg" alt="no-data" className="no-data-img" />
|
||||
<Typography.Text className="no-data-text-1">
|
||||
No logs found for selected span.
|
||||
<span className="no-data-text-2">
|
||||
Try viewing logs for the current trace.
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className="action-section">
|
||||
<Button
|
||||
className="action-btn"
|
||||
variant="action"
|
||||
prefixIcon={<Compass size={14} />}
|
||||
onClick={handleExplorerPageRedirect}
|
||||
size="md"
|
||||
>
|
||||
Log Explorer
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
|
||||
{(isLoading || isFetching) && <LogsLoading />}
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
logs.length === 0 &&
|
||||
renderNoLogsFound()}
|
||||
{isError && !isLoading && !isFetching && <LogsError />}
|
||||
{!isLoading && !isFetching && !isError && logs.length > 0 && renderContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanLogs;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Creates a query payload for fetching logs related to a specific span
|
||||
* @param start - Start time in milliseconds
|
||||
* @param end - End time in milliseconds
|
||||
* @param filter - V5 filter expression for trace_id and span_id
|
||||
* @param order - Timestamp ordering ('desc' for newest first, 'asc' for oldest first)
|
||||
* @returns Query payload for logs API
|
||||
*/
|
||||
export const getSpanLogsQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
filter: Filter,
|
||||
order: 'asc' | 'desc' = 'desc',
|
||||
): 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: [],
|
||||
filter,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order,
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: uuidv4(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates tag filters for querying logs by trace_id only (for context logs)
|
||||
* @param traceId - The trace identifier
|
||||
* @returns Tag filters for the query builder
|
||||
*/
|
||||
export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||
items: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
id: uuidv4(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
},
|
||||
op: 'in',
|
||||
value: traceId,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
.span-logs {
|
||||
margin-inline: 16px;
|
||||
height: calc(100% - 64px - 55px - 56px);
|
||||
|
||||
&-virtuoso {
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
&-list-container .logs-loading-skeleton {
|
||||
height: 100%;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-top: none;
|
||||
color: var(--bg-vanilla-400);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
&-empty-content {
|
||||
height: 100%;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 96px;
|
||||
gap: 12px;
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: 320px;
|
||||
|
||||
.no-data-img {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.no-data-text-1 {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
.no-data-text-2 {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.action-section {
|
||||
width: 320px;
|
||||
|
||||
.action-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.span-logs {
|
||||
&-empty-content {
|
||||
.description {
|
||||
.no-data-text-1 {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
.no-data-text-2 {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-section {
|
||||
.action-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-200);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { getSpanLogsQueryPayload } from './constants';
|
||||
|
||||
interface UseSpanContextLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UseSpanContextLogsReturn {
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
spanLogIds: Set<string>;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
}
|
||||
|
||||
const traceIdKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
};
|
||||
/**
|
||||
* Creates v5 filter expression for querying logs by trace_id and span_id (for span logs)
|
||||
*/
|
||||
const createSpanLogsFilters = (traceId: string, spanId: string): Filter => {
|
||||
const spanIdKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'span_id',
|
||||
};
|
||||
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: spanId,
|
||||
key: spanIdKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
return convertFiltersToExpression(filters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates v5 filter expression for querying context logs with id constraints
|
||||
*/
|
||||
const createContextFilters = (
|
||||
traceId: string,
|
||||
logId: string,
|
||||
operator: 'lt' | 'gt',
|
||||
): Filter => {
|
||||
const idKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'id',
|
||||
};
|
||||
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(operator === 'lt' ? OPERATORS['<'] : OPERATORS['>']),
|
||||
value: logId,
|
||||
key: idKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
return convertFiltersToExpression(filters);
|
||||
};
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
export const useSpanContextLogs = ({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Phase 1: Fetch span-specific logs (trace_id + span_id)
|
||||
const spanFilter = useMemo(() => createSpanLogsFilters(traceId, spanId), [
|
||||
traceId,
|
||||
spanId,
|
||||
]);
|
||||
const spanQueryPayload = useMemo(
|
||||
() =>
|
||||
getSpanLogsQueryPayload(timeRange.startTime, timeRange.endTime, spanFilter),
|
||||
[timeRange.startTime, timeRange.endTime, spanFilter],
|
||||
);
|
||||
|
||||
const {
|
||||
data: spanData,
|
||||
isLoading: isSpanLoading,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_LOGS,
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(spanQueryPayload, ENTITY_VERSION_V5),
|
||||
enabled: !!traceId && !!spanId,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Extract span logs and track their IDs
|
||||
const spanLogs = useMemo(() => {
|
||||
if (!spanData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
setSpanLogIds(new Set());
|
||||
return [];
|
||||
}
|
||||
|
||||
const logs = spanData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
// Track span log IDs
|
||||
const logIds = new Set(logs.map((log: ILog) => log.id));
|
||||
setSpanLogIds(logIds);
|
||||
|
||||
return logs;
|
||||
}, [spanData]);
|
||||
|
||||
// Get first and last span logs for context queries
|
||||
const { firstSpanLog, lastSpanLog } = useMemo(() => {
|
||||
if (spanLogs.length === 0) return { firstSpanLog: null, lastSpanLog: null };
|
||||
|
||||
const sortedLogs = [...spanLogs].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
firstSpanLog: sortedLogs[0],
|
||||
lastSpanLog: sortedLogs[sortedLogs.length - 1],
|
||||
};
|
||||
}, [spanLogs]);
|
||||
// Phase 2: Fetch context logs before first span log
|
||||
const beforeFilter = useMemo(() => {
|
||||
if (!firstSpanLog) return null;
|
||||
return createContextFilters(traceId, firstSpanLog.id, 'lt');
|
||||
}, [traceId, firstSpanLog]);
|
||||
|
||||
const beforeQueryPayload = useMemo(() => {
|
||||
if (!beforeFilter) return null;
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
beforeFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, beforeFilter]);
|
||||
|
||||
const { data: beforeData, isFetching: isBeforeFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_BEFORE_LOGS,
|
||||
traceId,
|
||||
firstSpanLog?.id,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(beforeQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: !!beforeQueryPayload && !!firstSpanLog,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Phase 3: Fetch context logs after last span log
|
||||
const afterFilter = useMemo(() => {
|
||||
if (!lastSpanLog) return null;
|
||||
return createContextFilters(traceId, lastSpanLog.id, 'gt');
|
||||
}, [traceId, lastSpanLog]);
|
||||
|
||||
const afterQueryPayload = useMemo(() => {
|
||||
if (!afterFilter) return null;
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
afterFilter,
|
||||
'asc',
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, afterFilter]);
|
||||
|
||||
const { data: afterData, isFetching: isAfterFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_AFTER_LOGS,
|
||||
traceId,
|
||||
lastSpanLog?.id,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(afterQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: !!afterQueryPayload && !!lastSpanLog,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Extract context logs
|
||||
const beforeLogs = useMemo(() => {
|
||||
if (!beforeData?.payload?.data?.newResult?.data?.result?.[0]?.list) return [];
|
||||
|
||||
return beforeData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
}, [beforeData]);
|
||||
|
||||
const afterLogs = useMemo(() => {
|
||||
if (!afterData?.payload?.data?.newResult?.data?.result?.[0]?.list) return [];
|
||||
|
||||
return afterData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
}, [afterData]);
|
||||
|
||||
useEffect(() => {
|
||||
const combined = [...afterLogs.reverse(), ...spanLogs, ...beforeLogs];
|
||||
setAllLogs(combined);
|
||||
}, [beforeLogs, spanLogs, afterLogs]);
|
||||
|
||||
// Helper function to check if a log belongs to the span
|
||||
const isLogSpanRelated = useCallback(
|
||||
(logId: string): boolean => spanLogIds.has(logId),
|
||||
[spanLogIds],
|
||||
);
|
||||
|
||||
return {
|
||||
logs: allLogs,
|
||||
isLoading: isSpanLoading && spanLogs.length === 0,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||
spanLogIds,
|
||||
isLogSpanRelated,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
.span-related-signals-drawer {
|
||||
.ant-drawer-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 16px 15px;
|
||||
.title {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-divider {
|
||||
margin-inline-start: 10px !important;
|
||||
margin-inline-end: 16px !important;
|
||||
height: 16px;
|
||||
border-color: var(--bg-slate-500);
|
||||
}
|
||||
.ant-drawer-close {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.views-tabs-container {
|
||||
padding: 16px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.open-in-explorer {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
width: 114px;
|
||||
height: 32px;
|
||||
|
||||
.view-title {
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__applied-filters {
|
||||
padding: 11px;
|
||||
margin-inline: 16px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__filters-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__filter-tag {
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-300);
|
||||
cursor: default;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.infra-placeholder {
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
.infra-placeholder-content {
|
||||
text-align: center;
|
||||
color: var(--bg-slate-400);
|
||||
|
||||
svg {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
font-size: 16px;
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.span-related-signals-drawer {
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.views-tabs-container {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.views-tabs {
|
||||
.ant-radio-button-wrapper {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-300);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
&.selected_view {
|
||||
background: var(--bg-robin-500);
|
||||
border-color: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-robin-400);
|
||||
border-color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__applied-filters {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__filter-tag {
|
||||
background-color: var(--bg-vanilla-400);
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.infra-placeholder-content {
|
||||
color: var(--bg-ink-300);
|
||||
|
||||
svg {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
.open-in-explorer {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
.views-tabs-container {
|
||||
.ant-radio-button-wrapper {
|
||||
.view-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import './SpanRelatedSignals.styles.scss';
|
||||
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Drawer, Typography } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, X } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { RelatedSignalsViews } from '../constants';
|
||||
import SpanLogs from '../SpanLogs/SpanLogs';
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
interface AppliedFiltersProps {
|
||||
filters: TagFilterItem[];
|
||||
}
|
||||
|
||||
function AppliedFilters({ filters }: AppliedFiltersProps): JSX.Element {
|
||||
return (
|
||||
<div className="span-related-signals-drawer__applied-filters">
|
||||
<div className="span-related-signals-drawer__filters-list">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.id} className="span-related-signals-drawer__filter-tag">
|
||||
<Typography.Text>
|
||||
{filter.key?.key}={filter.value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpanRelatedSignalsProps {
|
||||
selectedSpan: Span;
|
||||
traceStartTime: number;
|
||||
traceEndTime: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialView: RelatedSignalsViews;
|
||||
}
|
||||
|
||||
function SpanRelatedSignals({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
isOpen,
|
||||
onClose,
|
||||
initialView,
|
||||
}: SpanRelatedSignalsProps): JSX.Element {
|
||||
const [selectedView, setSelectedView] = useState<RelatedSignalsViews>(
|
||||
initialView,
|
||||
);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleTabChange = useCallback((e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setSelectedView(RelatedSignalsViews.LOGS);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const appliedFilters = useMemo(
|
||||
(): TagFilterItem[] => [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.traceId,
|
||||
},
|
||||
],
|
||||
[selectedSpan.traceId],
|
||||
);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
|
||||
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
|
||||
|
||||
const traceIdFilter = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.traceId,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: traceIdFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
|
||||
searchParams.set(QueryParams.startTime, startTimeMs.toString());
|
||||
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||
|
||||
window.open(
|
||||
`${window.location.origin}${
|
||||
ROUTES.LOGS_EXPLORER
|
||||
}?${searchParams.toString()}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="50%"
|
||||
title={
|
||||
<>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text className="title">
|
||||
Related Signals - {selectedSpan.name}
|
||||
</Typography.Text>
|
||||
</>
|
||||
}
|
||||
placement="right"
|
||||
onClose={handleClose}
|
||||
open={isOpen}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
|
||||
}}
|
||||
className="span-related-signals-drawer"
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
{selectedSpan && (
|
||||
<div className="span-related-signals-drawer__content">
|
||||
<div className="views-tabs-container">
|
||||
<SignozRadioGroup
|
||||
value={selectedView}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<LogsIcon width={14} height={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.LOGS,
|
||||
},
|
||||
// {
|
||||
// label: (
|
||||
// <div className="view-title">
|
||||
// <LogsIcon width={14} height={14} />
|
||||
// Metrics
|
||||
// </div>
|
||||
// ),
|
||||
// value: RelatedSignalsViews.METRICS,
|
||||
// },
|
||||
// {
|
||||
// label: (
|
||||
// <div className="view-title">
|
||||
// <Server size={14} />
|
||||
// Infra
|
||||
// </div>
|
||||
// ),
|
||||
// value: RelatedSignalsViews.INFRA,
|
||||
// },
|
||||
]}
|
||||
onChange={handleTabChange}
|
||||
className="related-signals-radio"
|
||||
/>
|
||||
{selectedView === RelatedSignalsViews.LOGS && (
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="open-in-explorer"
|
||||
onClick={handleExplorerPageRedirect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedView === RelatedSignalsViews.LOGS && (
|
||||
<>
|
||||
<AppliedFilters filters={appliedFilters} />
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.traceId}
|
||||
spanId={selectedSpan.spanId}
|
||||
timeRange={{
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanRelatedSignals;
|
||||
@@ -20,6 +20,20 @@ const mockQueryClient = {
|
||||
fetchQuery: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
@@ -54,6 +68,8 @@ jest.mock('react-query', () => ({
|
||||
useQueryClient: (): any => mockQueryClient,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({ toast: jest.fn() }));
|
||||
|
||||
// Mock the API response for getAggregateKeys
|
||||
const mockAggregateKeysResponse = {
|
||||
payload: {
|
||||
@@ -123,12 +139,11 @@ const renderSpanDetailsDrawer = (span: Span = createMockSpan()): any => {
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={span}
|
||||
traceID={span.traceId}
|
||||
traceStartTime={span.timestamp}
|
||||
traceEndTime={span.timestamp + span.durationNano}
|
||||
/>
|
||||
</Route>
|
||||
</MemoryRouter>{' '}
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
import {
|
||||
expectedAfterFilterExpression,
|
||||
expectedBeforeFilterExpression,
|
||||
expectedSpanFilterExpression,
|
||||
mockAfterLogsResponse,
|
||||
mockBeforeLogsResponse,
|
||||
mockEmptyLogsResponse,
|
||||
mockSpan,
|
||||
mockSpanLogsResponse,
|
||||
} from './mockData';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${ROUTES.TRACE_DETAIL}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
});
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock uplot to avoid rendering issues
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: jest.fn().mockReturnValue('#1f77b4'),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
// eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name
|
||||
function ({ children }: any) {
|
||||
return <div data-testid="overlay-scrollbar">{children}</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock Virtuoso to avoid complex virtualization
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock RawLogView component
|
||||
jest.mock(
|
||||
'components/Logs/RawLogView',
|
||||
() =>
|
||||
// eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name
|
||||
function MockRawLogView({
|
||||
data,
|
||||
onLogClick,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
}: any) {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
data-testid={`raw-log-${data.id}`}
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||
title={helpTooltip}
|
||||
onClick={(e): void => onLogClick?.(data, e)}
|
||||
>
|
||||
<div>{data.body}</div>
|
||||
<div>{data.timestamp}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('SpanDetailsDrawer', () => {
|
||||
let apiCallHistory: any[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
apiCallHistory = [];
|
||||
mockSafeNavigate.mockClear();
|
||||
mockWindowOpen.mockClear();
|
||||
mockUpdateAllQueriesOperators.mockClear();
|
||||
|
||||
// Setup API call tracking
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||
apiCallHistory.push(query);
|
||||
|
||||
// Determine response based on v5 filter expressions
|
||||
const filterExpression =
|
||||
query.query?.builder?.queryData?.[0]?.filter?.expression;
|
||||
|
||||
if (!filterExpression) return Promise.resolve(mockEmptyLogsResponse);
|
||||
|
||||
// Check for span logs query (contains both trace_id and span_id)
|
||||
if (filterExpression.includes('span_id')) {
|
||||
return Promise.resolve(mockSpanLogsResponse);
|
||||
}
|
||||
// Check for before logs query (contains trace_id and id <)
|
||||
if (filterExpression.includes('id <')) {
|
||||
return Promise.resolve(mockBeforeLogsResponse);
|
||||
}
|
||||
// Check for after logs query (contains trace_id and id >)
|
||||
if (filterExpression.includes('id >')) {
|
||||
return Promise.resolve(mockAfterLogsResponse);
|
||||
}
|
||||
|
||||
return Promise.resolve(mockEmptyLogsResponse);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Mock QueryBuilder context value
|
||||
const mockQueryBuilderContextValue = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stagedQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
panelType: 'list',
|
||||
redirectWithQuery: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
handleStageQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
};
|
||||
|
||||
const renderSpanDetailsDrawer = (props = {}): void => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpan}
|
||||
traceStartTime={1640995200000} // 2022-01-01 00:00:00 in milliseconds
|
||||
traceEndTime={1640995260000} // 2022-01-01 00:01:00 in milliseconds
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it('should display logs tab in right sidebar when span is selected', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Verify logs tab is visible
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
expect(logsButton).toBeInTheDocument();
|
||||
expect(logsButton).toBeVisible();
|
||||
});
|
||||
|
||||
it('should open related logs view when logs tab is clicked', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on logs tab
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs view to open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('overlay-scrollbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify logs are displayed
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('raw-log-context-log-before')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should make three API queries when logs tab is opened', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on logs tab to trigger API calls
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for all API calls to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// Verify the three distinct queries were made
|
||||
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||
|
||||
// 1. Span logs query (trace_id + span_id)
|
||||
expect(spanQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedSpanFilterExpression,
|
||||
);
|
||||
|
||||
// 2. Before logs query (trace_id + id < first_span_log_id)
|
||||
expect(beforeQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedBeforeFilterExpression,
|
||||
);
|
||||
|
||||
// 3. After logs query (trace_id + id > last_span_log_id)
|
||||
expect(afterQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedAfterFilterExpression,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use correct timestamp ordering for different query types', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on logs tab to trigger API calls
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for all API calls to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||
|
||||
// Verify ordering: span query should use 'desc' (default)
|
||||
expect(spanQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
|
||||
|
||||
// Before query should use 'desc' (default)
|
||||
expect(beforeQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
|
||||
|
||||
// After query should use 'asc' for chronological order
|
||||
expect(afterQuery.query.builder.queryData[0].orderBy[0].order).toBe('asc');
|
||||
});
|
||||
|
||||
it('should navigate to logs explorer with span filters when span log is clicked', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on a span log (highlighted)
|
||||
const spanLog = screen.getByTestId('raw-log-span-log-1');
|
||||
fireEvent.click(spanLog);
|
||||
|
||||
// Verify window.open was called with correct parameters
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
// Check navigation URL contains expected parameters
|
||||
const navigationCall = mockWindowOpen.mock.calls[0][0];
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
expect(urlParams.get(QueryParams.activeLogId)).toBe('"span-log-1"');
|
||||
expect(urlParams.get(QueryParams.startTime)).toBe('1640994900000'); // traceStartTime - 5 minutes
|
||||
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000'); // traceEndTime + 5 minutes
|
||||
|
||||
// Verify composite query includes both trace_id and span_id filters
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
const { filter } = compositeQuery.builder.queryData[0];
|
||||
|
||||
// Check that the filter expression contains trace_id
|
||||
// Note: Current behavior uses only trace_id filter for navigation
|
||||
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
|
||||
|
||||
// Verify mockSafeNavigate was NOT called
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate to logs explorer with trace filter when context log is clicked', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('raw-log-context-log-before')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on a context log (non-highlighted)
|
||||
const contextLog = screen.getByTestId('raw-log-context-log-before');
|
||||
fireEvent.click(contextLog);
|
||||
|
||||
// Verify window.open was called
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
// Check navigation URL parameters
|
||||
const navigationCall = mockWindowOpen.mock.calls[0][0];
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
|
||||
|
||||
// Verify composite query includes only trace_id filter (no span_id for context logs)
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
const { filter } = compositeQuery.builder.queryData[0];
|
||||
|
||||
// Check that the filter expression contains trace_id but not span_id for context logs
|
||||
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
|
||||
// Context logs should not have span_id filter
|
||||
expect(filter.expression).not.toContain('span_id');
|
||||
|
||||
// Verify mockSafeNavigate was NOT called
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should always open logs explorer in new tab regardless of click type', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regular click on a log
|
||||
const spanLog = screen.getByTestId('raw-log-span-log-1');
|
||||
fireEvent.click(spanLog);
|
||||
|
||||
// Verify window.open was called for new tab
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
// Verify navigate was NOT called (always opens new tab)
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty logs state', async () => {
|
||||
// Mock empty response for all queries
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockEmptyLogsResponse);
|
||||
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait and verify empty state is shown
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No logs found for selected span/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display span logs as highlighted and context logs as regular', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify span logs are highlighted
|
||||
const spanLog1 = screen.getByTestId('raw-log-span-log-1');
|
||||
const spanLog2 = screen.getByTestId('raw-log-span-log-2');
|
||||
expect(spanLog1).toHaveClass('log-highlighted');
|
||||
expect(spanLog2).toHaveClass('log-highlighted');
|
||||
expect(spanLog1).toHaveAttribute(
|
||||
'title',
|
||||
'This log belongs to the current span',
|
||||
);
|
||||
|
||||
// Verify context logs are not highlighted
|
||||
const contextLogBefore = screen.getByTestId('raw-log-context-log-before');
|
||||
const contextLogAfter = screen.getByTestId('raw-log-context-log-after');
|
||||
expect(contextLogBefore).toHaveClass('log-context');
|
||||
expect(contextLogAfter).toHaveClass('log-context');
|
||||
expect(contextLogBefore).not.toHaveAttribute('title');
|
||||
});
|
||||
});
|
||||
209
frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts
Normal file
209
frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
// Constants
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SERVICE = 'test-service';
|
||||
|
||||
// Mock span data
|
||||
export const mockSpan: Span = {
|
||||
spanId: TEST_SPAN_ID,
|
||||
traceId: TEST_TRACE_ID,
|
||||
name: TEST_SERVICE,
|
||||
serviceName: TEST_SERVICE,
|
||||
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
|
||||
durationNano: 1000000000, // 1 second in nanoseconds
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'STATUS_CODE_OK',
|
||||
statusMessage: '',
|
||||
parentSpanId: '',
|
||||
references: [],
|
||||
event: [],
|
||||
tagMap: {
|
||||
'http.method': 'GET',
|
||||
'http.url': '/api/test',
|
||||
'http.status_code': '200',
|
||||
},
|
||||
hasError: false,
|
||||
rootSpanId: '',
|
||||
kind: 0,
|
||||
rootName: '',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
// Mock logs with proper relationships
|
||||
export const mockSpanLogs: ILog[] = [
|
||||
{
|
||||
id: 'span-log-1',
|
||||
timestamp: '2022-01-01T00:00:01.000Z',
|
||||
body: 'Processing request in span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: TEST_SPAN_ID,
|
||||
span_id: TEST_SPAN_ID,
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
{
|
||||
id: 'span-log-2',
|
||||
timestamp: '2022-01-01T00:00:02.000Z',
|
||||
body: 'Span operation completed',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: TEST_SPAN_ID,
|
||||
span_id: TEST_SPAN_ID,
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockContextLogs: ILog[] = [
|
||||
{
|
||||
id: 'context-log-before',
|
||||
timestamp: '2021-12-31T23:59:59.000Z',
|
||||
body: 'Context log before span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: 'different-span-id',
|
||||
span_id: 'different-span-id',
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
{
|
||||
id: 'context-log-after',
|
||||
timestamp: '2022-01-01T00:00:03.000Z',
|
||||
body: 'Context log after span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: 'another-different-span-id',
|
||||
span_id: 'another-different-span-id',
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Combined logs in chronological order
|
||||
export const mockAllLogs: ILog[] = [
|
||||
mockContextLogs[0], // before
|
||||
...mockSpanLogs, // span logs
|
||||
mockContextLogs[1], // after
|
||||
];
|
||||
|
||||
// Mock API responses
|
||||
export const mockSpanLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: mockSpanLogs.map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockBeforeLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [mockContextLogs[0]].map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAfterLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [mockContextLogs[1]].map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockEmptyLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Expected v5 filter expressions
|
||||
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
|
||||
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
|
||||
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
|
||||
11
frontend/src/container/SpanDetailsDrawer/constants.ts
Normal file
11
frontend/src/container/SpanDetailsDrawer/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export enum RelatedSignalsViews {
|
||||
LOGS = 'logs',
|
||||
// METRICS = 'metrics',
|
||||
// INFRA = 'infra',
|
||||
}
|
||||
|
||||
export const RELATED_SIGNALS_VIEW_TYPES = {
|
||||
LOGS: RelatedSignalsViews.LOGS,
|
||||
// METRICS: RelatedSignalsViews.METRICS,
|
||||
// INFRA: RelatedSignalsViews.INFRA,
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
@@ -53,20 +54,32 @@ function Filters({
|
||||
startTime,
|
||||
endTime,
|
||||
traceID,
|
||||
onFilteredSpansChange = (): void => {},
|
||||
}: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceID: string;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
const [noData, setNoData] = useState<boolean>(false);
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const handleFilterChange = (value: TagFilter): void => {
|
||||
setFilters(value);
|
||||
};
|
||||
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(value: TagFilter): void => {
|
||||
if (value.items.length === 0) {
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}
|
||||
setFilters(value);
|
||||
},
|
||||
[onFilteredSpansChange],
|
||||
);
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
@@ -115,16 +128,22 @@ function Filters({
|
||||
queryKey: [filters],
|
||||
enabled: filters.items.length > 0,
|
||||
onSuccess: (data) => {
|
||||
const isFilterActive = filters.items.length > 0;
|
||||
if (data?.payload.data.newResult.data.result[0].list) {
|
||||
const spanIds = data?.payload.data.newResult.data.result[0].list.map(
|
||||
(val) => val.data.spanID,
|
||||
const uniqueSpans = uniqBy(
|
||||
data?.payload.data.newResult.data.result[0].list,
|
||||
'data.spanID',
|
||||
);
|
||||
|
||||
const spanIds = uniqueSpans.map((val) => val.data.spanID);
|
||||
setFilteredSpanIds(spanIds);
|
||||
onFilteredSpansChange?.(spanIds, isFilterActive);
|
||||
handlePrevNext(0, spanIds[0]);
|
||||
setNoData(false);
|
||||
} else {
|
||||
setNoData(true);
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], isFilterActive);
|
||||
setCurrentSearchedIndex(0);
|
||||
}
|
||||
},
|
||||
@@ -180,4 +199,8 @@ function Filters({
|
||||
);
|
||||
}
|
||||
|
||||
Filters.defaultProps = {
|
||||
onFilteredSpansChange: undefined,
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
|
||||
@@ -315,7 +315,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.interested-span {
|
||||
.interested-span,
|
||||
.selected-non-matching-span {
|
||||
border-radius: 4px;
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
|
||||
@@ -323,6 +324,20 @@
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.dimmed-span {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.highlighted-span {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.selected-non-matching-span {
|
||||
.span-overview-content,
|
||||
.span-line-text {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.div-td + .div-td {
|
||||
|
||||
@@ -67,6 +67,8 @@ function SpanOverview({
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
traceMetadata,
|
||||
}: {
|
||||
span: Span;
|
||||
@@ -75,6 +77,8 @@ function SpanOverview({
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
traceMetadata: ITraceMetadata;
|
||||
}): JSX.Element {
|
||||
const isRootSpan = span.level === 0;
|
||||
@@ -85,13 +89,23 @@ function SpanOverview({
|
||||
color = `var(--bg-cherry-500)`;
|
||||
}
|
||||
|
||||
// Smart highlighting logic
|
||||
const isMatching =
|
||||
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
|
||||
const isSelected = selectedSpan?.spanId === span.spanId;
|
||||
const isDimmed = isFilterActive && !isMatching && !isSelected;
|
||||
const isHighlighted = isFilterActive && isMatching && !isSelected;
|
||||
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
|
||||
|
||||
return (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx(
|
||||
'span-overview',
|
||||
selectedSpan?.spanId === span.spanId ? 'interested-span' : '',
|
||||
)}
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
style={{
|
||||
paddingLeft: `${
|
||||
isRootSpan
|
||||
@@ -199,11 +213,15 @@ export function SpanDuration({
|
||||
traceMetadata,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}): JSX.Element {
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(
|
||||
span.durationNano / 1e6,
|
||||
@@ -224,6 +242,13 @@ export function SpanDuration({
|
||||
|
||||
const [hasActionButtons, setHasActionButtons] = useState(false);
|
||||
|
||||
const isMatching =
|
||||
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
|
||||
const isSelected = selectedSpan?.spanId === span.spanId;
|
||||
const isDimmed = isFilterActive && !isMatching && !isSelected;
|
||||
const isHighlighted = isFilterActive && isMatching && !isSelected;
|
||||
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
|
||||
|
||||
const handleMouseEnter = (): void => {
|
||||
setHasActionButtons(true);
|
||||
};
|
||||
@@ -256,10 +281,12 @@ export function SpanDuration({
|
||||
return (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx(
|
||||
'span-duration',
|
||||
selectedSpan?.spanId === span.spanId ? 'interested-span' : '',
|
||||
)}
|
||||
className={cx('span-duration', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(): void => {
|
||||
@@ -325,14 +352,17 @@ function getWaterfallColumns({
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: {
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
uncollapsedNodes: string[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}): ColumnDef<Span, any>[] {
|
||||
const waterfallColumns: ColumnDef<Span, any>[] = [
|
||||
columnDefHelper.display({
|
||||
@@ -347,6 +377,8 @@ function getWaterfallColumns({
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
handleAddSpanToFunnel={handleAddSpanToFunnel}
|
||||
traceMetadata={traceMetadata}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
),
|
||||
size: 450,
|
||||
@@ -371,6 +403,8 @@ function getWaterfallColumns({
|
||||
traceMetadata={traceMetadata}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
@@ -390,8 +424,19 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
|
||||
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean) => {
|
||||
setFilteredSpanIds(spanIds);
|
||||
setIsFilterActive(isActive);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCollapseUncollapse = useCallback(
|
||||
(spanId: string, collapse: boolean) => {
|
||||
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
|
||||
@@ -443,6 +488,8 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}),
|
||||
[
|
||||
handleCollapseUncollapse,
|
||||
@@ -451,6 +498,8 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -508,6 +557,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
startTime={traceMetadata.startTime / 1e3}
|
||||
endTime={traceMetadata.endTime / 1e3}
|
||||
traceID={traceMetadata.traceId}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
/>
|
||||
<TableV3
|
||||
columns={columns}
|
||||
|
||||
@@ -6,6 +6,14 @@ import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { SpanDuration } from '../Success';
|
||||
|
||||
// Constants to avoid string duplication
|
||||
const SPAN_DURATION_TEXT = '1.16 ms';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
const INTERESTED_SPAN_CLASS = 'interested-span';
|
||||
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
|
||||
const DIMMED_SPAN_CLASS = 'dimmed-span';
|
||||
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/useSafeNavigate');
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
@@ -87,11 +95,13 @@ describe('SpanDuration', () => {
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find and click the span duration element
|
||||
const spanElement = screen.getByText('1.16 ms');
|
||||
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
|
||||
fireEvent.click(spanElement);
|
||||
|
||||
// Verify setSelectedSpan was called with the correct span
|
||||
@@ -113,10 +123,12 @@ describe('SpanDuration', () => {
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen.getByText('1.16 ms');
|
||||
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
|
||||
|
||||
// Initially, action buttons should not be visible
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
@@ -139,10 +151,135 @@ describe('SpanDuration', () => {
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen.getByText('1.16 ms').closest('.span-duration');
|
||||
expect(spanElement).toHaveClass('interested-span');
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies highlighted-span class when span matches filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={[mockSpan.spanId]}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies dimmed-span class when span does not match filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={['other-span-id']}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={[mockSpan.spanId]}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={['different-span-id']}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected and no filter is active', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('dims span when filter is active but no matches found', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]} // Empty array but filter is active
|
||||
isFilterActive // This is the key difference
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,12 @@ jest.mock('@signozhq/badge', () => ({
|
||||
Badge: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/resizable', () => ({
|
||||
ResizableHandle: jest.fn(),
|
||||
ResizablePanel: jest.fn(),
|
||||
ResizablePanelGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@signozhq/resizable';
|
||||
import { Button, Tabs } from 'antd';
|
||||
import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
|
||||
import cx from 'classnames';
|
||||
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
|
||||
import SpanDetailsDrawer from 'container/SpanDetailsDrawer/SpanDetailsDrawer';
|
||||
import TraceMetadata from 'container/TraceMetadata/TraceMetadata';
|
||||
@@ -121,11 +127,8 @@ function TraceDetailsV2(): JSX.Element {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="trace-layout">
|
||||
<div
|
||||
className="trace-left-content"
|
||||
style={{ width: `calc(100% - ${isSpanDetailsDocked ? 48 : 330}px)` }}
|
||||
>
|
||||
<ResizablePanelGroup direction="horizontal" autoSaveId="trace-drawer">
|
||||
<ResizablePanel minSize={20} maxSize={80} className="trace-left-content">
|
||||
<TraceMetadata
|
||||
traceID={traceId}
|
||||
duration={
|
||||
@@ -144,16 +147,27 @@ function TraceDetailsV2(): JSX.Element {
|
||||
) : (
|
||||
<NoData />
|
||||
)}
|
||||
</div>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={isSpanDetailsDocked}
|
||||
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
|
||||
selectedSpan={selectedSpan}
|
||||
traceID={traceId}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle className="resizable-handle" />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={20}
|
||||
minSize={20}
|
||||
maxSize={50}
|
||||
className={cx('span-details-drawer', {
|
||||
'span-details-drawer-docked': isSpanDetailsDocked,
|
||||
})}
|
||||
>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={isSpanDetailsDocked}
|
||||
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
10
frontend/src/types/api/exportRawData/getExportRawData.ts
Normal file
10
frontend/src/types/api/exportRawData/getExportRawData.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface ExportRawDataProps {
|
||||
source: string;
|
||||
format: string;
|
||||
start: number;
|
||||
end: number;
|
||||
columns: string[];
|
||||
filter: string;
|
||||
orderBy: string;
|
||||
limit: number;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface ILog {
|
||||
id: string;
|
||||
traceId: string;
|
||||
spanID: string;
|
||||
span_id?: string;
|
||||
traceFlags: number;
|
||||
severityText: string;
|
||||
severityNumber: number;
|
||||
|
||||
@@ -5,7 +5,10 @@ export interface PayloadProps {
|
||||
result: QueryData[];
|
||||
}
|
||||
|
||||
export type ListItem = { timestamp: string; data: Omit<ILog, 'timestamp'> };
|
||||
export type ListItem = {
|
||||
timestamp: string;
|
||||
data: Omit<ILog, 'timestamp' | 'span_id'>;
|
||||
};
|
||||
|
||||
export interface QueryData {
|
||||
lowerBoundSeries?: [number, string][];
|
||||
|
||||
@@ -34,16 +34,18 @@ export const convertBuilderQueryToIBuilderQuery = (
|
||||
queryName: builderQuery.name,
|
||||
dataSource,
|
||||
legend: builderQuery.legend,
|
||||
groupBy: builderQuery.groupBy?.map((group) => ({
|
||||
key: group?.name,
|
||||
dataType: group?.fieldDataType,
|
||||
type: group?.fieldContext,
|
||||
id: `${group?.name}--${group?.fieldDataType}--${group?.fieldContext}`,
|
||||
})),
|
||||
orderBy: builderQuery.order?.map((order) => ({
|
||||
columnName: order?.key?.name,
|
||||
order: order?.direction,
|
||||
})),
|
||||
groupBy:
|
||||
builderQuery.groupBy?.map((group) => ({
|
||||
key: group?.name,
|
||||
dataType: group?.fieldDataType,
|
||||
type: group?.fieldContext,
|
||||
id: `${group?.name}--${group?.fieldDataType}--${group?.fieldContext}`,
|
||||
})) || [],
|
||||
orderBy:
|
||||
builderQuery.order?.map((order) => ({
|
||||
columnName: order?.key?.name,
|
||||
order: order?.direction,
|
||||
})) || [],
|
||||
} as unknown) as IBuilderQuery;
|
||||
|
||||
return result;
|
||||
|
||||
@@ -48,3 +48,13 @@ export const getHightLightedLogBackground = (
|
||||
if (!isHighlightedLog) return '';
|
||||
return `background-color: ${orange[3]};`;
|
||||
};
|
||||
|
||||
export const getCustomHighlightBackground = (
|
||||
isHighlighted = false,
|
||||
isDarkMode = true,
|
||||
$logType: string,
|
||||
): string => {
|
||||
if (!isHighlighted) return '';
|
||||
|
||||
return getActiveLogBackground(true, isDarkMode, $logType);
|
||||
};
|
||||
|
||||
@@ -4247,7 +4247,7 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/button@^0.0.2":
|
||||
"@signozhq/button@0.0.2", "@signozhq/button@^0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/button/-/button-0.0.2.tgz#c13edef1e735134b784a41f874b60a14bc16993f"
|
||||
integrity sha512-434/gbTykC00LrnzFPp7c33QPWZkf9n+8+SToLZFTB0rzcaS/xoB4b7QKhvk+8xLCj4zpw6BxfeRAL+gSoOUJw==
|
||||
@@ -4323,6 +4323,20 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/resizable@0.0.0":
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/resizable/-/resizable-0.0.0.tgz#a517818b9f9bcdaeafc55ae134be86522bc90e9f"
|
||||
integrity sha512-yAkJdMgTkh8kv42ZuabwTZguxalwYqIp4b44YdSrw6jRUSq9tscUBXVllNN79T71lPUtc5AV13uQ4Qm5AcfVbQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-slot" "^1.1.0"
|
||||
class-variance-authority "^0.7.0"
|
||||
clsx "^2.1.1"
|
||||
lucide-react "^0.445.0"
|
||||
react-resizable-panels "^3.0.5"
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/sonner@0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/sonner/-/sonner-0.1.0.tgz#1310cc530c60459608246550eb977a1ae27b6ce4"
|
||||
@@ -15439,6 +15453,11 @@ react-remove-scroll@^2.6.3:
|
||||
use-callback-ref "^1.3.3"
|
||||
use-sidecar "^1.1.3"
|
||||
|
||||
react-resizable-panels@^3.0.5:
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-3.0.5.tgz#50a20645263eed02344de4a70d1319bbc0014bbd"
|
||||
integrity sha512-3z1yN25DMTXLg2wfyFrW32r5k4WEcUa3F7cJ2EgtNK07lnOs4mpM8yWLGunCpkhcQRwJX4fqoLcIh/pHPxzlmQ==
|
||||
|
||||
react-resizable@3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.4.tgz"
|
||||
@@ -16103,6 +16122,13 @@ robust-predicates@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
||||
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
||||
|
||||
rrule@2.8.1:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.8.1.tgz#e8341a9ce3e68ce5b8da4d502e893cd9f286805e"
|
||||
integrity sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
rtl-css-js@^1.14.0, rtl-css-js@^1.16.1:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"
|
||||
|
||||
@@ -207,7 +207,7 @@ var (
|
||||
// TODO(srikanthccv): import metadata yaml from receivers and use generated files to check the metrics
|
||||
podMetricNamesToCheck = []string{
|
||||
GetDotMetrics("k8s_pod_cpu_usage"),
|
||||
GetDotMetrics("k8s_pod_memory_usage"),
|
||||
GetDotMetrics("k8s_pod_memory_working_set"),
|
||||
GetDotMetrics("k8s_pod_cpu_request_utilization"),
|
||||
GetDotMetrics("k8s_pod_memory_request_utilization"),
|
||||
GetDotMetrics("k8s_pod_cpu_limit_utilization"),
|
||||
@@ -218,7 +218,7 @@ var (
|
||||
nodeMetricNamesToCheck = []string{
|
||||
GetDotMetrics("k8s_node_cpu_usage"),
|
||||
GetDotMetrics("k8s_node_allocatable_cpu"),
|
||||
GetDotMetrics("k8s_node_memory_usage"),
|
||||
GetDotMetrics("k8s_node_memory_working_set"),
|
||||
GetDotMetrics("k8s_node_allocatable_memory"),
|
||||
GetDotMetrics("k8s_node_condition_ready"),
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ var (
|
||||
metricNamesForNodes = map[string]string{
|
||||
"cpu": GetDotMetrics("k8s_node_cpu_usage"),
|
||||
"cpu_allocatable": GetDotMetrics("k8s_node_allocatable_cpu"),
|
||||
"memory": GetDotMetrics("k8s_node_memory_usage"),
|
||||
"memory": GetDotMetrics("k8s_node_memory_working_set"),
|
||||
"memory_allocatable": GetDotMetrics("k8s_node_allocatable_memory"),
|
||||
"node_condition": GetDotMetrics("k8s_node_condition_ready"),
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ var (
|
||||
"cpu": GetDotMetrics("k8s_pod_cpu_usage"),
|
||||
"cpu_request": GetDotMetrics("k8s_pod_cpu_request_utilization"),
|
||||
"cpu_limit": GetDotMetrics("k8s_pod_cpu_limit_utilization"),
|
||||
"memory": GetDotMetrics("k8s_pod_memory_usage"),
|
||||
"memory": GetDotMetrics("k8s_pod_memory_working_set"),
|
||||
"memory_request": GetDotMetrics("k8s_pod_memory_request_utilization"),
|
||||
"memory_limit": GetDotMetrics("k8s_pod_memory_limit_utilization"),
|
||||
"restarts": GetDotMetrics("k8s_container_restarts"),
|
||||
|
||||
@@ -7,7 +7,7 @@ var (
|
||||
"cpu": GetDotMetrics("k8s_pod_cpu_usage"),
|
||||
"cpu_request": GetDotMetrics("k8s_pod_cpu_request_utilization"),
|
||||
"cpu_limit": GetDotMetrics("k8s_pod_cpu_limit_utilization"),
|
||||
"memory": GetDotMetrics("k8s_pod_memory_usage"),
|
||||
"memory": GetDotMetrics("k8s_pod_memory_working_set"),
|
||||
"memory_request": GetDotMetrics("k8s_pod_memory_request_utilization"),
|
||||
"memory_limit": GetDotMetrics("k8s_pod_memory_limit_utilization"),
|
||||
"restarts": GetDotMetrics("k8s_container_restarts"),
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
|
||||
var searchTroubleshootingGuideURL = "https://signoz.io/docs/userguide/search-troubleshooting/"
|
||||
|
||||
const stringMatchingOperatorDocURL = "https://signoz.io/docs/userguide/operators-reference/#string-matching-operators"
|
||||
|
||||
// filterExpressionVisitor implements the FilterQueryVisitor interface
|
||||
// to convert the parsed filter expressions into ClickHouse WHERE clause
|
||||
type filterExpressionVisitor struct {
|
||||
@@ -533,11 +535,13 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
if ctx.NOT() != nil {
|
||||
op = qbtypes.FilterOperatorNotLike
|
||||
}
|
||||
v.warnIfLikeWithoutWildcards("LIKE", value)
|
||||
} else if ctx.ILIKE() != nil {
|
||||
op = qbtypes.FilterOperatorILike
|
||||
if ctx.NOT() != nil {
|
||||
op = qbtypes.FilterOperatorNotILike
|
||||
}
|
||||
v.warnIfLikeWithoutWildcards("ILIKE", value)
|
||||
} else if ctx.REGEXP() != nil {
|
||||
op = qbtypes.FilterOperatorRegexp
|
||||
if ctx.NOT() != nil {
|
||||
@@ -571,6 +575,19 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
return "" // Should not happen with valid input
|
||||
}
|
||||
|
||||
// warnIfLikeWithoutWildcards adds a guidance warning when LIKE/ILIKE is used without wildcards
|
||||
func (v *filterExpressionVisitor) warnIfLikeWithoutWildcards(op string, value any) {
|
||||
if hasLikeWildcards(value) {
|
||||
return
|
||||
}
|
||||
|
||||
msg := op + " operator used without wildcards (% or _). Consider using = operator for exact matches or add wildcards for pattern matching."
|
||||
v.warnings = append(v.warnings, msg)
|
||||
if v.mainWarnURL == "" {
|
||||
v.mainWarnURL = stringMatchingOperatorDocURL
|
||||
}
|
||||
}
|
||||
|
||||
// VisitInClause handles IN expressions
|
||||
func (v *filterExpressionVisitor) VisitInClause(ctx *grammar.InClauseContext) any {
|
||||
if ctx.ValueList() != nil {
|
||||
@@ -871,6 +888,15 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
|
||||
return fieldKeysForName
|
||||
}
|
||||
|
||||
// hasLikeWildcards checks if a value contains LIKE wildcards (% or _)
|
||||
func hasLikeWildcards(value any) bool {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(str, "%") || strings.Contains(str, "_")
|
||||
}
|
||||
|
||||
func trimQuotes(txt string) string {
|
||||
if len(txt) >= 2 {
|
||||
if (txt[0] == '"' && txt[len(txt)-1] == '"') ||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
@@ -60,7 +59,8 @@ func (migration *queryBuilderV5Migration) getTraceDuplicateKeys(ctx context.Cont
|
||||
|
||||
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query trace duplicate keys: %w", err)
|
||||
migration.logger.WarnContext(ctx, "failed to query trace duplicate keys", "error", err)
|
||||
return nil, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -68,7 +68,8 @@ func (migration *queryBuilderV5Migration) getTraceDuplicateKeys(ctx context.Cont
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan trace duplicate key: %w", err)
|
||||
migration.logger.WarnContext(ctx, "failed to scan trace duplicate key", "error", err)
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
@@ -89,7 +90,8 @@ func (migration *queryBuilderV5Migration) getLogDuplicateKeys(ctx context.Contex
|
||||
|
||||
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query log duplicate keys: %w", err)
|
||||
migration.logger.WarnContext(ctx, "failed to query log duplicate keys", "error", err)
|
||||
return nil, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -97,7 +99,8 @@ func (migration *queryBuilderV5Migration) getLogDuplicateKeys(ctx context.Contex
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan log duplicate key: %w", err)
|
||||
migration.logger.WarnContext(ctx, "failed to scan log duplicate key", "error", err)
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
82
pkg/telemetrylogs/filter_expr_like_warning_test.go
Normal file
82
pkg/telemetrylogs/filter_expr_like_warning_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package telemetrylogs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLikeAndILikeWithoutWildcards_Warns Tests that LIKE/ILIKE without wildcards add warnings and include docs URL
|
||||
func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
|
||||
keys := buildCompleteFieldKeyMap()
|
||||
|
||||
opts := querybuilder.FilterExprVisitorOpts{
|
||||
Logger: instrumentationtest.New().Logger(),
|
||||
FieldMapper: fm,
|
||||
ConditionBuilder: cb,
|
||||
FieldKeys: keys,
|
||||
FullTextColumn: DefaultFullTextColumn,
|
||||
JsonBodyPrefix: BodyJSONStringSearchPrefix,
|
||||
JsonKeyToKey: GetBodyJSONKey,
|
||||
}
|
||||
|
||||
tests := []string{
|
||||
"service.name LIKE 'demo-backend'",
|
||||
"service.name ILIKE 'demo-backend'",
|
||||
"service.name NOT LIKE 'demo-backend'",
|
||||
"service.name NOT ILIKE 'demo-backend'",
|
||||
}
|
||||
|
||||
for _, expr := range tests {
|
||||
t.Run(expr, func(t *testing.T) {
|
||||
clause, err := querybuilder.PrepareWhereClause(expr, opts)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, clause)
|
||||
|
||||
require.NotEmpty(t, clause.Warnings, "expected warning for: %s", expr)
|
||||
require.Contains(t, clause.Warnings[0], "operator used without wildcards")
|
||||
require.Contains(t, clause.WarningsDocURL, "operators-reference/#string-matching-operators")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLikeAndILikeWithWildcards_NoWarn Tests that LIKE/ILIKE with wildcards do not add warnings
|
||||
func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
|
||||
keys := buildCompleteFieldKeyMap()
|
||||
|
||||
opts := querybuilder.FilterExprVisitorOpts{
|
||||
Logger: instrumentationtest.New().Logger(),
|
||||
FieldMapper: fm,
|
||||
ConditionBuilder: cb,
|
||||
FieldKeys: keys,
|
||||
FullTextColumn: DefaultFullTextColumn,
|
||||
JsonBodyPrefix: BodyJSONStringSearchPrefix,
|
||||
JsonKeyToKey: GetBodyJSONKey,
|
||||
}
|
||||
|
||||
tests := []string{
|
||||
"service.name LIKE 'demo-%'",
|
||||
"service.name LIKE '%demo'",
|
||||
"service.name ILIKE '_demo'",
|
||||
"service.name ILIKE '%demo%'",
|
||||
}
|
||||
|
||||
for _, expr := range tests {
|
||||
t.Run(expr, func(t *testing.T) {
|
||||
clause, err := querybuilder.PrepareWhereClause(expr, opts)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, clause)
|
||||
|
||||
require.Empty(t, clause.Warnings, "did not expect warnings for: %s", expr)
|
||||
require.Empty(t, clause.WarningsDocURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -390,7 +390,11 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
innerSB.Select("trace_id", "duration_nano", sqlbuilder.Escape("resource_string_service$$name as `service.name`"), "name")
|
||||
innerSB.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
innerSB.Where("parent_span_id = ''")
|
||||
innerSB.Where("trace_id GLOBAL IN __toe")
|
||||
|
||||
// this only helps when there is a filter
|
||||
if query.Filter != nil && query.Filter.Expression != "" {
|
||||
innerSB.Where("trace_id GLOBAL IN __toe")
|
||||
}
|
||||
|
||||
// Add time filter to inner query
|
||||
innerSB.Where(
|
||||
|
||||
@@ -552,6 +552,20 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "List query without any filter",
|
||||
requestType: qbtypes.RequestTypeTrace,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __toe AS (SELECT trace_id FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __toe_duration_sorted AS (SELECT trace_id, duration_nano, resource_string_service$$name as `service.name`, name FROM signoz_traces.distributed_signoz_index_v3 WHERE parent_span_id = '' AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY duration_nano DESC LIMIT 1 BY trace_id) SELECT __toe_duration_sorted.`service.name` AS `service.name`, __toe_duration_sorted.name AS `name`, count() AS span_count, __toe_duration_sorted.duration_nano AS `duration_nano`, __toe_duration_sorted.trace_id AS `trace_id` FROM __toe INNER JOIN __toe_duration_sorted ON __toe.trace_id = __toe_duration_sorted.trace_id GROUP BY trace_id, duration_nano, name, `service.name` ORDER BY duration_nano DESC LIMIT 1 BY trace_id LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
|
||||
@@ -14,9 +14,10 @@ var (
|
||||
traceExplorerRegex = regexp.MustCompile(`/traces-explorer(?:\?.*)?$`)
|
||||
metricsExplorerRegex = regexp.MustCompile(`/metrics-explorer/explorer(?:\?.*)?$`)
|
||||
meterRegex = regexp.MustCompile(`/meter(?:/.*)?(?:\?.*)?$`)
|
||||
dashboardRegex = regexp.MustCompile(`/dashboard/[a-zA-Z0-9\-]+/(new|edit)(?:\?.*)?$`)
|
||||
dashboardIDRegex = regexp.MustCompile(`/dashboard/([a-f0-9\-]+)/`)
|
||||
widgetIDRegex = regexp.MustCompile(`widgetId=([a-f0-9\-]+)`)
|
||||
dashboardOpenRegex = regexp.MustCompile(`/dashboard/[a-zA-Z0-9\-]+(?:\?.*)?$`)
|
||||
dashboardEditRegex = regexp.MustCompile(`/dashboard/[a-zA-Z0-9\-]+/(new|edit)(?:\?.*)?$`)
|
||||
dashboardIDRegex = regexp.MustCompile(`/dashboard/([a-zA-Z0-9\-]+)`)
|
||||
widgetIDRegex = regexp.MustCompile(`widgetId=([a-zA-Z0-9\-]+)`)
|
||||
ruleRegex = regexp.MustCompile(`/alerts/(new|edit)(?:\?.*)?$`)
|
||||
ruleIDRegex = regexp.MustCompile(`ruleId=(\d+)`)
|
||||
)
|
||||
@@ -61,11 +62,12 @@ func CommentFromHTTPRequest(req *http.Request) map[string]string {
|
||||
traceExplorerMatched := traceExplorerRegex.MatchString(referrer)
|
||||
metricsExplorerMatched := metricsExplorerRegex.MatchString(referrer)
|
||||
meterMatched := meterRegex.MatchString(referrer)
|
||||
dashboardMatched := dashboardRegex.MatchString(referrer)
|
||||
dashboardViewMatched := dashboardOpenRegex.MatchString(referrer)
|
||||
dashboardEditMatched := dashboardEditRegex.MatchString(referrer)
|
||||
ruleMatched := ruleRegex.MatchString(referrer)
|
||||
|
||||
switch {
|
||||
case dashboardMatched:
|
||||
case dashboardViewMatched, dashboardEditMatched:
|
||||
comments["module_name"] = "dashboard"
|
||||
case ruleMatched:
|
||||
comments["module_name"] = "rule"
|
||||
@@ -81,7 +83,7 @@ func CommentFromHTTPRequest(req *http.Request) map[string]string {
|
||||
return comments
|
||||
}
|
||||
|
||||
if dashboardMatched {
|
||||
if dashboardViewMatched || dashboardEditMatched {
|
||||
if matches := dashboardIDRegex.FindStringSubmatch(referrer); len(matches) > 1 {
|
||||
comments["dashboard_id"] = matches[1]
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ func TestCommentFromHTTPRequest(t *testing.T) {
|
||||
req: &http.Request{Header: http.Header{"Referer": {"https://signoz.io/dashboard/123/new"}}},
|
||||
expected: map[string]string{"http_path": "/dashboard/123/new", "module_name": "dashboard", "dashboard_id": "123"},
|
||||
},
|
||||
{
|
||||
name: "DashboardLandingPage",
|
||||
req: &http.Request{Header: http.Header{"Referer": {"https://signoz.io/dashboard/01982be0-d67e-7326-8955-2e99720a9f72?relativeTime=30m"}}},
|
||||
expected: map[string]string{"http_path": "/dashboard/01982be0-d67e-7326-8955-2e99720a9f72", "module_name": "dashboard", "dashboard_id": "01982be0-d67e-7326-8955-2e99720a9f72"},
|
||||
},
|
||||
{
|
||||
name: "Rule",
|
||||
req: &http.Request{Header: http.Header{"Referer": {"https://signoz.io/alerts/new"}}},
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/govaluate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -423,13 +424,25 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
|
||||
canDefaultZero := make(map[string]bool)
|
||||
for _, q := range r.CompositeQuery.Queries {
|
||||
if q.Type == QueryTypeBuilder {
|
||||
if query, ok := q.Spec.(QueryBuilderQuery[TraceAggregation]); ok {
|
||||
if len(query.Aggregations) == 1 && canDefaultZeroAgg(query.Aggregations[0].Expression) {
|
||||
canDefaultZero[query.Name] = true
|
||||
switch spec := q.Spec.(type) {
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
if len(spec.Aggregations) == 1 && canDefaultZeroAgg(spec.Aggregations[0].Expression) {
|
||||
canDefaultZero[spec.Name] = true
|
||||
}
|
||||
} else if query, ok := q.Spec.(QueryBuilderQuery[LogAggregation]); ok {
|
||||
if len(query.Aggregations) == 1 && canDefaultZeroAgg(query.Aggregations[0].Expression) {
|
||||
canDefaultZero[query.Name] = true
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
if len(spec.Aggregations) == 1 && canDefaultZeroAgg(spec.Aggregations[0].Expression) {
|
||||
canDefaultZero[spec.Name] = true
|
||||
}
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
if len(spec.Aggregations) == 1 {
|
||||
timeAgg := spec.Aggregations[0].TimeAggregation
|
||||
|
||||
if timeAgg == metrictypes.TimeAggregationCount ||
|
||||
timeAgg == metrictypes.TimeAggregationCountDistinct ||
|
||||
timeAgg == metrictypes.TimeAggregationRate ||
|
||||
timeAgg == metrictypes.TimeAggregationIncrease {
|
||||
canDefaultZero[spec.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1683,6 +1683,86 @@ func TestQueryRangeRequest_GetQueriesSupportingZeroDefault(t *testing.T) {
|
||||
"A": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test metrics",
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &Filter{
|
||||
Expression: "service.name = demo",
|
||||
},
|
||||
Aggregations: []MetricAggregation{
|
||||
{
|
||||
MetricName: "calls",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &Filter{
|
||||
Expression: "service.name = demo",
|
||||
},
|
||||
Aggregations: []MetricAggregation{
|
||||
{
|
||||
MetricName: "memory.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &Filter{
|
||||
Expression: "service.name = demo",
|
||||
},
|
||||
Aggregations: []MetricAggregation{
|
||||
{
|
||||
MetricName: "calls",
|
||||
TimeAggregation: metrictypes.TimeAggregationIncrease,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &Filter{
|
||||
Expression: "service.name = demo",
|
||||
},
|
||||
Aggregations: []MetricAggregation{
|
||||
{
|
||||
MetricName: "calls",
|
||||
TimeAggregation: metrictypes.TimeAggregationCount,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: map[string]bool{
|
||||
"A": true,
|
||||
"C": true,
|
||||
"D": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test min on logs - doesn't support zeroDefault",
|
||||
CompositeQuery: CompositeQuery{
|
||||
|
||||
Reference in New Issue
Block a user