mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-27 14:10:30 +01:00
Compare commits
1 Commits
chore/dril
...
fix/recurr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3d75f92a |
@@ -10,7 +10,6 @@ import cx from 'classnames';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -47,7 +46,6 @@ import {
|
||||
TextSelect,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
@@ -81,10 +79,6 @@ function LogDetailInner({
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
|
||||
|
||||
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.BODY_JSON_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const [filters, setFilters] = useState<TagFilter | null>(null);
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
@@ -214,29 +208,11 @@ function LogDetailInner({
|
||||
}
|
||||
};
|
||||
|
||||
const logBody = useMemo(() => {
|
||||
if (!isBodyJsonQueryEnabled) {
|
||||
return log?.body || '';
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(log?.body || '');
|
||||
|
||||
if (typeof json?.message === 'string' && json.message !== '') {
|
||||
return json.message;
|
||||
}
|
||||
|
||||
return log?.body || '';
|
||||
} catch (error) {
|
||||
return log?.body || '';
|
||||
}
|
||||
}, [isBodyJsonQueryEnabled, log?.body]);
|
||||
|
||||
const htmlBody = useMemo(
|
||||
() => ({
|
||||
__html: getSanitizedLogBody(logBody || '', { shouldEscapeHtml: true }),
|
||||
__html: getSanitizedLogBody(log?.body || '', { shouldEscapeHtml: true }),
|
||||
}),
|
||||
[logBody],
|
||||
[log?.body],
|
||||
);
|
||||
|
||||
const handleJSONCopy = (): void => {
|
||||
@@ -442,7 +418,7 @@ function LogDetailInner({
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(logBody)}
|
||||
title={removeEscapeCharacters(log?.body)}
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
|
||||
@@ -9,5 +9,4 @@ export enum FeatureKeys {
|
||||
ANOMALY_DETECTION = 'anomaly_detection',
|
||||
ONBOARDING_V3 = 'onboarding_v3',
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
BODY_JSON_ENABLED = 'body_json_enabled',
|
||||
}
|
||||
|
||||
@@ -104,12 +104,7 @@ export const usePanelContextMenu = ({
|
||||
}
|
||||
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, {
|
||||
...data.record,
|
||||
label: data.label,
|
||||
seriesColor: data.seriesColor,
|
||||
timeRange,
|
||||
});
|
||||
onClick(data.coord, { ...data.record, label: data.label, timeRange });
|
||||
}
|
||||
},
|
||||
[onClick, queryResponse],
|
||||
|
||||
@@ -8,19 +8,8 @@ import {
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { TitleWrapper } from './BodyTitleRenderer.styles';
|
||||
import { DROPDOWN_KEY } from './constant';
|
||||
@@ -36,32 +25,17 @@ function BodyTitleRenderer({
|
||||
parentIsArray = false,
|
||||
nodeKey,
|
||||
value,
|
||||
handleChangeSelectedView,
|
||||
}: BodyTitleRendererProps): JSX.Element {
|
||||
const { onAddToQuery } = useActiveLog();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const cleanedNodeKey = removeObjectFromString(nodeKey);
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.BODY_JSON_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
// Group by is supported only for body json query enabled and not for array elements
|
||||
const isGroupBySupported =
|
||||
isBodyJsonQueryEnabled && !cleanedNodeKey.includes('[]');
|
||||
|
||||
const filterHandler = (isFilterIn: boolean) => (): void => {
|
||||
if (parentIsArray) {
|
||||
onAddToQuery(
|
||||
generateFieldKeyForArray(
|
||||
cleanedNodeKey,
|
||||
removeObjectFromString(nodeKey),
|
||||
getDataTypes(value),
|
||||
isBodyJsonQueryEnabled,
|
||||
),
|
||||
`${value}`,
|
||||
isFilterIn
|
||||
@@ -71,7 +45,7 @@ function BodyTitleRenderer({
|
||||
);
|
||||
} else {
|
||||
onAddToQuery(
|
||||
`body.${cleanedNodeKey}`,
|
||||
`body.${removeObjectFromString(nodeKey)}`,
|
||||
`${value}`,
|
||||
isFilterIn ? OPERATORS['='] : OPERATORS['!='],
|
||||
getDataTypes(value),
|
||||
@@ -79,67 +53,10 @@ function BodyTitleRenderer({
|
||||
}
|
||||
};
|
||||
|
||||
const groupByHandler = useCallback((): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupByKey = parentIsArray
|
||||
? generateFieldKeyForArray(
|
||||
cleanedNodeKey,
|
||||
getDataTypes(value),
|
||||
isBodyJsonQueryEnabled,
|
||||
)
|
||||
: `body.${cleanedNodeKey}`;
|
||||
|
||||
const fieldDataType = getDataTypes(value);
|
||||
const normalizedDataType: DataTypes | undefined = Object.values(
|
||||
DataTypes,
|
||||
).includes(fieldDataType as DataTypes)
|
||||
? (fieldDataType as DataTypes)
|
||||
: undefined;
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
if (index === 0) {
|
||||
const newGroupByItem: BaseAutocompleteData = {
|
||||
key: groupByKey,
|
||||
type: '',
|
||||
dataType: normalizedDataType,
|
||||
};
|
||||
|
||||
return { ...item, groupBy: [...(item.groupBy || []), newGroupByItem] };
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
|
||||
}, [
|
||||
cleanedNodeKey,
|
||||
handleChangeSelectedView,
|
||||
isBodyJsonQueryEnabled,
|
||||
parentIsArray,
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
value,
|
||||
viewName,
|
||||
]);
|
||||
|
||||
const onClickHandler: MenuProps['onClick'] = (props): void => {
|
||||
const mapper = {
|
||||
[DROPDOWN_KEY.FILTER_IN]: filterHandler(true),
|
||||
[DROPDOWN_KEY.FILTER_OUT]: filterHandler(false),
|
||||
[DROPDOWN_KEY.GROUP_BY]: groupByHandler,
|
||||
};
|
||||
|
||||
const handler = mapper[props.key];
|
||||
@@ -159,14 +76,6 @@ function BodyTitleRenderer({
|
||||
key: DROPDOWN_KEY.FILTER_OUT,
|
||||
label: `Filter out ${value}`,
|
||||
},
|
||||
...(isGroupBySupported
|
||||
? [
|
||||
{
|
||||
key: DROPDOWN_KEY.GROUP_BY,
|
||||
label: `Group by ${nodeKey}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
onClick: onClickHandler,
|
||||
};
|
||||
@@ -175,6 +84,7 @@ function BodyTitleRenderer({
|
||||
(e: React.MouseEvent): void => {
|
||||
// Prevent tree node expansion/collapse
|
||||
e.stopPropagation();
|
||||
const cleanedKey = removeObjectFromString(nodeKey);
|
||||
let copyText: string;
|
||||
|
||||
// Check if value is an object or array
|
||||
@@ -196,8 +106,8 @@ function BodyTitleRenderer({
|
||||
|
||||
if (copyText) {
|
||||
const notificationMessage = isObject
|
||||
? `${cleanedNodeKey} object copied to clipboard`
|
||||
: `${cleanedNodeKey} copied to clipboard`;
|
||||
? `${cleanedKey} object copied to clipboard`
|
||||
: `${cleanedKey} copied to clipboard`;
|
||||
|
||||
notifications.success({
|
||||
message: notificationMessage,
|
||||
@@ -205,7 +115,7 @@ function BodyTitleRenderer({
|
||||
});
|
||||
}
|
||||
},
|
||||
[cleanedNodeKey, parentIsArray, setCopy, value, notifications],
|
||||
[nodeKey, parentIsArray, setCopy, value, notifications],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
@@ -7,7 +6,6 @@ export interface BodyTitleRendererProps {
|
||||
nodeKey: string;
|
||||
value: unknown;
|
||||
parentIsArray?: boolean;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}
|
||||
|
||||
export type AnyObject = { [key: string]: any };
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Popover, Spin, Tooltip, Tree } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
@@ -58,7 +57,7 @@ interface ITableViewActionsProps {
|
||||
}
|
||||
|
||||
// Memoized Tree Component
|
||||
const MemoizedTree = React.memo<{ treeData: DataNode[] }>(({ treeData }) => (
|
||||
const MemoizedTree = React.memo<{ treeData: any[] }>(({ treeData }) => (
|
||||
<Tree
|
||||
defaultExpandAll
|
||||
showLine
|
||||
@@ -75,54 +74,50 @@ const BodyContent: React.FC<{
|
||||
record: DataType;
|
||||
bodyHtml: { __html: string };
|
||||
textToCopy: string;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}> = React.memo(
|
||||
({ fieldData, record, bodyHtml, textToCopy, handleChangeSelectedView }) => {
|
||||
const { isLoading, treeData, error } = useAsyncJSONProcessing(
|
||||
fieldData.value,
|
||||
record.field === 'body',
|
||||
handleChangeSelectedView,
|
||||
}> = React.memo(({ fieldData, record, bodyHtml, textToCopy }) => {
|
||||
const { isLoading, treeData, error } = useAsyncJSONProcessing(
|
||||
fieldData.value,
|
||||
record.field === 'body',
|
||||
);
|
||||
|
||||
// Show JSON tree if available, otherwise show HTML content
|
||||
if (record.field === 'body' && treeData) {
|
||||
return <MemoizedTree treeData={treeData} />;
|
||||
}
|
||||
|
||||
if (record.field === 'body' && isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Spin size="small" />
|
||||
<span style={{ color: Color.BG_SIENNA_400 }}>Processing JSON...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show JSON tree if available, otherwise show HTML content
|
||||
if (record.field === 'body' && treeData) {
|
||||
return <MemoizedTree treeData={treeData} />;
|
||||
}
|
||||
if (record.field === 'body' && error) {
|
||||
return (
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
Error parsing Body JSON
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.field === 'body' && isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Spin size="small" />
|
||||
<span style={{ color: Color.BG_SIENNA_400 }}>Processing JSON...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.field === 'body' && error) {
|
||||
return (
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<CopyClipboardHOC entityKey="body" textToCopy={textToCopy}>
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
Error parsing Body JSON
|
||||
<span dangerouslySetInnerHTML={bodyHtml} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
</CopyClipboardHOC>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<CopyClipboardHOC entityKey="body" textToCopy={textToCopy}>
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={bodyHtml} />
|
||||
</span>
|
||||
</CopyClipboardHOC>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
BodyContent.displayName = 'BodyContent';
|
||||
|
||||
@@ -324,7 +319,6 @@ export default function TableViewActions(
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -348,7 +342,6 @@ export default function TableViewActions(
|
||||
fieldData,
|
||||
bodyHtml,
|
||||
textToCopy,
|
||||
handleChangeSelectedView,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
cleanTimestamp,
|
||||
]);
|
||||
@@ -362,7 +355,6 @@ export default function TableViewActions(
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isListViewPanel &&
|
||||
!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { jsonToDataNodes, recursiveParseJSON } from '../utils';
|
||||
|
||||
@@ -12,7 +9,6 @@ const MAX_BODY_BYTES = 100 * 1024; // 100 KB
|
||||
const useAsyncJSONProcessing = (
|
||||
value: string,
|
||||
shouldProcess: boolean,
|
||||
handleChangeSelectedView?: ChangeViewFunctionType,
|
||||
): {
|
||||
isLoading: boolean;
|
||||
treeData: any[] | null;
|
||||
@@ -29,10 +25,6 @@ const useAsyncJSONProcessing = (
|
||||
});
|
||||
|
||||
const processingRef = useRef<boolean>(false);
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.BODY_JSON_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
useEffect((): (() => void) => {
|
||||
@@ -55,10 +47,7 @@ const useAsyncJSONProcessing = (
|
||||
try {
|
||||
const parsedBody = recursiveParseJSON(value);
|
||||
if (!isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
const treeData = jsonToDataNodes(parsedBody);
|
||||
setJsonState({ isLoading: false, treeData, error: null });
|
||||
} else {
|
||||
setJsonState({ isLoading: false, treeData: null, error: null });
|
||||
@@ -84,10 +73,7 @@ const useAsyncJSONProcessing = (
|
||||
try {
|
||||
const parsedBody = recursiveParseJSON(value);
|
||||
if (!isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
const treeData = jsonToDataNodes(parsedBody);
|
||||
setJsonState({ isLoading: false, treeData, error: null });
|
||||
} else {
|
||||
setJsonState({ isLoading: false, treeData: null, error: null });
|
||||
@@ -115,7 +101,7 @@ const useAsyncJSONProcessing = (
|
||||
return (): void => {
|
||||
processingRef.current = false;
|
||||
};
|
||||
}, [value, shouldProcess, isBodyJsonQueryEnabled, handleChangeSelectedView]);
|
||||
}, [value, shouldProcess]);
|
||||
|
||||
return jsonState;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const DROPDOWN_KEY = {
|
||||
FILTER_IN: 'filterIn',
|
||||
FILTER_OUT: 'filterOut',
|
||||
GROUP_BY: 'groupBy',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Convert from 'ansi-to-html';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import dompurify from 'dompurify';
|
||||
import { uniqueId } from 'lodash-es';
|
||||
@@ -35,32 +34,13 @@ export const recursiveParseJSON = (obj: string): Record<string, unknown> => {
|
||||
}
|
||||
};
|
||||
|
||||
type JsonToDataNodesOptions = {
|
||||
parentKey?: string;
|
||||
parentIsArray?: boolean;
|
||||
isBodyJsonQueryEnabled?: boolean;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
};
|
||||
|
||||
type ComputeDataNodeOptions = {
|
||||
key: string;
|
||||
valueIsArray: boolean;
|
||||
value: unknown;
|
||||
nodeKey: string;
|
||||
parentIsArray: boolean;
|
||||
isBodyJsonQueryEnabled?: boolean;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
};
|
||||
|
||||
export const computeDataNode = ({
|
||||
key,
|
||||
valueIsArray,
|
||||
value,
|
||||
nodeKey,
|
||||
parentIsArray,
|
||||
isBodyJsonQueryEnabled = false,
|
||||
handleChangeSelectedView,
|
||||
}: ComputeDataNodeOptions): DataNode => ({
|
||||
export const computeDataNode = (
|
||||
key: string,
|
||||
valueIsArray: boolean,
|
||||
value: unknown,
|
||||
nodeKey: string,
|
||||
parentIsArray: boolean,
|
||||
): DataNode => ({
|
||||
key: uniqueId(),
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
@@ -68,30 +48,20 @@ export const computeDataNode = ({
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
children: jsonToDataNodes(value as Record<string, unknown>, {
|
||||
parentKey: valueIsArray
|
||||
? `${nodeKey}${isBodyJsonQueryEnabled ? '[]' : '[*]'}`
|
||||
: nodeKey,
|
||||
parentIsArray: valueIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
}),
|
||||
children: jsonToDataNodes(
|
||||
value as Record<string, unknown>,
|
||||
valueIsArray ? `${nodeKey}[*]` : nodeKey,
|
||||
valueIsArray,
|
||||
),
|
||||
});
|
||||
|
||||
export function jsonToDataNodes(
|
||||
json: Record<string, unknown>,
|
||||
options: JsonToDataNodesOptions = {},
|
||||
parentKey = '',
|
||||
parentIsArray = false,
|
||||
): DataNode[] {
|
||||
const {
|
||||
parentKey = '',
|
||||
parentIsArray = false,
|
||||
isBodyJsonQueryEnabled = false,
|
||||
handleChangeSelectedView,
|
||||
} = options;
|
||||
|
||||
return Object.entries(json).map(([key, value]) => {
|
||||
let nodeKey = parentKey || key;
|
||||
if (parentIsArray) {
|
||||
@@ -104,15 +74,7 @@ export function jsonToDataNodes(
|
||||
|
||||
if (parentIsArray) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode({
|
||||
key,
|
||||
valueIsArray,
|
||||
value,
|
||||
nodeKey,
|
||||
parentIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -123,31 +85,14 @@ export function jsonToDataNodes(
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
children: jsonToDataNodes(
|
||||
{},
|
||||
{
|
||||
parentKey: nodeKey,
|
||||
parentIsArray: valueIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
},
|
||||
),
|
||||
children: jsonToDataNodes({}, nodeKey, valueIsArray),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode({
|
||||
key,
|
||||
valueIsArray,
|
||||
value,
|
||||
nodeKey,
|
||||
parentIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
|
||||
}
|
||||
return {
|
||||
key: uniqueId(),
|
||||
@@ -157,7 +102,6 @@ export function jsonToDataNodes(
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -179,7 +123,6 @@ export function flattenObject(obj: AnyObject, prefix = ''): AnyObject {
|
||||
export const generateFieldKeyForArray = (
|
||||
fieldKey: string,
|
||||
dataType: DataTypes,
|
||||
isBodyJsonQueryEnabled = false,
|
||||
): string => {
|
||||
let lastDotIndex = fieldKey.lastIndexOf('.');
|
||||
let resultNodeKey = fieldKey;
|
||||
@@ -195,16 +138,6 @@ export const generateFieldKeyForArray = (
|
||||
newResultNodeKey = resultNodeKey.substring(0, lastDotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// When filtering for a value inside an array, the query builder expects the
|
||||
// last array segment to be referenced without the trailing `[]`.
|
||||
// Examples:
|
||||
// - has(body.config.features, 'fast_checkout')
|
||||
// - has(body.config.features[].items, 'pen')
|
||||
// - has(body.config.features[].items[].variants, 'ballpen')
|
||||
if (isBodyJsonQueryEnabled && newResultNodeKey.endsWith('[]')) {
|
||||
newResultNodeKey = newResultNodeKey.slice(0, -2);
|
||||
}
|
||||
return `body.${newResultNodeKey}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -196,7 +196,6 @@ export const getUplotClickData = ({
|
||||
coord: { x: number; y: number };
|
||||
record: { queryName: string; filters: FilterData[] };
|
||||
label: string | React.ReactNode;
|
||||
seriesColor?: string;
|
||||
} | null => {
|
||||
if (!queryData?.queryName || !metric) {
|
||||
return null;
|
||||
@@ -209,8 +208,6 @@ export const getUplotClickData = ({
|
||||
|
||||
// Generate label from focusedSeries data
|
||||
let label: string | React.ReactNode = '';
|
||||
const seriesColor = focusedSeries?.color;
|
||||
|
||||
if (focusedSeries && focusedSeries.seriesName) {
|
||||
label = (
|
||||
<span style={{ color: focusedSeries.color }}>
|
||||
@@ -226,7 +223,6 @@ export const getUplotClickData = ({
|
||||
},
|
||||
record,
|
||||
label,
|
||||
seriesColor,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -241,7 +237,6 @@ export const getPieChartClickData = (
|
||||
queryName: string;
|
||||
filters: FilterData[];
|
||||
label: string | React.ReactNode;
|
||||
seriesColor?: string;
|
||||
} | null => {
|
||||
const { metric, queryName } = arc.data.record;
|
||||
if (!queryName || !metric) {
|
||||
@@ -253,7 +248,6 @@ export const getPieChartClickData = (
|
||||
queryName,
|
||||
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
|
||||
label,
|
||||
seriesColor: arc.data.color,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface AggregateData {
|
||||
endTime: number;
|
||||
};
|
||||
label?: string | React.ReactNode;
|
||||
seriesColor?: string;
|
||||
}
|
||||
|
||||
const useAggregateDrilldown = ({
|
||||
|
||||
@@ -228,13 +228,7 @@ const useBaseAggregateOptions = ({
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
key={key}
|
||||
icon={
|
||||
isLoading ? (
|
||||
<LoadingOutlined spin />
|
||||
) : (
|
||||
<span style={{ color: aggregateData?.seriesColor }}>{icon}</span>
|
||||
)
|
||||
}
|
||||
icon={isLoading ? <LoadingOutlined spin /> : icon}
|
||||
onClick={(): void => onClick()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--muted-foreground);
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
@@ -20,10 +20,13 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover,
|
||||
&:hover {
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: var(--l2-background-hover);
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
@@ -44,8 +47,7 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger-background);
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--bg-cherry-100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,24 +74,73 @@
|
||||
}
|
||||
|
||||
.context-menu-header {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
// Target the popover inner specifically for context menu
|
||||
.context-menu .ant-popover-inner {
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
max-width: 300px;
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
padding: 12px 8px !important;
|
||||
// max-height: 254px !important;
|
||||
max-width: 300px !important;
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
.darkMode {
|
||||
.context-menu-item {
|
||||
color: var(--muted-foreground);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--bg-cherry-400);
|
||||
|
||||
.icon {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
// Set the menu popover background
|
||||
.context-menu .ant-popover-inner {
|
||||
background: var(--l1-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu backdrop overlay
|
||||
.context-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
|
||||
// Prevent any pointer events from reaching elements behind
|
||||
pointer-events: auto;
|
||||
|
||||
// Ensure it covers the entire viewport including any scrollable areas
|
||||
position: fixed !important;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
|
||||
)
|
||||
var ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
|
||||
|
||||
type MaintenanceStatus struct {
|
||||
valuer.String
|
||||
@@ -63,15 +61,15 @@ type StorablePlannedMaintenance struct {
|
||||
}
|
||||
|
||||
type PlannedMaintenance struct {
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule" required:"true"`
|
||||
RuleIDs []string `json:"alertIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule" required:"true"`
|
||||
RuleIDs []string `json:"alertIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
Status MaintenanceStatus `json:"status" required:"true"`
|
||||
Kind MaintenanceKind `json:"kind" required:"true"`
|
||||
}
|
||||
@@ -161,8 +159,11 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
|
||||
|
||||
currentTime := now.In(loc)
|
||||
|
||||
// fixed schedule
|
||||
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
|
||||
// fixed schedule — only when no recurrence is configured.
|
||||
// When recurrence is set, the recurring check below handles everything;
|
||||
// falling through here would cause the window to match the absolute
|
||||
// StartTime–EndTime range instead of the daily/weekly/monthly pattern.
|
||||
if m.Schedule.Recurrence == nil && !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
|
||||
startTime := m.Schedule.StartTime.In(loc)
|
||||
endTime := m.Schedule.EndTime.In(loc)
|
||||
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
|
||||
@@ -333,6 +334,11 @@ func (m *PlannedMaintenance) Validate() error {
|
||||
}
|
||||
|
||||
if m.Schedule.Recurrence != nil {
|
||||
// Fixed range and recurrence are mutually exclusive.
|
||||
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload,
|
||||
"cannot set both a fixed time range (startTime/endTime) and a recurrence; use one or the other")
|
||||
}
|
||||
if m.Schedule.Recurrence.RepeatType.IsZero() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing repeat type in the payload")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,7 +14,6 @@ func timePtr(t time.Time) *time.Time {
|
||||
}
|
||||
|
||||
func TestShouldSkipMaintenance(t *testing.T) {
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
maintenance *PlannedMaintenance
|
||||
@@ -499,7 +499,7 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 1, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 4, 1, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -508,14 +508,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 15, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 4, 15, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -524,14 +524,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
|
||||
ts: time.Date(2024, 4, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
@@ -540,14 +540,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
|
||||
ts: time.Date(2024, 4, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
@@ -556,14 +556,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 06, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 5, 6, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -572,14 +572,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 06, 14, 00, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 5, 6, 14, 0, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -588,13 +588,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 04, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 4, 4, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -603,13 +603,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 04, 14, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 4, 4, 14, 10, 0, 0, time.UTC),
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
@@ -618,13 +618,53 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 04, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 5, 4, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
// #7548: recurring daily with Schedule.StartTime/EndTime also set.
|
||||
// The recurrence should govern — not the fixed range.
|
||||
{
|
||||
name: "recurring-daily-with-fixed-times-outside-daily-window",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
// These fixed fields should be ignored when Recurrence is set.
|
||||
StartTime: time.Date(2024, 6, 1, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2024, 6, 30, 18, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 6, 1, 14, 0, 0, 0, time.UTC), // daily at 14:00
|
||||
Duration: valuer.MustParseTextDuration("2h"), // until 16:00
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
},
|
||||
},
|
||||
// 11:00 is inside the fixed range but outside the daily 14:00-16:00 window.
|
||||
// Before the fix this returned true (bug); after fix it returns false.
|
||||
ts: time.Date(2024, 6, 15, 11, 0, 0, 0, time.UTC),
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "recurring-daily-with-fixed-times-inside-daily-window",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 6, 1, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2024, 6, 30, 18, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 6, 1, 14, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
},
|
||||
},
|
||||
// 15:00 is inside the daily 14:00-16:00 window — should skip.
|
||||
ts: time.Date(2024, 6, 15, 15, 0, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
}
|
||||
@@ -636,3 +676,27 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #7548: Validate rejects payloads that set both fixed range and recurrence.
|
||||
func TestValidate_FixedAndRecurrenceMutuallyExclusive(t *testing.T) {
|
||||
m := PlannedMaintenance{
|
||||
Name: "test",
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
},
|
||||
}
|
||||
err := m.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when both fixed range and recurrence are set")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot set both") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user