mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-22 19:30:43 +01:00
Compare commits
7 Commits
main
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23f745c261 | ||
|
|
734107911e | ||
|
|
20368ed944 | ||
|
|
a10899acd2 | ||
|
|
ef659e8db6 | ||
|
|
fb4423862e | ||
|
|
e612715ee1 |
@@ -33,7 +33,6 @@
|
||||
"NOT_FOUND": "SigNoz | Page Not Found",
|
||||
"LOGS": "SigNoz | Logs",
|
||||
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
||||
"LIVE_LOGS": "SigNoz | Live Logs",
|
||||
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
||||
"PASSWORD_RESET": "SigNoz | Password Reset",
|
||||
"LIST_LICENSES": "SigNoz | List of Licenses",
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"NOT_FOUND": "SigNoz | Page Not Found",
|
||||
"LOGS": "SigNoz | Logs",
|
||||
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
||||
"LIVE_LOGS": "SigNoz | Live Logs",
|
||||
"LOGS_PIPELINES": "SigNoz | Logs Pipelines",
|
||||
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
||||
"PASSWORD_RESET": "SigNoz | Password Reset",
|
||||
|
||||
@@ -1596,7 +1596,6 @@ describe('PrivateRoute', () => {
|
||||
// route definition comes last, and both keys are authz-aware either way.
|
||||
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
|
||||
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
|
||||
METRICS_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
|
||||
@@ -154,10 +154,6 @@ export const Logs = Loadable(
|
||||
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
export const LiveLogs = Loadable(
|
||||
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
|
||||
);
|
||||
|
||||
export const PipelinePage = Loadable(
|
||||
() => import(/* webpackChunkName: "Pipelines" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
LogsIndexToFields,
|
||||
@@ -282,13 +281,6 @@ const routes: AppRoutes[] = [
|
||||
key: 'LOGS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LIVE_LOGS,
|
||||
exact: true,
|
||||
component: LiveLogs,
|
||||
key: 'LIVE_LOGS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LOGS_PIPELINES,
|
||||
exact: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DrawerProps } from 'antd';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { ActionItemProps } from 'container/LogDetailedView/ActionItem';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
|
||||
@@ -19,10 +19,6 @@ jest.mock('periscope/components/DataViewer', () => ({
|
||||
}));
|
||||
|
||||
// Force v2 for these tests regardless of route.
|
||||
jest.mock('../useIsLogDetailsV2', () => ({
|
||||
useIsLogDetailsV2: (): boolean => true,
|
||||
}));
|
||||
|
||||
const mockLog: ILog = {
|
||||
id: 'log-1',
|
||||
timestamp: '2024-01-15T09:45:30Z',
|
||||
@@ -58,7 +54,7 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
|
||||
);
|
||||
}
|
||||
|
||||
describe('LogDetail drawer — header (isLogDetailsV2)', () => {
|
||||
describe('LogDetail drawer — header', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import LogStateIndicator, {
|
||||
LogType,
|
||||
} from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -19,33 +13,24 @@ import ContextView from 'container/LogDetailedView/ContextView/ContextView';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import Overview from 'container/LogDetailedView/Overview';
|
||||
import {
|
||||
aggregateAttributesResourcesToString,
|
||||
getSanitizedLogBody,
|
||||
removeEscapeCharacters,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Braces,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Compass,
|
||||
Copy,
|
||||
Filter,
|
||||
Histogram,
|
||||
Table,
|
||||
TextSelect,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -56,7 +41,6 @@ import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
|
||||
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
|
||||
import LogHighlights from './LogHighlights/LogHighlights';
|
||||
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
|
||||
|
||||
import './LogDetails.styles.scss';
|
||||
|
||||
@@ -64,11 +48,8 @@ import './LogDetails.styles.scss';
|
||||
function LogDetailInner({
|
||||
log,
|
||||
onClose,
|
||||
onAddToQuery,
|
||||
onClickActionItem,
|
||||
selectedTab,
|
||||
isListViewPanel = false,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
logs,
|
||||
onNavigateLog,
|
||||
@@ -81,7 +62,6 @@ function LogDetailInner({
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>(
|
||||
initialContextQuery,
|
||||
);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
|
||||
|
||||
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
|
||||
@@ -94,8 +74,6 @@ function LogDetailInner({
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
// Handle clicks outside to close drawer, except on explicitly ignored regions
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
@@ -173,12 +151,6 @@ function LogDetailInner({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { onLogCopy } = useCopyLogLink(log?.id);
|
||||
|
||||
const LogJsonData = log ? aggregateAttributesResourcesToString(log) : '';
|
||||
|
||||
const handleModeChange = (value: string): void => {
|
||||
setSelectedView(value as VIEWS);
|
||||
setIsEdit(false);
|
||||
@@ -220,13 +192,6 @@ function LogDetailInner({
|
||||
[logBody],
|
||||
);
|
||||
|
||||
const handleJSONCopy = (): void => {
|
||||
copyToClipboard(LogJsonData);
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
};
|
||||
|
||||
const handleQueryExpressionChange = useCallback(
|
||||
(value: string, queryIndex: number) => {
|
||||
// update the query at the given index
|
||||
@@ -295,8 +260,6 @@ function LogDetailInner({
|
||||
}
|
||||
};
|
||||
|
||||
const logType = log?.attributes_string?.log_level || LogType.INFO;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
@@ -304,69 +267,15 @@ function LogDetailInner({
|
||||
maskClosable={false}
|
||||
getContainer={getContainer}
|
||||
title={
|
||||
isLogDetailsV2 ? (
|
||||
<LogDetailsHeader
|
||||
log={log}
|
||||
onNavigatePrev={goToPrev}
|
||||
onNavigateNext={goToNext}
|
||||
isPrevDisabled={isPrevDisabled}
|
||||
isNextDisabled={isNextDisabled}
|
||||
showOpenInExplorer={!!handleOpenInExplorer}
|
||||
onOpenInExplorer={handleOpenInExplorer}
|
||||
/>
|
||||
) : (
|
||||
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__title-left">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
|
||||
<Typography.Text className="title">Log details</Typography.Text>
|
||||
</div>
|
||||
<div className="log-detail-drawer__title-right">
|
||||
<div className="log-arrows">
|
||||
<Tooltip
|
||||
title={isPrevDisabled ? '' : 'Move to previous log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronUp size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-up"
|
||||
disabled={isPrevDisabled}
|
||||
onClick={goToPrev}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={isNextDisabled ? '' : 'Move to next log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronDown size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-down"
|
||||
disabled={isNextDisabled}
|
||||
onClick={goToNext}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{handleOpenInExplorer && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<Compass size={16} />}
|
||||
className="open-in-explorer-btn"
|
||||
onClick={handleOpenInExplorer}
|
||||
>
|
||||
Open in Explorer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<LogDetailsHeader
|
||||
log={log}
|
||||
onNavigatePrev={goToPrev}
|
||||
onNavigateNext={goToNext}
|
||||
isPrevDisabled={isPrevDisabled}
|
||||
isNextDisabled={isNextDisabled}
|
||||
showOpenInExplorer={!!handleOpenInExplorer}
|
||||
onOpenInExplorer={handleOpenInExplorer}
|
||||
/>
|
||||
}
|
||||
placement="right"
|
||||
onClose={drawerCloseHandler}
|
||||
@@ -385,15 +294,11 @@ function LogDetailInner({
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
{isLogDetailsV2 ? (
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
) : (
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
)}
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(logBody)}
|
||||
placement="left"
|
||||
@@ -405,9 +310,9 @@ function LogDetailInner({
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
</div>
|
||||
|
||||
{isLogDetailsV2 && <LogHighlights log={log} />}
|
||||
<LogHighlights log={log} />
|
||||
|
||||
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
|
||||
<div className="log-detail-drawer__section-divider" />
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
@@ -425,21 +330,6 @@ function LogDetailInner({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
|
||||
// JSON tab is redundant.
|
||||
...(isLogDetailsV2
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: VIEW_TYPES.JSON,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Braces size={14} />
|
||||
JSON
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]),
|
||||
{
|
||||
value: VIEW_TYPES.CONTEXT,
|
||||
label: (
|
||||
@@ -478,26 +368,6 @@ function LogDetailInner({
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* V2 moves copy actions into the header ⋯ menu */}
|
||||
{!isLogDetailsV2 && (
|
||||
<Tooltip
|
||||
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
|
||||
placement="topLeft"
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Copy size={12} />}
|
||||
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
@@ -514,19 +384,11 @@ function LogDetailInner({
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<Overview
|
||||
logData={log}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onClickActionItem}
|
||||
isListViewPanel={isListViewPanel}
|
||||
selectedOptions={options}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
|
||||
<JsonView data={LogJsonData} height="68vh" />
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
log={log}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useIsLogDetailsV2(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
pathname === ROUTES.LOGS_EXPLORER ||
|
||||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
|
||||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ReactNode, useCallback, useEffect } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Popover } from 'antd';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
|
||||
function CopyClipboardHOC({
|
||||
entityKey,
|
||||
textToCopy,
|
||||
tooltipText = 'Copy to clipboard',
|
||||
children,
|
||||
}: CopyClipboardHOCProps): JSX.Element {
|
||||
const [value, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
useEffect(() => {
|
||||
if (value.value) {
|
||||
const key = entityKey || '';
|
||||
|
||||
const notificationMessage = `${key} copied to clipboard`;
|
||||
|
||||
notifications.success({
|
||||
message: notificationMessage,
|
||||
key: notificationMessage,
|
||||
});
|
||||
}
|
||||
}, [value, notifications, entityKey]);
|
||||
|
||||
const onClick = useCallback((): void => {
|
||||
setCopy(textToCopy);
|
||||
}, [setCopy, textToCopy]);
|
||||
|
||||
return (
|
||||
<span onClick={onClick} role="presentation" tabIndex={-1}>
|
||||
<Popover
|
||||
placement="top"
|
||||
overlayClassName="drawer-popover"
|
||||
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyClipboardHOCProps {
|
||||
entityKey: string | undefined;
|
||||
textToCopy: string;
|
||||
tooltipText?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default CopyClipboardHOC;
|
||||
CopyClipboardHOC.defaultProps = {
|
||||
tooltipText: 'Copy to clipboard',
|
||||
};
|
||||
@@ -38,7 +38,6 @@ const ROUTES = {
|
||||
LOGS_BASE: '/logs',
|
||||
LOGS: '/logs/logs-explorer',
|
||||
LOGS_EXPLORER: '/logs/logs-explorer',
|
||||
LIVE_LOGS: '/logs/logs-explorer/live',
|
||||
LOGS_PIPELINES: '/logs/pipelines',
|
||||
PASSWORD_RESET: '/password-reset',
|
||||
LIST_LICENSES: '/licenses',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import type { ColumnsType } from 'antd/lib/table';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
|
||||
import styles from './EventsContent.module.scss';
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { CircleMinus, CirclePlus } from '@signozhq/icons';
|
||||
import { Button, Col, Popover } from 'antd';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
function ActionItem({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
onClickActionItem,
|
||||
}: ActionItemProps): JSX.Element {
|
||||
const handleClick = useCallback(
|
||||
(operator: string) => {
|
||||
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
|
||||
|
||||
onClickActionItem(fieldKey, validatedFieldValue, operator);
|
||||
},
|
||||
[onClickActionItem, fieldKey, fieldValue],
|
||||
);
|
||||
|
||||
const onClickHandler = useCallback(
|
||||
(operator: string) => (): void => {
|
||||
handleClick(operator);
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
const PopOverMenuContent = useMemo(
|
||||
() => (
|
||||
<Col>
|
||||
<Button type="text" size="small" onClick={onClickHandler(OPERATORS.IN)}>
|
||||
<CirclePlus size={12} /> Filter for value
|
||||
</Button>
|
||||
<br />
|
||||
<Button type="text" size="small" onClick={onClickHandler(OPERATORS.NIN)}>
|
||||
<CircleMinus size={12} /> Filter out value
|
||||
</Button>
|
||||
</Col>
|
||||
),
|
||||
[onClickHandler],
|
||||
);
|
||||
return (
|
||||
<Popover placement="bottomLeft" content={PopOverMenuContent} trigger="click">
|
||||
<Button type="text" size="small">
|
||||
...
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ActionItemProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
onClickActionItem: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default memo(ActionItem);
|
||||
@@ -1,14 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TitleWrapper = styled.span`
|
||||
user-select: text !important;
|
||||
cursor: text;
|
||||
|
||||
.hover-reveal {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover .hover-reveal {
|
||||
visibility: visible;
|
||||
}
|
||||
`;
|
||||
@@ -1,247 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import {
|
||||
type BaseMenuItem,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
negateOperator,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
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';
|
||||
import { BodyTitleRendererProps } from './LogDetailedView.types';
|
||||
import {
|
||||
generateFieldKeyForArray,
|
||||
getDataTypes,
|
||||
removeObjectFromString,
|
||||
} from './utils';
|
||||
|
||||
function BodyTitleRenderer({
|
||||
title,
|
||||
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 cleanedNodeKey = removeObjectFromString(nodeKey);
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.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,
|
||||
getDataTypes(value),
|
||||
isBodyJsonQueryEnabled,
|
||||
),
|
||||
`${value}`,
|
||||
isFilterIn
|
||||
? QUERY_BUILDER_FUNCTIONS.HAS
|
||||
: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
|
||||
parentIsArray ? getDataTypes([value]) : getDataTypes(value),
|
||||
);
|
||||
} else {
|
||||
onAddToQuery(
|
||||
`body.${cleanedNodeKey}`,
|
||||
`${value}`,
|
||||
isFilterIn ? OPERATORS['='] : OPERATORS['!='],
|
||||
getDataTypes(value),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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 = {
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
|
||||
}, [
|
||||
cleanedNodeKey,
|
||||
handleChangeSelectedView,
|
||||
isBodyJsonQueryEnabled,
|
||||
parentIsArray,
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
value,
|
||||
]);
|
||||
|
||||
const onClickHandler = (key: string): void => {
|
||||
const mapper = {
|
||||
[DROPDOWN_KEY.FILTER_IN]: filterHandler(true),
|
||||
[DROPDOWN_KEY.FILTER_OUT]: filterHandler(false),
|
||||
[DROPDOWN_KEY.GROUP_BY]: groupByHandler,
|
||||
};
|
||||
|
||||
const handler = mapper[key];
|
||||
|
||||
if (handler) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems: BaseMenuItem[] = [
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_IN,
|
||||
label: `Filter for ${value}`,
|
||||
},
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_OUT,
|
||||
label: `Filter out ${value}`,
|
||||
},
|
||||
...(isGroupBySupported
|
||||
? [
|
||||
{
|
||||
key: DROPDOWN_KEY.GROUP_BY,
|
||||
label: `Group by ${nodeKey}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
// Prevent tree node expansion/collapse
|
||||
e.stopPropagation();
|
||||
let copyText: string;
|
||||
|
||||
// Check if value is an object or array
|
||||
const isObject = typeof value === 'object' && value !== null;
|
||||
|
||||
if (isObject) {
|
||||
// For objects/arrays, stringify the entire structure
|
||||
copyText = JSON.stringify(value, null, 2);
|
||||
} else if (parentIsArray) {
|
||||
// array elements
|
||||
copyText = `${value}`;
|
||||
} else {
|
||||
// primitive values
|
||||
const valueStr = typeof value === 'string' ? value : String(value);
|
||||
copyText = valueStr;
|
||||
}
|
||||
|
||||
setCopy(copyText);
|
||||
|
||||
if (copyText) {
|
||||
const notificationMessage = isObject
|
||||
? `${cleanedNodeKey} object copied to clipboard`
|
||||
: `${cleanedNodeKey} copied to clipboard`;
|
||||
|
||||
notifications.success({
|
||||
message: notificationMessage,
|
||||
key: notificationMessage,
|
||||
});
|
||||
}
|
||||
},
|
||||
[cleanedNodeKey, parentIsArray, setCopy, value, notifications],
|
||||
);
|
||||
|
||||
return (
|
||||
<TitleWrapper onClick={handleNodeClick}>
|
||||
{typeof value !== 'object' && (
|
||||
<span
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<div data-log-detail-ignore="true">
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.key}
|
||||
onSelect={(): void => onClickHandler(item.key as string)}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
{!parentIsArray && typeof value !== 'object' && (
|
||||
<span>
|
||||
: <span style={{ color: orange[6] }}>{`${value}`}</span>
|
||||
</span>
|
||||
)}
|
||||
</TitleWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default BodyTitleRenderer;
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { JSONViewProps } from './LogDetailedView.types';
|
||||
import { aggregateAttributesResourcesToString } from './utils';
|
||||
|
||||
import './JsonView.styles.scss';
|
||||
|
||||
function JSONView({ logData }: JSONViewProps): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
|
||||
const LogJsonData = useMemo(
|
||||
() => aggregateAttributesResourcesToString(logData),
|
||||
[logData],
|
||||
);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const options: EditorProps['options'] = {
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
wordWrap: isWrapWord ? 'on' : 'off',
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
fontWeight: '400',
|
||||
// fontFamily: 'SF Mono',
|
||||
fontFamily: 'Geist Mono',
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
},
|
||||
folding: false,
|
||||
};
|
||||
|
||||
const handleWrapWord = (checked: boolean): void => {
|
||||
setIsWrapWord(checked);
|
||||
};
|
||||
|
||||
function setEditorTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme('my-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': Color.BG_INK_400,
|
||||
},
|
||||
// fontFamily: 'SF Mono',
|
||||
fontFamily: 'Space Mono',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.06,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="json-view-container">
|
||||
<MEditor
|
||||
value={LogJsonData}
|
||||
language="json"
|
||||
options={options}
|
||||
onChange={(): void => {}}
|
||||
height="68vh"
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
|
||||
<div className="json-view-footer">
|
||||
<div className="log-switch">
|
||||
<div className="wrap-word-switch">
|
||||
<Typography.Text>Wrap text</Typography.Text>
|
||||
<Switch value={isWrapWord} onChange={handleWrapWord} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JSONView;
|
||||
@@ -1,14 +1,5 @@
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
export interface BodyTitleRendererProps {
|
||||
title: string;
|
||||
nodeKey: string;
|
||||
value: unknown;
|
||||
parentIsArray?: boolean;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
export type AnyObject = { [key: string]: any };
|
||||
|
||||
@@ -22,6 +13,21 @@ export interface IFieldAttributes {
|
||||
logType?: MetricsType;
|
||||
}
|
||||
|
||||
export interface JSONViewProps {
|
||||
logData: ILog;
|
||||
/** One key/field/value row in an attribute table. */
|
||||
export interface DataType {
|
||||
key: string;
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ActionItemProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
onClickActionItem: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Collapse } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ReactNode } from 'react';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
|
||||
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import { useLogAttributeActions } from './hooks/useLogAttributeActions';
|
||||
import TableView from './TableView';
|
||||
import {
|
||||
aggregateAttributesResourcesToObject,
|
||||
buildPrettyViewData,
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
removeEscapeCharacters,
|
||||
} from './utils';
|
||||
|
||||
import './Overview.styles.scss';
|
||||
@@ -38,247 +19,58 @@ const MAX_BODY_SANITIZE_CHARS = 64 * 1024;
|
||||
interface OverviewProps {
|
||||
logData: ILog;
|
||||
isListViewPanel?: boolean;
|
||||
selectedOptions: OptionsQuery;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
}
|
||||
|
||||
type Props = OverviewProps &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||
|
||||
function Overview({
|
||||
logData,
|
||||
onAddToQuery,
|
||||
onClickActionItem,
|
||||
isListViewPanel = false,
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
const [isAttributesExpanded, setIsAttributesExpanded] =
|
||||
useState<boolean>(true);
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
}: OverviewProps): JSX.Element {
|
||||
const { actions, visibleActions } = useLogAttributeActions({
|
||||
handleChangeSelectedView,
|
||||
isListViewPanel,
|
||||
onApplyLogFilter,
|
||||
});
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
if (isLogDetailsV2) {
|
||||
const raw = aggregateAttributesResourcesToObject(logData);
|
||||
const prettyData = buildPrettyViewData(raw);
|
||||
return (
|
||||
<div className="overview-container">
|
||||
<DataViewer
|
||||
data={prettyData}
|
||||
drawerKey="logs-details"
|
||||
fontSize={13}
|
||||
prettyViewProps={{
|
||||
actions,
|
||||
visibleActions,
|
||||
renderLeafValue: (value, keyPath): ReactNode | undefined => {
|
||||
// Sanitize (unescape + ANSI→color) string values under `body`.
|
||||
// Skip huge ones (render raw, still safe) to avoid the sanitize
|
||||
// choke;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
keyPath[keyPath.length - 1] !== 'body' ||
|
||||
value.length > MAX_BODY_SANITIZE_CHARS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="log-body-value"
|
||||
// Safe: getSanitizedLogBody runs the value through dompurify.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
jsonString={JSON.stringify(raw, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const options: EditorProps['options'] = {
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
wordWrap: isWrapWord ? 'on' : 'off',
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
fontWeight: '400',
|
||||
fontFamily: 'Geist Mono',
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
},
|
||||
};
|
||||
|
||||
const handleWrapWord = (checked: boolean): void => {
|
||||
setIsWrapWord(checked);
|
||||
};
|
||||
|
||||
function setEditorTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme('my-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': Color.BG_INK_400,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleSearchVisible = (): void => {
|
||||
setIsSearchVisible(!isSearchVisible);
|
||||
};
|
||||
|
||||
const toogleAttributePanelOpenState = (): void => {
|
||||
setIsAttributesExpanded(!isAttributesExpanded);
|
||||
};
|
||||
const raw = aggregateAttributesResourcesToObject(logData);
|
||||
const prettyData = buildPrettyViewData(raw);
|
||||
|
||||
return (
|
||||
<div className="overview-container">
|
||||
<Collapse
|
||||
defaultActiveKey={['1']}
|
||||
expandIcon={(props): ReactNode =>
|
||||
props.isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Badge color="vanilla">
|
||||
<Typography.Text style={{ color: Color.BG_ROBIN_400 }}>
|
||||
body
|
||||
</Typography.Text>
|
||||
</Badge>
|
||||
),
|
||||
children: (
|
||||
<div className="logs-body-content">
|
||||
<MEditor
|
||||
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
|
||||
language="json"
|
||||
options={options}
|
||||
onChange={(): void => {}}
|
||||
height="20vh"
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
onMount={(_, monaco): void => {
|
||||
document.fonts.ready.then(() => {
|
||||
monaco.editor.remeasureFonts();
|
||||
});
|
||||
}}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
<Divider
|
||||
style={{
|
||||
margin: 0,
|
||||
border: isDarkMode
|
||||
? `1px solid ${Color.BG_SLATE_500}`
|
||||
: `1px solid ${Color.BG_VANILLA_200}`,
|
||||
}}
|
||||
/>
|
||||
<div className="log-switch">
|
||||
<div className="wrap-word-switch">
|
||||
<Typography.Text>Wrap text</Typography.Text>
|
||||
<Switch value={isWrapWord} onChange={handleWrapWord} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
// extra: <Badge className="tag" color="vanilla">JSON</Badge>,
|
||||
className: 'collapse-content',
|
||||
<DataViewer
|
||||
data={prettyData}
|
||||
drawerKey="logs-details"
|
||||
fontSize={13}
|
||||
prettyViewProps={{
|
||||
actions,
|
||||
visibleActions,
|
||||
renderLeafValue: (value, keyPath): ReactNode | undefined => {
|
||||
// Sanitize (unescape + ANSI→color) string values under `body`.
|
||||
// Skip huge ones (render raw, still safe) to avoid the sanitize
|
||||
// choke;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
keyPath[keyPath.length - 1] !== 'body' ||
|
||||
value.length > MAX_BODY_SANITIZE_CHARS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="log-body-value"
|
||||
// Safe: getSanitizedLogBody runs the value through dompurify.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
className="attribute-table"
|
||||
defaultActiveKey={['1']}
|
||||
bordered={false}
|
||||
expandIcon={(props): ReactNode =>
|
||||
props.isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div
|
||||
className="attribute-tab-header"
|
||||
onClick={toogleAttributePanelOpenState}
|
||||
>
|
||||
<Badge color="vanilla">
|
||||
<Typography.Text style={{ color: Color.BG_ROBIN_400 }}>
|
||||
Attributes
|
||||
</Typography.Text>
|
||||
</Badge>
|
||||
|
||||
{isAttributesExpanded && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="none"
|
||||
className="action-btn"
|
||||
prefix={<Search size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSearchVisible();
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{isSearchVisible && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search for a field..."
|
||||
className="search-input"
|
||||
value={fieldSearchInput}
|
||||
onChange={(e): void => setFieldSearchInput(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TableView
|
||||
logData={logData}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fieldSearchInput={fieldSearchInput}
|
||||
onClickActionItem={onClickActionItem}
|
||||
isListViewPanel={isListViewPanel}
|
||||
selectedOptions={selectedOptions}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
className: 'collapse-content attribute-collapse',
|
||||
},
|
||||
]}
|
||||
}}
|
||||
jsonString={JSON.stringify(raw, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -286,7 +78,6 @@ function Overview({
|
||||
|
||||
Overview.defaultProps = {
|
||||
isListViewPanel: false,
|
||||
listViewPanelSelectedFields: null,
|
||||
handleChangeSelectedView: undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
.attribute-table-container {
|
||||
.ant-table {
|
||||
background: var(--l2-background);
|
||||
|
||||
.ant-table-row:hover {
|
||||
.ant-table-cell {
|
||||
.value-field {
|
||||
.action-btn {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 16px;
|
||||
transform: translateY(-50%);
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attribute-name {
|
||||
.ant-btn {
|
||||
&:hover {
|
||||
background-color: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-pin {
|
||||
cursor: pointer;
|
||||
|
||||
padding: 14px 8px 8px;
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
|
||||
.log-attribute-pin {
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.pin-attribute-icon {
|
||||
border: none;
|
||||
|
||||
&.pinned svg {
|
||||
fill: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value-field-container {
|
||||
background: var(--l2-background);
|
||||
|
||||
&.attribute-pin {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.value-field {
|
||||
font-family: 'Geist Mono';
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: none;
|
||||
width: max-content;
|
||||
position: absolute;
|
||||
padding: 0 16px;
|
||||
right: 0;
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
padding: 2px 3px;
|
||||
gap: 3px;
|
||||
height: 18px;
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Link, Pin } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Space, TableColumnsType as ColumnsType, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import AddToQueryHOC, {
|
||||
AddToQueryHOCProps,
|
||||
} from 'components/Logs/AddToQueryHOC';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
import { fieldSearchFilter } from 'lib/logs/fieldSearch';
|
||||
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from './config';
|
||||
import FieldRenderer from './FieldRenderer';
|
||||
import TableViewActions from './TableView/TableViewActions';
|
||||
import {
|
||||
filterKeyForField,
|
||||
findKeyPath,
|
||||
flattenObject,
|
||||
getFieldAttributes,
|
||||
} from './utils';
|
||||
|
||||
import './TableView.styles.scss';
|
||||
|
||||
interface TableViewProps {
|
||||
logData: ILog;
|
||||
fieldSearchInput: string;
|
||||
selectedOptions: OptionsQuery;
|
||||
isListViewPanel?: boolean;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}
|
||||
|
||||
type Props = TableViewProps &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||
|
||||
function TableView({
|
||||
logData,
|
||||
fieldSearchInput,
|
||||
onAddToQuery,
|
||||
onClickActionItem,
|
||||
isListViewPanel = false,
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
}: Props): JSX.Element | null {
|
||||
const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||
const [isfilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [pinnedAttributes, setPinnedAttributes] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
const pinnedAttributes: Record<string, boolean> = {};
|
||||
|
||||
if (isListViewPanel) {
|
||||
listViewPanelSelectedFields?.forEach((val) => {
|
||||
const path = findKeyPath(logData, val.name, '');
|
||||
if (path) {
|
||||
pinnedAttributes[path] = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
selectedOptions.selectColumns.forEach((val) => {
|
||||
const path = findKeyPath(logData, val.name, '');
|
||||
if (path) {
|
||||
pinnedAttributes[path] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
// pin trace_id by default when present
|
||||
if (logData?.trace_id) {
|
||||
pinnedAttributes.trace_id = true;
|
||||
}
|
||||
|
||||
setPinnedAttributes(pinnedAttributes);
|
||||
}, [
|
||||
logData,
|
||||
selectedOptions.selectColumns,
|
||||
listViewPanelSelectedFields,
|
||||
isListViewPanel,
|
||||
]);
|
||||
|
||||
// When USE_JSON_BODY is enabled, body arrives as a pre-parsed object. Serialize it
|
||||
// back to a string so flattenObject keeps `body` as a single table row instead of
|
||||
// recursively expanding it into dotted sub-keys (body.message, body.foo.bar, …),
|
||||
// which would break the tree view in BodyContent that relies on record.field === 'body'.
|
||||
const flattenLogData: Record<string, string> | null = useMemo(() => {
|
||||
if (!logData) {
|
||||
return null;
|
||||
}
|
||||
const normalizedLog =
|
||||
typeof logData.body === 'object' && logData.body !== null
|
||||
? { ...logData, body: JSON.stringify(logData.body) }
|
||||
: logData;
|
||||
return flattenObject(normalizedLog);
|
||||
}, [logData]);
|
||||
|
||||
const handleClick = (
|
||||
operator: string,
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
dataType: string | undefined,
|
||||
fieldType: string | undefined,
|
||||
): void => {
|
||||
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
|
||||
if (onClickActionItem) {
|
||||
onClickActionItem(
|
||||
fieldKey,
|
||||
validatedFieldValue,
|
||||
operator,
|
||||
dataType as DataTypes,
|
||||
fieldType,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHandler =
|
||||
(
|
||||
operator: string,
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
dataType: string | undefined,
|
||||
fieldType: MetricsType | undefined,
|
||||
) =>
|
||||
(): void => {
|
||||
handleClick(operator, fieldKey, fieldValue, dataType, fieldType);
|
||||
if (operator === OPERATORS['=']) {
|
||||
setIsFilterInLoading(true);
|
||||
}
|
||||
if (operator === OPERATORS['!=']) {
|
||||
setIsFilterOutLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (logData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataSource =
|
||||
flattenLogData !== null &&
|
||||
Object.keys(flattenLogData)
|
||||
.filter((field) => fieldSearchFilter(field, fieldSearchInput))
|
||||
.map((key) => ({
|
||||
key,
|
||||
field: key,
|
||||
value: JSON.stringify(flattenLogData[key]),
|
||||
}));
|
||||
|
||||
const onTraceHandler = (
|
||||
record: DataType,
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
): void => {
|
||||
if (flattenLogData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const traceId = flattenLogData[record.field];
|
||||
|
||||
const spanId = flattenLogData?.span_id;
|
||||
|
||||
if (traceId) {
|
||||
const basePath = generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceId,
|
||||
});
|
||||
|
||||
const route = spanId ? `${basePath}?spanId=${spanId}` : basePath;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// open the trace in new tab
|
||||
openInNewTab(route);
|
||||
} else {
|
||||
history.push(route);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!dataSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DataType> = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'pin',
|
||||
key: 'pin',
|
||||
width: 5,
|
||||
align: 'left',
|
||||
className: 'attribute-pin value-field-container',
|
||||
render: (fieldData: Record<string, string>, record): JSX.Element => {
|
||||
let pinColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500;
|
||||
|
||||
if (pinnedAttributes[record?.key]) {
|
||||
pinColor = Color.BG_ROBIN_500;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="log-attribute-pin value-field">
|
||||
<div
|
||||
className={cx(
|
||||
'pin-attribute-icon',
|
||||
pinnedAttributes[record?.key] ? 'pinned' : '',
|
||||
)}
|
||||
>
|
||||
{pinnedAttributes[record?.key] && <Pin size={14} color={pinColor} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Field',
|
||||
dataIndex: 'field',
|
||||
key: 'field',
|
||||
width: 50,
|
||||
align: 'left',
|
||||
ellipsis: true,
|
||||
className: 'attribute-name',
|
||||
render: (field: string, record): JSX.Element => {
|
||||
const renderedField = <FieldRenderer field={field} />;
|
||||
|
||||
if (record.field === 'trace_id') {
|
||||
const traceId = flattenLogData[record.field];
|
||||
|
||||
return (
|
||||
<Space size="middle" className="log-attribute">
|
||||
<Typography.Text>{renderedField}</Typography.Text>
|
||||
|
||||
{traceId && (
|
||||
<Tooltip title="Inspect in Trace" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
onClick={(
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
): void => {
|
||||
onTraceHandler(record, event);
|
||||
}}
|
||||
>
|
||||
<Link size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldFilterKey = filterKeyForField(field);
|
||||
const { dataType } = getFieldAttributes(field);
|
||||
if (!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey)) {
|
||||
return (
|
||||
<AddToQueryHOC
|
||||
fieldKey={fieldFilterKey}
|
||||
fieldValue={flattenLogData[field]}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={FontSize.SMALL}
|
||||
dataType={dataType as DataTypes}
|
||||
>
|
||||
{renderedField}
|
||||
</AddToQueryHOC>
|
||||
);
|
||||
}
|
||||
return renderedField;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Value',
|
||||
key: 'value',
|
||||
width: 70,
|
||||
ellipsis: false,
|
||||
className: 'value-field-container attribute-value',
|
||||
render: (fieldData: Record<string, string>, record): JSX.Element => (
|
||||
<TableViewActions
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isfilterInLoading={isfilterInLoading}
|
||||
isfilterOutLoading={isfilterOutLoading}
|
||||
onClickHandler={onClickHandler}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
function sortPinnedAttributes(
|
||||
data: Record<string, string>[],
|
||||
sortingObj: Record<string, boolean>,
|
||||
): Record<string, string>[] {
|
||||
const sortingKeys = Object.keys(sortingObj);
|
||||
return data.sort((a, b) => {
|
||||
const aKey = a.key;
|
||||
const bKey = b.key;
|
||||
const aSortIndex = sortingKeys.indexOf(aKey);
|
||||
const bSortIndex = sortingKeys.indexOf(bKey);
|
||||
|
||||
if (sortingObj[aKey] && !sortingObj[bKey]) {
|
||||
return -1;
|
||||
}
|
||||
if (!sortingObj[aKey] && sortingObj[bKey]) {
|
||||
return 1;
|
||||
}
|
||||
return aSortIndex - bSortIndex;
|
||||
});
|
||||
}
|
||||
|
||||
const sortedAttributes = sortPinnedAttributes(dataSource, pinnedAttributes);
|
||||
|
||||
return (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={sortedAttributes}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className="attribute-table-container"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TableView.defaultProps = {
|
||||
isListViewPanel: false,
|
||||
listViewPanelSelectedFields: null,
|
||||
handleChangeSelectedView: undefined,
|
||||
};
|
||||
|
||||
export interface DataType {
|
||||
key: string;
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default TableView;
|
||||
@@ -1,62 +0,0 @@
|
||||
.open-popover {
|
||||
&.value-field {
|
||||
.action-btn {
|
||||
display: flex !important;
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
right: 16px !important;
|
||||
transform: translateY(-50%) !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selectable-tree {
|
||||
.ant-tree-node-content-wrapper {
|
||||
user-select: text !important;
|
||||
cursor: text !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ant-tree-title {
|
||||
user-select: text !important;
|
||||
cursor: text !important;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.table-view-actions-content {
|
||||
.ant-popover-inner {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0px;
|
||||
.more-filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
padding: 12px 18px 12px 14px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.more-filter-actions:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
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';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import {
|
||||
ArrowDownToDot,
|
||||
ArrowUpFromDot,
|
||||
Ellipsis,
|
||||
RefreshCw,
|
||||
} from '@signozhq/icons';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { RESTRICTED_SELECTED_FIELDS } from '../config';
|
||||
import { DataType } from '../TableView';
|
||||
import {
|
||||
filterKeyForField,
|
||||
getFieldAttributes,
|
||||
getSanitizedLogBody,
|
||||
parseFieldValue,
|
||||
removeEscapeCharacters,
|
||||
} from '../utils';
|
||||
import useAsyncJSONProcessing from './useAsyncJSONProcessing';
|
||||
|
||||
import './TableViewActions.styles.scss';
|
||||
|
||||
interface ITableViewActionsProps {
|
||||
fieldData: Record<string, string>;
|
||||
record: DataType;
|
||||
isListViewPanel: boolean;
|
||||
isfilterInLoading: boolean;
|
||||
isfilterOutLoading: boolean;
|
||||
onClickHandler: (
|
||||
operator: string,
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
dataType: string | undefined,
|
||||
logType: MetricsType | undefined,
|
||||
) => () => void;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}
|
||||
|
||||
// Memoized Tree Component
|
||||
const MemoizedTree = React.memo<{ treeData: DataNode[] }>(({ treeData }) => (
|
||||
<Tree
|
||||
defaultExpandAll
|
||||
showLine
|
||||
treeData={treeData}
|
||||
className="selectable-tree"
|
||||
/>
|
||||
));
|
||||
|
||||
MemoizedTree.displayName = 'MemoizedTree';
|
||||
|
||||
// Body Content Component
|
||||
const BodyContent: React.FC<{
|
||||
fieldData: Record<string, string>;
|
||||
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,
|
||||
);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
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') {
|
||||
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;
|
||||
},
|
||||
);
|
||||
|
||||
BodyContent.displayName = 'BodyContent';
|
||||
|
||||
export default function TableViewActions(
|
||||
props: ITableViewActionsProps,
|
||||
): React.ReactElement {
|
||||
const {
|
||||
fieldData,
|
||||
record,
|
||||
isListViewPanel,
|
||||
isfilterInLoading,
|
||||
isfilterOutLoading,
|
||||
onClickHandler,
|
||||
handleChangeSelectedView,
|
||||
} = props;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
|
||||
|
||||
// there is no option for where clause in live logs page or infra monitoring
|
||||
const isLiveLogsOrInfraPage = useMemo(
|
||||
() =>
|
||||
pathname === ROUTES.LIVE_LOGS ||
|
||||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_HOSTS ||
|
||||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// Memoize bodyHtml computation
|
||||
const bodyHtml = useMemo(() => {
|
||||
if (record.field !== 'body') {
|
||||
return { __html: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
__html: getSanitizedLogBody(record.value, { shouldEscapeHtml: true }),
|
||||
};
|
||||
}, [record.field, record.value]);
|
||||
|
||||
const fieldFilterKey = filterKeyForField(fieldData.field);
|
||||
|
||||
const handleGroupByAttribute = useCallback((): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const normalizedDataType: DataTypes | undefined =
|
||||
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
|
||||
? (dataType as DataTypes)
|
||||
: undefined;
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
// Only add groupBy for index 0
|
||||
if (index === 0) {
|
||||
const newGroupByItem: BaseAutocompleteData = {
|
||||
key: fieldFilterKey,
|
||||
type: fieldType || '',
|
||||
dataType: normalizedDataType,
|
||||
};
|
||||
|
||||
const updatedGroupBy = [...(item.groupBy || []), newGroupByItem];
|
||||
|
||||
return { ...item, groupBy: updatedGroupBy };
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
|
||||
}, [
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
fieldFilterKey,
|
||||
fieldType,
|
||||
dataType,
|
||||
handleChangeSelectedView,
|
||||
]);
|
||||
|
||||
const handleReplaceFilter = useCallback((): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const normalizedDataType: DataTypes | undefined =
|
||||
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
|
||||
? (dataType as DataTypes)
|
||||
: undefined;
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
// Only replace filters for index 0
|
||||
if (index === 0) {
|
||||
const newFilterItem: BaseAutocompleteData = {
|
||||
key: fieldFilterKey,
|
||||
type: fieldType || '',
|
||||
dataType: normalizedDataType,
|
||||
};
|
||||
|
||||
// Create new filter items array with single IN filter
|
||||
const newFilters = {
|
||||
items: [
|
||||
{
|
||||
id: '',
|
||||
key: newFilterItem,
|
||||
op: OPERATORS.IN,
|
||||
value: [parseFieldValue(fieldData.value)],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
// Clear the expression and update filters
|
||||
return {
|
||||
...item,
|
||||
filters: newFilters,
|
||||
filter: { expression: '' },
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
|
||||
}, [
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
fieldFilterKey,
|
||||
fieldType,
|
||||
dataType,
|
||||
fieldData,
|
||||
handleChangeSelectedView,
|
||||
]);
|
||||
|
||||
// Memoize textToCopy computation
|
||||
const textToCopy = useMemo(() => {
|
||||
let text = fieldData.value;
|
||||
try {
|
||||
text = text.replace(/^"|"$/g, '');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to remove starting and ending quotes from the value',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// If the value is valid JSON (object or array), pretty-print it for copying
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
} catch {
|
||||
// not JSON, return as-is
|
||||
}
|
||||
return text;
|
||||
}, [fieldData.value]);
|
||||
|
||||
// Memoize cleanTimestamp computation
|
||||
const cleanTimestamp = useMemo(() => {
|
||||
if (record.field !== 'timestamp') {
|
||||
return '';
|
||||
}
|
||||
return fieldData.value.replace(/^["']|["']$/g, '');
|
||||
}, [record.field, fieldData.value]);
|
||||
|
||||
const renderFieldContent = useCallback((): JSX.Element => {
|
||||
const commonStyles: React.CSSProperties = {
|
||||
color: Color.BG_SIENNA_400,
|
||||
whiteSpace: 'pre-wrap',
|
||||
tabSize: 4,
|
||||
};
|
||||
|
||||
switch (record.field) {
|
||||
case 'body':
|
||||
return (
|
||||
<BodyContent
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'timestamp':
|
||||
return (
|
||||
<span style={commonStyles}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
cleanTimestamp,
|
||||
DATE_TIME_FORMATS.UTC_US_MS,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<span style={commonStyles}>{removeEscapeCharacters(fieldData.value)}</span>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
record,
|
||||
fieldData,
|
||||
bodyHtml,
|
||||
textToCopy,
|
||||
handleChangeSelectedView,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
cleanTimestamp,
|
||||
]);
|
||||
|
||||
// Early return for body field with async processing
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
<BodyContent
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isListViewPanel &&
|
||||
!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
icon={
|
||||
isfilterInLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={onClickHandler(
|
||||
OPERATORS['='],
|
||||
fieldFilterKey,
|
||||
parseFieldValue(fieldData.value),
|
||||
dataType,
|
||||
fieldType,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Filter out value" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
icon={
|
||||
isfilterOutLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={onClickHandler(
|
||||
OPERATORS['!='],
|
||||
fieldFilterKey,
|
||||
parseFieldValue(fieldData.value),
|
||||
dataType,
|
||||
fieldType,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isLiveLogsOrInfraPage && (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
arrow={false}
|
||||
content={
|
||||
<div data-log-detail-ignore="true">
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupByAttribute}
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleReplaceFilter}
|
||||
>
|
||||
Replace filters with this value
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="table-view-actions-content"
|
||||
trigger="hover"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
className="filter-btn periscope-btn"
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
|
||||
{renderFieldContent()}
|
||||
</CopyClipboardHOC>
|
||||
{!isListViewPanel &&
|
||||
!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
icon={
|
||||
isfilterInLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={onClickHandler(
|
||||
OPERATORS['='],
|
||||
fieldFilterKey,
|
||||
parseFieldValue(fieldData.value),
|
||||
dataType,
|
||||
fieldType,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Filter out value" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
icon={
|
||||
isfilterOutLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={onClickHandler(
|
||||
OPERATORS['!='],
|
||||
fieldFilterKey,
|
||||
parseFieldValue(fieldData.value),
|
||||
dataType,
|
||||
fieldType,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isLiveLogsOrInfraPage && (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
arrow={false}
|
||||
content={
|
||||
<div data-log-detail-ignore="true">
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupByAttribute}
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleReplaceFilter}
|
||||
>
|
||||
Replace filters with this value
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="table-view-actions-content"
|
||||
trigger="hover"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
className="filter-btn periscope-btn"
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TableViewActions.defaultProps = {
|
||||
handleChangeSelectedView: undefined,
|
||||
};
|
||||
@@ -1,366 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
import { RESTRICTED_SELECTED_FIELDS } from '../../config';
|
||||
import TableViewActions from '../TableViewActions';
|
||||
import useAsyncJSONProcessing from '../useAsyncJSONProcessing';
|
||||
|
||||
// Mock data for tests
|
||||
let mockCopyToClipboard: jest.Mock;
|
||||
let mockNotificationsSuccess: jest.Mock;
|
||||
|
||||
// Mock the components and hooks
|
||||
jest.mock('components/Logs/CopyClipboardHOC', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
textToCopy,
|
||||
entityKey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
textToCopy: string;
|
||||
entityKey: string;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
className="CopyClipboardHOC"
|
||||
data-testid={`copy-clipboard-${entityKey}`}
|
||||
data-text-to-copy={textToCopy}
|
||||
onClick={(): void => {
|
||||
if (mockCopyToClipboard) {
|
||||
mockCopyToClipboard(textToCopy);
|
||||
}
|
||||
if (mockNotificationsSuccess) {
|
||||
mockNotificationsSuccess({
|
||||
message: `${entityKey} copied to clipboard`,
|
||||
key: `${entityKey} copied to clipboard`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../useAsyncJSONProcessing', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('antd', () => {
|
||||
const antd = jest.requireActual('antd');
|
||||
return {
|
||||
...antd,
|
||||
// Render popover content inline to make its children testable
|
||||
Popover: ({ content, children }: any): JSX.Element => (
|
||||
<div data-testid="popover">
|
||||
<div data-testid="popover-content">{content}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): {
|
||||
formatTimezoneAdjustedTimestamp: (timestamp: string) => string;
|
||||
} => ({
|
||||
formatTimezoneAdjustedTimestamp: (timestamp: string): string => timestamp,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
state: null;
|
||||
} => ({
|
||||
pathname: '/test',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
jest.mock('hooks/saveViews/useGetSavedViewParams');
|
||||
|
||||
describe('TableViewActions', () => {
|
||||
const TEST_VALUE = 'test value';
|
||||
const TEST_FIELD = 'test-field';
|
||||
const ACTION_BUTTON_TEST_ID = '.action-btn';
|
||||
const defaultProps = {
|
||||
fieldData: {
|
||||
field: TEST_FIELD,
|
||||
value: TEST_VALUE,
|
||||
},
|
||||
record: {
|
||||
key: 'test-key',
|
||||
field: TEST_FIELD,
|
||||
value: TEST_VALUE,
|
||||
},
|
||||
isListViewPanel: false,
|
||||
isfilterInLoading: false,
|
||||
isfilterOutLoading: false,
|
||||
onClickHandler: jest.fn(),
|
||||
handleChangeSelectedView: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockCopyToClipboard = jest.fn();
|
||||
mockNotificationsSuccess = jest.fn();
|
||||
defaultProps.onClickHandler = jest.fn();
|
||||
defaultProps.handleChangeSelectedView = jest.fn();
|
||||
|
||||
// Default mock for useAsyncJSONProcessing
|
||||
const mockUseAsyncJSONProcessing = jest.mocked(useAsyncJSONProcessing);
|
||||
mockUseAsyncJSONProcessing.mockReturnValue({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Default mock for useQueryBuilder
|
||||
jest.mocked(useQueryBuilder).mockReturnValue({
|
||||
stagedQuery: null,
|
||||
updateQueriesData: jest.fn((query, type, callback) => {
|
||||
const updatedBuilder = {
|
||||
...query.builder,
|
||||
[type]: query.builder[type].map(callback),
|
||||
};
|
||||
return {
|
||||
...query,
|
||||
builder: updatedBuilder,
|
||||
};
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// Default mock for useGetSavedViewParams
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<TableViewActions
|
||||
fieldData={defaultProps.fieldData}
|
||||
record={defaultProps.record}
|
||||
isListViewPanel={defaultProps.isListViewPanel}
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(TEST_VALUE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render action buttons for restricted fields', () => {
|
||||
RESTRICTED_SELECTED_FIELDS.forEach((field) => {
|
||||
const { container } = render(
|
||||
<TableViewActions
|
||||
fieldData={{
|
||||
...defaultProps.fieldData,
|
||||
field,
|
||||
}}
|
||||
record={{
|
||||
...defaultProps.record,
|
||||
field,
|
||||
}}
|
||||
isListViewPanel={defaultProps.isListViewPanel}
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
// Verify that action buttons are not rendered for restricted fields
|
||||
expect(
|
||||
container.querySelector(ACTION_BUTTON_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render action buttons for non-restricted fields', () => {
|
||||
const { container } = render(
|
||||
<TableViewActions
|
||||
fieldData={defaultProps.fieldData}
|
||||
record={defaultProps.record}
|
||||
isListViewPanel={defaultProps.isListViewPanel}
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
// Verify that action buttons are rendered for non-restricted fields
|
||||
expect(container.querySelector(ACTION_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleChangeSelectedView when clicking group by', () => {
|
||||
const mockStagedQuery = {
|
||||
id: 'test-query-id',
|
||||
queryType: 'queryBuilder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
aggregateOperator: 'count',
|
||||
functions: [],
|
||||
filter: {},
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
legend: '',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
const mockUpdateQueriesData = jest.fn((query, type, callback) => {
|
||||
const section = query.builder?.[type];
|
||||
if (!Array.isArray(section)) {
|
||||
return query;
|
||||
}
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
[type]: section.map(callback),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mocked(useQueryBuilder).mockReturnValue({
|
||||
stagedQuery: mockStagedQuery,
|
||||
updateQueriesData: mockUpdateQueriesData,
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
|
||||
render(
|
||||
<TableViewActions
|
||||
fieldData={defaultProps.fieldData}
|
||||
record={defaultProps.record}
|
||||
isListViewPanel={defaultProps.isListViewPanel}
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Group By Attribute'));
|
||||
|
||||
expect(defaultProps.handleChangeSelectedView).toHaveBeenCalledWith(
|
||||
ExplorerViews.TIMESERIES,
|
||||
expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
groupBy: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: TEST_FIELD,
|
||||
type: '',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render action buttons in list view panel', () => {
|
||||
const { container } = render(
|
||||
<TableViewActions
|
||||
fieldData={defaultProps.fieldData}
|
||||
record={defaultProps.record}
|
||||
isListViewPanel
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
// Verify that action buttons are not rendered in list view panel
|
||||
expect(
|
||||
container.querySelector(ACTION_BUTTON_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should copy non-JSON body text without quotes when user clicks on body', () => {
|
||||
// Setup: body field with surrounding quotes
|
||||
const bodyValueWithQuotes =
|
||||
'"FeatureFlag \'kafkaQueueProblems\' is enabled, sleeping 1 second"';
|
||||
const expectedCopiedText =
|
||||
"FeatureFlag 'kafkaQueueProblems' is enabled, sleeping 1 second";
|
||||
|
||||
const bodyProps = {
|
||||
fieldData: {
|
||||
field: 'body',
|
||||
value: bodyValueWithQuotes,
|
||||
},
|
||||
record: {
|
||||
key: 'body-key',
|
||||
field: 'body',
|
||||
value: bodyValueWithQuotes,
|
||||
},
|
||||
isListViewPanel: false,
|
||||
isfilterInLoading: false,
|
||||
isfilterOutLoading: false,
|
||||
onClickHandler: jest.fn(),
|
||||
handleChangeSelectedView: jest.fn(),
|
||||
};
|
||||
|
||||
// Render component with body field
|
||||
render(
|
||||
<TableViewActions
|
||||
fieldData={bodyProps.fieldData}
|
||||
record={bodyProps.record}
|
||||
isListViewPanel={bodyProps.isListViewPanel}
|
||||
isfilterInLoading={bodyProps.isfilterInLoading}
|
||||
isfilterOutLoading={bodyProps.isfilterOutLoading}
|
||||
onClickHandler={bodyProps.onClickHandler}
|
||||
handleChangeSelectedView={bodyProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the clickable copy area for body
|
||||
const copyArea = screen.getByTestId('copy-clipboard-body');
|
||||
|
||||
// Verify it has the correct text to copy (without quotes)
|
||||
expect(copyArea).toHaveAttribute('data-text-to-copy', expectedCopiedText);
|
||||
|
||||
// Action: User clicks on body content
|
||||
fireEvent.click(copyArea);
|
||||
|
||||
// Assert: Text was copied without surrounding quotes
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(expectedCopiedText);
|
||||
|
||||
// Assert: Success notification shown
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'body copied to clipboard',
|
||||
key: 'body copied to clipboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
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';
|
||||
|
||||
const MAX_BODY_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
// Hook for async JSON processing
|
||||
const useAsyncJSONProcessing = (
|
||||
value: string | Record<string, unknown>,
|
||||
shouldProcess: boolean,
|
||||
handleChangeSelectedView?: ChangeViewFunctionType,
|
||||
): {
|
||||
isLoading: boolean;
|
||||
treeData: any[] | null;
|
||||
error: string | null;
|
||||
} => {
|
||||
const [jsonState, setJsonState] = useState<{
|
||||
isLoading: boolean;
|
||||
treeData: any[] | null;
|
||||
error: string | null;
|
||||
}>({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const processingRef = useRef<boolean>(false);
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
useEffect((): (() => void) => {
|
||||
if (!shouldProcess || processingRef.current) {
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
// When value is already a parsed object skip the size check and JSON parsing
|
||||
const parseBody = (): Record<string, unknown> | null => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
const byteSize = new Blob([value as string]).size;
|
||||
if (byteSize > MAX_BODY_BYTES) {
|
||||
return null;
|
||||
}
|
||||
return recursiveParseJSON(value as string);
|
||||
};
|
||||
|
||||
processingRef.current = true;
|
||||
setJsonState({ isLoading: true, treeData: null, error: null });
|
||||
|
||||
// Option 1: Using setTimeout for non-blocking processing
|
||||
const processAsync = (): void => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const parsedBody = parseBody();
|
||||
if (parsedBody && !isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
setJsonState({ isLoading: false, treeData, error: null });
|
||||
} else {
|
||||
setJsonState({ isLoading: false, treeData: null, error: null });
|
||||
}
|
||||
} catch (error) {
|
||||
setJsonState({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: error instanceof Error ? error.message : 'Parsing failed',
|
||||
});
|
||||
} finally {
|
||||
processingRef.current = false;
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Option 2: Using requestIdleCallback for better performance
|
||||
const processWithIdleCallback = (): void => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
(): void => {
|
||||
try {
|
||||
const parsedBody = parseBody();
|
||||
if (parsedBody && !isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
setJsonState({ isLoading: false, treeData, error: null });
|
||||
} else {
|
||||
setJsonState({ isLoading: false, treeData: null, error: null });
|
||||
}
|
||||
} catch (error) {
|
||||
setJsonState({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: error instanceof Error ? error.message : 'Parsing failed',
|
||||
});
|
||||
} finally {
|
||||
processingRef.current = false;
|
||||
}
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
} else {
|
||||
processAsync();
|
||||
}
|
||||
};
|
||||
|
||||
processWithIdleCallback();
|
||||
|
||||
// Cleanup function
|
||||
return (): void => {
|
||||
processingRef.current = false;
|
||||
};
|
||||
}, [value, shouldProcess, isBodyJsonQueryEnabled, handleChangeSelectedView]);
|
||||
|
||||
return jsonState;
|
||||
};
|
||||
|
||||
export default useAsyncJSONProcessing;
|
||||
@@ -1,108 +0,0 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import BodyTitleRenderer from '../BodyTitleRenderer';
|
||||
|
||||
let mockSetCopy: jest.Mock;
|
||||
const mockNotification = jest.fn();
|
||||
|
||||
jest.mock('hooks/logs/useActiveLog', () => ({
|
||||
useActiveLog: (): any => ({
|
||||
onAddToQuery: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): any => {
|
||||
mockSetCopy = jest.fn();
|
||||
return [{ value: null }, mockSetCopy];
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
success: mockNotification,
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
open: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BodyTitleRenderer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should copy primitive value when node is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="name"
|
||||
nodeKey="user.name"
|
||||
value="John"
|
||||
parentIsArray={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('name'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('John');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('user.name'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy array element value when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="0"
|
||||
nodeKey="items[*].0"
|
||||
value="arrayElement"
|
||||
parentIsArray
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('0'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('arrayElement');
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy entire object when object node is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testObject = { id: 123, active: true };
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="metadata"
|
||||
nodeKey="user.metadata"
|
||||
value={testObject}
|
||||
parentIsArray={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('metadata'));
|
||||
|
||||
await waitFor(() => {
|
||||
const callArg = mockSetCopy.mock.calls[0][0];
|
||||
const expectedJson = JSON.stringify(testObject, null, 2);
|
||||
expect(callArg).toBe(expectedJson);
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('object copied'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
@@ -53,7 +51,6 @@ export function useLogAttributeActions({
|
||||
isListViewPanel = false,
|
||||
onApplyLogFilter,
|
||||
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
@@ -61,8 +58,6 @@ export function useLogAttributeActions({
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
const isLiveLogs = pathname === ROUTES.LIVE_LOGS;
|
||||
|
||||
const filterFor = useCallback(
|
||||
(context: FieldContext, isFilterIn: boolean): void => {
|
||||
const target = buildLogFilterTarget(
|
||||
@@ -219,8 +214,7 @@ export function useLogAttributeActions({
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView ||
|
||||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.groupBySupported ||
|
||||
isLiveLogs,
|
||||
.groupBySupported,
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.REPLACE_FILTER,
|
||||
@@ -228,7 +222,7 @@ export function useLogAttributeActions({
|
||||
icon: <RefreshCw size={12} />,
|
||||
onClick: replaceFilter,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView || isRestricted(fieldKeyPath) || isLiveLogs,
|
||||
!handleChangeSelectedView || isRestricted(fieldKeyPath),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
@@ -236,7 +230,6 @@ export function useLogAttributeActions({
|
||||
groupBy,
|
||||
replaceFilter,
|
||||
isBodyJsonQueryEnabled,
|
||||
isLiveLogs,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
]);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
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';
|
||||
import {
|
||||
ILog,
|
||||
ILogAggregateAttributesResources,
|
||||
@@ -13,7 +10,6 @@ import {
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
|
||||
import BodyTitleRenderer from './BodyTitleRenderer';
|
||||
import { typeToArrayTypeMapper } from './config';
|
||||
import { AnyObject, IFieldAttributes } from './LogDetailedView.types';
|
||||
|
||||
@@ -40,135 +36,6 @@ 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 => ({
|
||||
key: uniqueId(),
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
title={`${key} ${valueIsArray ? '[...]' : ''}`}
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
children: jsonToDataNodes(value as Record<string, unknown>, {
|
||||
parentKey: valueIsArray
|
||||
? `${nodeKey}${isBodyJsonQueryEnabled ? '[]' : '[*]'}`
|
||||
: nodeKey,
|
||||
parentIsArray: valueIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
}),
|
||||
});
|
||||
|
||||
export function jsonToDataNodes(
|
||||
json: Record<string, unknown>,
|
||||
options: JsonToDataNodesOptions = {},
|
||||
): DataNode[] {
|
||||
const {
|
||||
parentKey = '',
|
||||
parentIsArray = false,
|
||||
isBodyJsonQueryEnabled = false,
|
||||
handleChangeSelectedView,
|
||||
} = options;
|
||||
|
||||
return Object.entries(json).map(([key, value]) => {
|
||||
let nodeKey = parentKey || key;
|
||||
if (parentIsArray) {
|
||||
nodeKey += `.${value}`;
|
||||
} else if (parentKey) {
|
||||
nodeKey += `.${key}`;
|
||||
}
|
||||
|
||||
const valueIsArray = Array.isArray(value);
|
||||
|
||||
if (parentIsArray) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode({
|
||||
key,
|
||||
valueIsArray,
|
||||
value,
|
||||
nodeKey,
|
||||
parentIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
key: uniqueId(),
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
title={value as string}
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
children: jsonToDataNodes(
|
||||
{},
|
||||
{
|
||||
parentKey: nodeKey,
|
||||
parentIsArray: valueIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode({
|
||||
key,
|
||||
valueIsArray,
|
||||
value,
|
||||
nodeKey,
|
||||
parentIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
}
|
||||
return {
|
||||
key: uniqueId(),
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
title={key}
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function flattenObject(obj: AnyObject, prefix = ''): AnyObject {
|
||||
return Object.keys(obj).reduce((acc: AnyObject, k: string): AnyObject => {
|
||||
const pre = prefix.length ? `${prefix}.` : '';
|
||||
@@ -254,14 +121,6 @@ export const getFieldAttributes = (field: string): IFieldAttributes => {
|
||||
// Returns key to be used when filtering for `field` via
|
||||
// the query builder. This is useful for powering filtering
|
||||
// by field values from log details view.
|
||||
export const filterKeyForField = (field: string): string => {
|
||||
// Must work for all 3 of the following types of cases
|
||||
// timestamp -> timestamp
|
||||
// attributes_string.log.file -> log.file
|
||||
// resources_string.k8s.pod.name -> k8s.pod.name
|
||||
const fieldAttribs = getFieldAttributes(field);
|
||||
return fieldAttribs?.newField || field;
|
||||
};
|
||||
|
||||
export const aggregateAttributesResourcesToObject = (
|
||||
logData: ILog,
|
||||
@@ -418,16 +277,6 @@ export const escapeHtml = (unsafe: string): string =>
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// parse field value to remove escaping characters
|
||||
export const parseFieldValue = (value: string): string => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// now we do not want to render colors everywhere like in tooltip and monaco editor hence we remove such codes to make
|
||||
// the log line readable
|
||||
export const removeEscapeCharacters = (str: string): string =>
|
||||
(str ?? '')
|
||||
.replace(/\\x1[bB][[0-9;]*m/g, '')
|
||||
@@ -458,10 +307,6 @@ export const unescapeString = (str: string): string =>
|
||||
String.fromCharCode(parseInt(hex, 16)),
|
||||
); // Replaces Unicode escape sequences
|
||||
|
||||
export function removeExtraSpaces(input: string): string {
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function findKeyPath(
|
||||
obj: AnyObject,
|
||||
targetKey: string,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import ResizeTable from 'components/ResizeTable/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { CircleArrowDown, CircleArrowRight, Focus } from '@signozhq/icons';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricAttributes } from 'api/generated/services/metrics';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
|
||||
@@ -21,7 +21,7 @@ import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { PenLine, Save, X } from '@signozhq/icons';
|
||||
|
||||
|
||||
@@ -207,4 +207,4 @@ export const routesToSkip = [
|
||||
ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER];
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface TimeRange {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface UsePaginatedLogsProps {
|
||||
timeRange: TimeRange;
|
||||
filters: IBuilderQuery['filters'];
|
||||
queryKeyFilters?: string[];
|
||||
excludeFilterKeys?: string[];
|
||||
basePayload: GetQueryResultsProps;
|
||||
}
|
||||
|
||||
interface UseHandleLogsPagination {
|
||||
logs: ILog[];
|
||||
hasReachedEndOfLogs: boolean;
|
||||
isPaginating: boolean;
|
||||
currentPage: number;
|
||||
resetLogsList: boolean;
|
||||
setIsPaginating: Dispatch<SetStateAction<boolean>>;
|
||||
handleNewData: (currentData: any) => void;
|
||||
loadMoreLogs: () => void;
|
||||
shouldResetPage: boolean;
|
||||
queryPayload: GetQueryResultsProps;
|
||||
}
|
||||
|
||||
export const useHandleLogsPagination = ({
|
||||
timeRange,
|
||||
filters,
|
||||
queryKeyFilters = [],
|
||||
excludeFilterKeys = [],
|
||||
basePayload,
|
||||
}: UsePaginatedLogsProps): UseHandleLogsPagination => {
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [hasReachedEndOfLogs, setHasReachedEndOfLogs] = useState(false);
|
||||
const [restFilters, setRestFilters] = useState<TagFilterItem[]>([]);
|
||||
const [resetLogsList, setResetLogsList] = useState<boolean>(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [prevTimeRange, setPrevTimeRange] = useState<TimeRange | null>(
|
||||
timeRange,
|
||||
);
|
||||
const [isPaginating, setIsPaginating] = useState(false);
|
||||
|
||||
const { shouldResetPage, newRestFilters } = useMemo(() => {
|
||||
const newRestFilters = filters?.items?.filter((item) => {
|
||||
const keyToCheck = item.key?.key ?? '';
|
||||
return (
|
||||
!queryKeyFilters.includes(keyToCheck) &&
|
||||
!excludeFilterKeys.includes(keyToCheck)
|
||||
);
|
||||
});
|
||||
|
||||
const areFiltersSame = isEqual(restFilters, newRestFilters);
|
||||
|
||||
const shouldResetPage =
|
||||
!areFiltersSame ||
|
||||
timeRange.startTime !== prevTimeRange?.startTime ||
|
||||
timeRange.endTime !== prevTimeRange?.endTime;
|
||||
|
||||
return { shouldResetPage, newRestFilters };
|
||||
}, [
|
||||
filters,
|
||||
timeRange,
|
||||
prevTimeRange,
|
||||
queryKeyFilters,
|
||||
excludeFilterKeys,
|
||||
restFilters,
|
||||
]);
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
if (shouldResetPage) {
|
||||
return 1;
|
||||
}
|
||||
return page;
|
||||
}, [shouldResetPage, page]);
|
||||
|
||||
// Handle data updates
|
||||
const handleNewData = useCallback(
|
||||
(currentData: any) => {
|
||||
if (!currentData[0].list) {
|
||||
setHasReachedEndOfLogs(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLogs: ILog[] =
|
||||
currentData[0].list?.map((item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
})) || [];
|
||||
|
||||
if (resetLogsList) {
|
||||
setLogs(currentLogs);
|
||||
setResetLogsList(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLogs = currentLogs.filter(
|
||||
(newLog) => !logs.some((existingLog) => isEqual(existingLog, newLog)),
|
||||
);
|
||||
|
||||
if (newLogs.length > 0) {
|
||||
setLogs((prev) => [...prev, ...newLogs]);
|
||||
}
|
||||
},
|
||||
[logs, resetLogsList],
|
||||
);
|
||||
|
||||
// Reset logic
|
||||
useEffect(() => {
|
||||
if (shouldResetPage) {
|
||||
setPage(1);
|
||||
setLogs([]);
|
||||
setResetLogsList(true);
|
||||
}
|
||||
|
||||
setPrevTimeRange(timeRange);
|
||||
setRestFilters(newRestFilters || []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldResetPage, timeRange]);
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (!logs.length) {
|
||||
return;
|
||||
}
|
||||
setPage((prev) => prev + 1);
|
||||
setIsPaginating(true);
|
||||
}, [logs]);
|
||||
|
||||
const queryPayload = useMemo(
|
||||
() => ({
|
||||
...basePayload,
|
||||
query: {
|
||||
...basePayload.query,
|
||||
builder: {
|
||||
...basePayload.query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...basePayload.query.builder.queryData[0],
|
||||
pageSize: DEFAULT_PER_PAGE_VALUE,
|
||||
offset: (currentPage - 1) * DEFAULT_PER_PAGE_VALUE,
|
||||
orderBy: [
|
||||
{ columnName: 'timestamp', order: ORDERBY_FILTERS.DESC },
|
||||
{ columnName: 'id', order: ORDERBY_FILTERS.DESC },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
[basePayload, currentPage],
|
||||
);
|
||||
|
||||
return {
|
||||
logs,
|
||||
hasReachedEndOfLogs,
|
||||
isPaginating,
|
||||
currentPage,
|
||||
resetLogsList,
|
||||
queryPayload,
|
||||
setIsPaginating,
|
||||
handleNewData,
|
||||
loadMoreLogs,
|
||||
shouldResetPage,
|
||||
};
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
export const fieldSearchFilter = (
|
||||
searchSpace = '',
|
||||
currentValue = '',
|
||||
): boolean => {
|
||||
if (!currentValue || !searchSpace) {
|
||||
return true;
|
||||
}
|
||||
return searchSpace.toLowerCase().indexOf(currentValue.toLowerCase()) !== -1;
|
||||
};
|
||||
@@ -95,7 +95,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LIVE_LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LIST_LICENSES: ['ADMIN'],
|
||||
LOGS_INDEX_FIELDS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS_PIPELINES: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -169,7 +168,6 @@ export const routeWithInitialAuthZSupport = {
|
||||
TRACE_DETAIL: true,
|
||||
LOGS: true,
|
||||
LOGS_EXPLORER: true,
|
||||
LIVE_LOGS: true,
|
||||
ROLES_SETTINGS: true,
|
||||
ROLE_CREATE: true,
|
||||
ROLE_DETAILS: true,
|
||||
|
||||
Reference in New Issue
Block a user