mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-29 23:00:41 +01:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d80f98710 | ||
|
|
adfcebf855 | ||
|
|
254758942e | ||
|
|
8a5d98f467 |
@@ -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",
|
||||
|
||||
@@ -300,14 +300,6 @@ describe('PrivateRoute', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect /logs-explorer/live to /logs/logs-explorer/live', () => {
|
||||
renderPrivateRoute({ initialRoute: '/logs-explorer/live' });
|
||||
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(
|
||||
'/logs/logs-explorer/live',
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect /logs-save-views to /logs/saved-views', () => {
|
||||
renderPrivateRoute({ initialRoute: '/logs-save-views' });
|
||||
|
||||
@@ -1596,7 +1588,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,
|
||||
@@ -521,7 +513,6 @@ export const LIST_LICENSES: AppRoutes = {
|
||||
export const oldNewRoutesMapping: Record<string, string> = {
|
||||
'/pipelines': '/logs/pipelines',
|
||||
'/logs-explorer': '/logs/logs-explorer',
|
||||
'/logs-explorer/live': '/logs/logs-explorer/live',
|
||||
'/logs-save-views': '/logs/saved-views',
|
||||
'/traces-save-views': '/traces/saved-views',
|
||||
'/settings/access-tokens': '/settings/service-accounts',
|
||||
|
||||
@@ -106,7 +106,7 @@ describe.each([
|
||||
renderWithStore(dataSource);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('periscope-btn', 'ghost');
|
||||
expect(button).toHaveAccessibleName('Download');
|
||||
});
|
||||
|
||||
it('shows popover with export options when download button is clicked', () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { Download } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
@@ -111,8 +112,9 @@ export default function DownloadOptionsMenu({
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Download size={16} />}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Download size={16} />}
|
||||
onClick={handleExport}
|
||||
className="export-button"
|
||||
disabled={isDownloading}
|
||||
@@ -144,16 +146,14 @@ export default function DownloadOptionsMenu({
|
||||
>
|
||||
<Tooltip title="Download" placement="top">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={
|
||||
isDownloading ? (
|
||||
<LoaderCircle size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)
|
||||
}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Download size={14} />}
|
||||
aria-label="Download"
|
||||
data-testid={`periscope-btn-download-${dataSource}`}
|
||||
disabled={isDownloading}
|
||||
loading={isDownloading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
@@ -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',
|
||||
|
||||
109
frontend/src/container/ExplorerActions/AddToDashboardButton.tsx
Normal file
109
frontend/src/container/ExplorerActions/AddToDashboardButton.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid2X2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExportPanelContainer from 'container/ExportPanel/ExportPanelContainer';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
EXPLORER_ACTION_EVENTS,
|
||||
getExplorerActionEventPayload,
|
||||
getExportPanelType,
|
||||
} from './utils';
|
||||
|
||||
function AddToDashboardButton({
|
||||
query,
|
||||
sourcepage,
|
||||
panelType,
|
||||
isOneChartPerQuery = false,
|
||||
}: {
|
||||
query: Query | null;
|
||||
sourcepage: DataSource;
|
||||
panelType?: PANEL_TYPES;
|
||||
isOneChartPerQuery?: boolean;
|
||||
}): JSX.Element {
|
||||
const [queryToExport, setQueryToExport] = useState<Query | null>(null);
|
||||
const { panelType: contextPanelType } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const open = (): void => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
void logEvent(
|
||||
EXPLORER_ACTION_EVENTS[sourcepage].addToDashboard,
|
||||
getExplorerActionEventPayload({
|
||||
sourcepage,
|
||||
panelType: contextPanelType,
|
||||
isOneChartPerQuery,
|
||||
}),
|
||||
);
|
||||
setQueryToExport(query);
|
||||
};
|
||||
|
||||
const handleExport = (
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
): void => {
|
||||
if (!dashboard || !queryToExport) {
|
||||
return;
|
||||
}
|
||||
const exportPanelType = panelType ?? getExportPanelType(contextPanelType);
|
||||
|
||||
void logEvent(EXPLORER_ACTION_EVENTS[sourcepage].exported, {
|
||||
...getExplorerActionEventPayload({
|
||||
sourcepage,
|
||||
panelType: contextPanelType,
|
||||
isOneChartPerQuery,
|
||||
}),
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard.title,
|
||||
});
|
||||
|
||||
const link = getExportToDashboardLink({
|
||||
query: queryToExport,
|
||||
panelType: exportPanelType,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId: v4(),
|
||||
});
|
||||
if (link) {
|
||||
safeNavigate(link);
|
||||
}
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={!query}
|
||||
onClick={open}
|
||||
prefix={<Grid2X2 size={16} />}
|
||||
aria-label="Add to dashboard"
|
||||
data-testid="explorer-add-to-dashboard"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipSimple title="Add to dashboard">{button}</TooltipSimple>
|
||||
<ExportPanelContainer
|
||||
open={queryToExport !== null}
|
||||
onClose={(): void => setQueryToExport(null)}
|
||||
query={queryToExport}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddToDashboardButton;
|
||||
61
frontend/src/container/ExplorerActions/CreateAlertButton.tsx
Normal file
61
frontend/src/container/ExplorerActions/CreateAlertButton.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { ConciergeBell } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
EXPLORER_ACTION_EVENTS,
|
||||
getCreateAlertLink,
|
||||
getExplorerActionEventPayload,
|
||||
} from './utils';
|
||||
|
||||
function CreateAlertButton({
|
||||
query,
|
||||
sourcepage,
|
||||
isOneChartPerQuery = false,
|
||||
}: {
|
||||
query: Query | null;
|
||||
sourcepage: DataSource;
|
||||
isOneChartPerQuery?: boolean;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { panelType } = useQueryBuilder();
|
||||
|
||||
const createAlert = (): void => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
void logEvent(
|
||||
EXPLORER_ACTION_EVENTS[sourcepage].createAlert,
|
||||
getExplorerActionEventPayload({ sourcepage, panelType, isOneChartPerQuery }),
|
||||
);
|
||||
history.push(getCreateAlertLink({ query, panelType }));
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size={isOneChartPerQuery ? 'icon' : 'md'}
|
||||
disabled={!query}
|
||||
onClick={createAlert}
|
||||
prefix={<ConciergeBell size={16} />}
|
||||
aria-label="Create an alert"
|
||||
data-testid="explorer-create-alert"
|
||||
>
|
||||
{!isOneChartPerQuery && 'Create an alert'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return isOneChartPerQuery ? (
|
||||
<TooltipSimple title="Create an alert">{button}</TooltipSimple>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAlertButton;
|
||||
39
frontend/src/container/ExplorerActions/ExplorerActions.tsx
Normal file
39
frontend/src/container/ExplorerActions/ExplorerActions.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddToDashboardButton from './AddToDashboardButton';
|
||||
import CreateAlertButton from './CreateAlertButton';
|
||||
|
||||
function ExplorerActions({
|
||||
query,
|
||||
dashboardQuery = query,
|
||||
sourcepage,
|
||||
panelType,
|
||||
isOneChartPerQuery,
|
||||
}: {
|
||||
query: Query | null;
|
||||
// When the dashboard export differs from the alert one (traces list injects columns).
|
||||
dashboardQuery?: Query | null;
|
||||
sourcepage: DataSource;
|
||||
panelType?: PANEL_TYPES;
|
||||
isOneChartPerQuery?: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<CreateAlertButton
|
||||
query={query}
|
||||
sourcepage={sourcepage}
|
||||
isOneChartPerQuery={isOneChartPerQuery}
|
||||
/>
|
||||
<AddToDashboardButton
|
||||
query={dashboardQuery}
|
||||
sourcepage={sourcepage}
|
||||
panelType={panelType}
|
||||
isOneChartPerQuery={isOneChartPerQuery}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExplorerActions;
|
||||
@@ -0,0 +1,320 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
getExportQueryData as getLogsExportQuery,
|
||||
getQueryByPanelType as getLogsQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
getExportQueryData as getTracesExportQuery,
|
||||
getQueryByPanelType as getTracesQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import AddToDashboardButton from '../AddToDashboardButton';
|
||||
import { getExportPanelType } from '../utils';
|
||||
|
||||
const DASHBOARD = { id: 'dash-1', title: 'Dash 1' };
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: jest.fn(),
|
||||
}));
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'widget-1' }));
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
// The picker is the dialog's business; here it just hands a dashboard back.
|
||||
jest.mock('container/ExportPanel/ExportPanelContainer', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
open,
|
||||
query,
|
||||
onExport,
|
||||
}: {
|
||||
open: boolean;
|
||||
query: Query | null;
|
||||
onExport: (dashboard: { id: string; title: string }) => void;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="export-stub"
|
||||
data-query={JSON.stringify(query)}
|
||||
onClick={(): void => onExport({ id: 'dash-1', title: 'Dash 1' })}
|
||||
>
|
||||
export
|
||||
</button>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedUseSafeNavigate = jest.mocked(useSafeNavigate);
|
||||
const mockedLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const FILTER = "service.name = 'frontend'";
|
||||
const COLUMNS = [{ name: 'service.name' }, { name: 'name' }];
|
||||
const options = { selectColumns: COLUMNS } as unknown as OptionsQuery;
|
||||
|
||||
function stagedQuery(dataSource: DataSource, queryName = 'A'): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
id: `query-${queryName}`,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
queryName,
|
||||
aggregateOperator: StringOperators.COUNT,
|
||||
filter: { expression: FILTER },
|
||||
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
function setPanelType(panelType: PANEL_TYPES): void {
|
||||
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
|
||||
typeof useQueryBuilder
|
||||
>);
|
||||
}
|
||||
|
||||
async function exportTo(
|
||||
query: Query | null,
|
||||
sourcepage: DataSource,
|
||||
panelType: PANEL_TYPES,
|
||||
panelTypeProp?: PANEL_TYPES,
|
||||
): Promise<void> {
|
||||
setPanelType(panelType);
|
||||
render(
|
||||
<AddToDashboardButton
|
||||
query={query}
|
||||
sourcepage={sourcepage}
|
||||
panelType={panelTypeProp}
|
||||
/>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId('explorer-add-to-dashboard'));
|
||||
await user.click(screen.getByTestId('export-stub'));
|
||||
}
|
||||
|
||||
function expectedLink(query: Query, panelType: PANEL_TYPES): string | null {
|
||||
return buildExportPanelLink({
|
||||
query,
|
||||
panelType,
|
||||
dashboardId: DASHBOARD.id,
|
||||
});
|
||||
}
|
||||
|
||||
describe('AddToDashboardButton', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockReset();
|
||||
mockedLogEvent.mockClear();
|
||||
mockedUseSafeNavigate.mockReturnValue({ safeNavigate: mockSafeNavigate });
|
||||
});
|
||||
|
||||
it('is disabled without a query and the picker stays closed', () => {
|
||||
setPanelType(PANEL_TYPES.LIST);
|
||||
render(<AddToDashboardButton query={null} sourcepage={DataSource.LOGS} />);
|
||||
|
||||
expect(screen.getByTestId('explorer-add-to-dashboard')).toBeDisabled();
|
||||
expect(screen.queryByTestId('export-stub')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the picker the same query it will export', async () => {
|
||||
const query = stagedQuery(DataSource.LOGS);
|
||||
setPanelType(PANEL_TYPES.TIME_SERIES);
|
||||
render(<AddToDashboardButton query={query} sourcepage={DataSource.LOGS} />);
|
||||
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByTestId('explorer-add-to-dashboard'));
|
||||
|
||||
expect(screen.getByTestId('export-stub')).toHaveAttribute(
|
||||
'data-query',
|
||||
JSON.stringify(query),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs the explorer's own open and success events", async () => {
|
||||
const query = stagedQuery(DataSource.TRACES);
|
||||
|
||||
await exportTo(query, DataSource.TRACES, PANEL_TYPES.TABLE);
|
||||
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(
|
||||
'Traces Explorer: Add to dashboard clicked',
|
||||
{
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(
|
||||
'Traces Explorer: Add to dashboard successful',
|
||||
{
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
isNewDashboard: undefined,
|
||||
dashboardName: DASHBOARD.title,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('a panel type from the page wins over the fold of the context one', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS);
|
||||
|
||||
// context says list, the page says time series
|
||||
await exportTo(
|
||||
query,
|
||||
DataSource.METRICS,
|
||||
PANEL_TYPES.LIST,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(query, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
|
||||
describe('logs, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.LOGS);
|
||||
|
||||
it('list: the list request shaping with timestamp desc, panel type list', async () => {
|
||||
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: FILTER },
|
||||
});
|
||||
const exportQuery = getLogsExportQuery(
|
||||
listRequest,
|
||||
PANEL_TYPES.LIST,
|
||||
) as Query;
|
||||
|
||||
await exportTo(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.LIST),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query untouched, same panel type',
|
||||
async (panelType) => {
|
||||
const exportQuery = getLogsExportQuery(staged, panelType) as Query;
|
||||
|
||||
await exportTo(exportQuery, DataSource.LOGS, panelType);
|
||||
|
||||
expect(exportQuery).toBe(staged);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(staged, panelType),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('traces, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.TRACES);
|
||||
|
||||
it('list: list shaping plus the selected columns, panel type list', async () => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, PANEL_TYPES.LIST),
|
||||
getExportPanelType(PANEL_TYPES.LIST),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.LIST);
|
||||
|
||||
const [queryData] = exportQuery.builder.queryData;
|
||||
expect(queryData.selectColumns).toStrictEqual(COLUMNS);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.LIST),
|
||||
);
|
||||
});
|
||||
|
||||
it('trace: list shaping, no columns, panel type folds to time series', async () => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, PANEL_TYPES.TRACE),
|
||||
getExportPanelType(PANEL_TYPES.TRACE),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.TRACE);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].selectColumns).toBeUndefined();
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
|
||||
// Same as the alert: the list / trace order lives in ListView state and the
|
||||
// page shapes the export without it, so the panel query has no order by.
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: order by is not carried into the panel query',
|
||||
async (panelType) => {
|
||||
expect(staged.builder.queryData[0].orderBy).toHaveLength(1);
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, panelType),
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, getExportPanelType(panelType)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query untouched, same panel type',
|
||||
async (panelType) => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, panelType),
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(exportQuery).toBe(staged);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(staged, panelType),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('metrics: the chart query as is, panel type time series from the page', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS);
|
||||
|
||||
await exportTo(
|
||||
query,
|
||||
DataSource.METRICS,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(query, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
getExportQueryData as getLogsExportQuery,
|
||||
getQueryByPanelType as getLogsQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { getQueryByPanelType as getTracesQueryByPanelType } from 'container/TracesExplorer/explorerUtils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import CreateAlertButton from '../CreateAlertButton';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const mockPush = jest.fn();
|
||||
const mockedUseHistory = jest.mocked(useHistory);
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const FILTER = "service.name = 'frontend'";
|
||||
const ORDER_BY = [{ columnName: 'timestamp', order: 'asc' }];
|
||||
|
||||
function stagedQuery(
|
||||
dataSource: DataSource,
|
||||
aggregateOperator: StringOperators,
|
||||
queryName = 'A',
|
||||
): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
id: `query-${queryName}`,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
queryName,
|
||||
aggregateOperator,
|
||||
filter: { expression: FILTER },
|
||||
orderBy: ORDER_BY,
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
function pushedQuery(): Query {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
const [path, search] = (mockPush.mock.calls[0][0] as string).split('?');
|
||||
expect(path).toBe(ROUTES.ALERTS_NEW);
|
||||
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
|
||||
return JSON.parse(raw as string);
|
||||
}
|
||||
|
||||
function setPanelType(panelType: PANEL_TYPES): void {
|
||||
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
|
||||
typeof useQueryBuilder
|
||||
>);
|
||||
}
|
||||
|
||||
async function clickCreateAlert(
|
||||
query: Query | null,
|
||||
sourcepage: DataSource,
|
||||
panelType: PANEL_TYPES,
|
||||
): Promise<void> {
|
||||
setPanelType(panelType);
|
||||
render(<CreateAlertButton query={query} sourcepage={sourcepage} />);
|
||||
await userEvent.setup().click(screen.getByTestId('explorer-create-alert'));
|
||||
}
|
||||
|
||||
describe('CreateAlertButton', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset();
|
||||
mockedLogEvent.mockClear();
|
||||
mockedUseHistory.mockReturnValue({ push: mockPush } as unknown as ReturnType<
|
||||
typeof useHistory
|
||||
>);
|
||||
});
|
||||
|
||||
it('is disabled and does nothing without a query', async () => {
|
||||
await clickCreateAlert(null, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
expect(screen.getByTestId('explorer-create-alert')).toBeDisabled();
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs the explorer's own create alert event", async () => {
|
||||
const query = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
|
||||
|
||||
await clickCreateAlert(query, DataSource.TRACES, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith('Traces Explorer: Create alert', {
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('one chart per query on metrics: icon only, and the event carries the layout', async () => {
|
||||
setPanelType(PANEL_TYPES.TIME_SERIES);
|
||||
render(
|
||||
<CreateAlertButton
|
||||
query={stagedQuery(DataSource.METRICS, StringOperators.COUNT)}
|
||||
sourcepage={DataSource.METRICS}
|
||||
isOneChartPerQuery
|
||||
/>,
|
||||
);
|
||||
const button = screen.getByTestId('explorer-create-alert');
|
||||
expect(button).not.toHaveTextContent('Create an alert');
|
||||
|
||||
await userEvent.setup().click(button);
|
||||
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(
|
||||
'Metrics Explorer: Add to alert clicked',
|
||||
{
|
||||
sourcepage: DataSource.METRICS,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
tab: 'explorer',
|
||||
oneChartPerQueryEnabled: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('logs, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.LOGS, StringOperators.NOOP);
|
||||
|
||||
it('list: count aggregation, no order by, filter and pagination as the page sent them', async () => {
|
||||
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: FILTER },
|
||||
});
|
||||
const exportQuery = getLogsExportQuery(
|
||||
listRequest,
|
||||
PANEL_TYPES.LIST,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.filter).toStrictEqual({ expression: FILTER });
|
||||
expect(queryData.pageSize).toBe(100);
|
||||
});
|
||||
|
||||
it('time series: staged query as is, order by and group by kept', async () => {
|
||||
const tsStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
|
||||
const exportQuery = getLogsExportQuery(
|
||||
tsStaged,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(
|
||||
exportQuery,
|
||||
DataSource.LOGS,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData).toStrictEqual(tsStaged.builder.queryData[0]);
|
||||
});
|
||||
|
||||
it('table: staged query as is', async () => {
|
||||
const tableStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
|
||||
const exportQuery = getLogsExportQuery(
|
||||
tableStaged,
|
||||
PANEL_TYPES.TABLE,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.TABLE);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(tableStaged.builder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traces, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.TRACES, StringOperators.NOOP);
|
||||
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: count aggregation, group by cleared by the list shaping, filter kept',
|
||||
async (panelType) => {
|
||||
const exportQuery = getTracesQueryByPanelType(staged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.filter).toStrictEqual({ expression: FILTER });
|
||||
},
|
||||
);
|
||||
|
||||
// The list / trace views keep their order in ListView state, and the page
|
||||
// shapes the export without it, so the alert never sees an order by.
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: order by is not carried, even when the staged query has one',
|
||||
async (panelType) => {
|
||||
expect(staged.builder.queryData[0].orderBy).toStrictEqual(ORDER_BY);
|
||||
const exportQuery = getTracesQueryByPanelType(staged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(pushedQuery().builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query as is',
|
||||
async (panelType) => {
|
||||
const aggStaged = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
|
||||
const exportQuery = getTracesQueryByPanelType(aggStaged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(aggStaged.builder);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('metrics: the chart query as is', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS, StringOperators.COUNT);
|
||||
|
||||
await clickCreateAlert(query, DataSource.METRICS, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(query.builder);
|
||||
});
|
||||
});
|
||||
144
frontend/src/container/ExplorerActions/__tests__/utils.test.ts
Normal file
144
frontend/src/container/ExplorerActions/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { getCreateAlertLink, getExportPanelType } from '../utils';
|
||||
|
||||
function withFirstQuery(
|
||||
base: Query,
|
||||
overrides: Partial<Query['builder']['queryData'][number]>,
|
||||
): Query {
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [{ ...base.builder.queryData[0], ...overrides }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeQuery(link: string): Query {
|
||||
const search = link.split('?')[1];
|
||||
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
|
||||
return JSON.parse(raw as string);
|
||||
}
|
||||
|
||||
describe('getExportPanelType', () => {
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE, PANEL_TYPES.LIST])(
|
||||
'keeps %s',
|
||||
(panelType) => {
|
||||
expect(getExportPanelType(panelType)).toBe(panelType);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.BAR, PANEL_TYPES.PIE, PANEL_TYPES.TRACE, null])(
|
||||
'folds %s to time series',
|
||||
(panelType) => {
|
||||
expect(getExportPanelType(panelType)).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('getCreateAlertLink', () => {
|
||||
const orderBy = [{ columnName: 'timestamp', order: 'desc' }];
|
||||
|
||||
it('points at the new alert route with the query in the url', () => {
|
||||
const query = initialQueriesMap.traces;
|
||||
const link = getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
expect(link.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
|
||||
expect(decodeQuery(link)).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('logs list: noop becomes count and order by is dropped', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
}),
|
||||
).builder.queryData;
|
||||
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('logs time series keeps order by', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.COUNT,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
).builder.queryData;
|
||||
|
||||
expect(queryData.orderBy).toStrictEqual(orderBy);
|
||||
});
|
||||
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s drops order by whatever the source',
|
||||
(panelType) => {
|
||||
const query = withFirstQuery(initialQueriesMap.traces, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(getCreateAlertLink({ query, panelType }))
|
||||
.builder.queryData;
|
||||
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('converts a noop on any query, not only the first', () => {
|
||||
const first = initialQueriesMap.logs.builder.queryData[0];
|
||||
const query: Query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{ ...first, aggregateOperator: StringOperators.COUNT },
|
||||
{ ...first, queryName: 'B', aggregateOperator: StringOperators.NOOP },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const operators = decodeQuery(
|
||||
getCreateAlertLink({ query, panelType: PANEL_TYPES.TIME_SERIES }),
|
||||
).builder.queryData.map((item) => item.aggregateOperator);
|
||||
|
||||
expect(operators).toStrictEqual([
|
||||
StringOperators.COUNT,
|
||||
StringOperators.COUNT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mutate the query it is given', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
const snapshot = JSON.stringify(query);
|
||||
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(query)).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
85
frontend/src/container/ExplorerActions/utils.ts
Normal file
85
frontend/src/container/ExplorerActions/utils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
MetricsExplorerEventKeys,
|
||||
MetricsExplorerEvents,
|
||||
} from 'container/MetricsExplorer/events';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
export const EXPLORER_ACTION_EVENTS: Record<
|
||||
DataSource,
|
||||
{ createAlert: string; addToDashboard: string; exported: string }
|
||||
> = {
|
||||
[DataSource.LOGS]: {
|
||||
createAlert: 'Logs Explorer: Create alert',
|
||||
addToDashboard: 'Logs Explorer: Add to dashboard clicked',
|
||||
exported: 'Logs Explorer: Add to dashboard successful',
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
createAlert: 'Traces Explorer: Create alert',
|
||||
addToDashboard: 'Traces Explorer: Add to dashboard clicked',
|
||||
exported: 'Traces Explorer: Add to dashboard successful',
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
createAlert: MetricsExplorerEvents.AddToAlertClicked,
|
||||
addToDashboard: MetricsExplorerEvents.AddToDashboardClicked,
|
||||
exported: MetricsExplorerEvents.AddToDashboardSuccessful,
|
||||
},
|
||||
};
|
||||
|
||||
export function getExplorerActionEventPayload({
|
||||
sourcepage,
|
||||
panelType,
|
||||
isOneChartPerQuery,
|
||||
}: {
|
||||
sourcepage: DataSource;
|
||||
panelType: PANEL_TYPES | null;
|
||||
isOneChartPerQuery: boolean;
|
||||
}): Record<string, unknown> {
|
||||
if (sourcepage !== DataSource.METRICS) {
|
||||
return { sourcepage, panelType };
|
||||
}
|
||||
return {
|
||||
sourcepage,
|
||||
panelType,
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
[MetricsExplorerEventKeys.OneChartPerQueryEnabled]: isOneChartPerQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export function getExportPanelType(panelType: PANEL_TYPES | null): PANEL_TYPES {
|
||||
return panelType && AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
}
|
||||
|
||||
// Alerts need an aggregation, and list style views carry an order the alert
|
||||
// cannot use.
|
||||
export function getCreateAlertLink({
|
||||
query,
|
||||
panelType,
|
||||
}: {
|
||||
query: Query;
|
||||
panelType: PANEL_TYPES | null;
|
||||
}): string {
|
||||
const isListStyle =
|
||||
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
|
||||
|
||||
const alertQuery = cloneDeep(query);
|
||||
alertQuery.builder.queryData = alertQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
aggregateOperator:
|
||||
item.aggregateOperator === StringOperators.NOOP
|
||||
? StringOperators.COUNT
|
||||
: item.aggregateOperator,
|
||||
orderBy: isListStyle ? [] : item.orderBy,
|
||||
}));
|
||||
|
||||
return `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
JSON.stringify(alertQuery),
|
||||
)}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
@@ -6,7 +6,6 @@ import FieldsSelector from 'components/FieldsSelector';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
@@ -14,18 +13,18 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
selectedPanelType,
|
||||
showFrequencyChart,
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
explorerActions,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
showFrequencyChart: boolean;
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
explorerActions: ReactNode;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -60,48 +59,43 @@ function LogsActionsContainer({
|
||||
<div className="logs-actions-container">
|
||||
<div className="tab-options">
|
||||
<div className="tab-options-left">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
value={showFrequencyChart}
|
||||
defaultValue
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
value={showFrequencyChart}
|
||||
defaultValue
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-options-right">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<>
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
{explorerActions}
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<div className="download-options-container">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<div className="download-options-container">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{config.fieldsSelector && (
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getListQuery,
|
||||
getQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
@@ -140,6 +141,13 @@ function LogsExplorerViewsContainer({
|
||||
[selectedPanelType, requestData],
|
||||
);
|
||||
|
||||
const explorerActions = useMemo(
|
||||
() => (
|
||||
<ExplorerActions query={exportDefaultQuery} sourcepage={DataSource.LOGS} />
|
||||
),
|
||||
[exportDefaultQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
data: listChartData,
|
||||
isFetching: isFetchingListChartData,
|
||||
@@ -416,14 +424,14 @@ function LogsExplorerViewsContainer({
|
||||
return (
|
||||
<div className="logs-explorer-views-container">
|
||||
<div className="logs-explorer-views-types">
|
||||
{!showLiveLogs && (
|
||||
{!showLiveLogs && selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<LogsActionsContainer
|
||||
listQuery={listQuery}
|
||||
selectedPanelType={selectedPanelType}
|
||||
showFrequencyChart={showFrequencyChart}
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
explorerActions={explorerActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -474,21 +482,23 @@ function LogsExplorerViewsContainer({
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
|
||||
<div className="table-view-container">
|
||||
{data && !isError && (
|
||||
<div className="table-view-container-header">
|
||||
<div className="table-view-container-header">
|
||||
{explorerActions}
|
||||
{data && !isError && (
|
||||
<ExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.metrics}
|
||||
fileName="logs-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<LogsExplorerTable
|
||||
data={
|
||||
(data?.payload?.data?.newResult?.data?.result ||
|
||||
|
||||
@@ -394,6 +394,7 @@ function Explorer(): JSX.Element {
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -51,6 +52,7 @@ function TimeSeries({
|
||||
showYAxisUnitSelector,
|
||||
metrics,
|
||||
isCancelled = false,
|
||||
exportDefaultQuery,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -272,6 +274,9 @@ function TimeSeries({
|
||||
metricName;
|
||||
|
||||
const currentYAxisUnit = yAxisUnit || metricUnit;
|
||||
const exportQuery = changeLayoutForOneChartPerQuery
|
||||
? queryPayloads[index]
|
||||
: exportDefaultQuery;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -312,6 +317,14 @@ function TimeSeries({
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={
|
||||
<ExplorerActions
|
||||
query={stagedQuery ? exportQuery : null}
|
||||
sourcepage={DataSource.METRICS}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
isOneChartPerQuery={changeLayoutForOneChartPerQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
@@ -146,9 +147,11 @@ function renderExplorer(): void {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
<TooltipProvider>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
</TooltipProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
|
||||
import TimeSeries from '../TimeSeries';
|
||||
import { TimeSeriesProps } from '../types';
|
||||
@@ -71,6 +72,7 @@ function renderTimeSeries(
|
||||
yAxisUnit="count"
|
||||
setYAxisUnit={mockSetYAxisUnit}
|
||||
showYAxisUnitSelector={false}
|
||||
exportDefaultQuery={initialQueriesMap.metrics}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
@@ -17,4 +18,5 @@ export interface TimeSeriesProps {
|
||||
setYAxisUnit: (unit: string) => void;
|
||||
showYAxisUnitSelector: boolean;
|
||||
isCancelled?: boolean;
|
||||
exportDefaultQuery: Query;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export enum MetricsExplorerEvents {
|
||||
YAxisUnitApplied = 'Metrics Explorer: Y axis unit applied',
|
||||
AddToAlertClicked = 'Metrics Explorer: Add to alert clicked',
|
||||
AddToDashboardClicked = 'Metrics Explorer: Add to dashboard clicked',
|
||||
AddToDashboardSuccessful = 'Metrics Explorer: Add to dashboard successful',
|
||||
SaveViewClicked = 'Metrics Explorer: Save view clicked',
|
||||
SearchApplied = 'Metrics Explorer: Search applied',
|
||||
ViewEdited = 'Metrics Explorer: View edited',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.timeSeriesView {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.time-series-view {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import Uplot from 'components/Uplot';
|
||||
@@ -50,7 +52,7 @@ import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
|
||||
import './TimeSeriesView.styles.scss';
|
||||
import styles from './TimeSeriesView.module.scss';
|
||||
|
||||
function TimeSeriesView({
|
||||
data,
|
||||
@@ -66,6 +68,7 @@ function TimeSeriesView({
|
||||
allowExport = false,
|
||||
exportFileName,
|
||||
onYAxisUnitChange,
|
||||
headerActions,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -252,28 +255,31 @@ function TimeSeriesView({
|
||||
);
|
||||
|
||||
const showExport = allowExport && !!data?.rawV5Response;
|
||||
const showHeader = showExport || !!onYAxisUnitChange;
|
||||
const showHeader = showExport || !!onYAxisUnitChange || !!headerActions;
|
||||
|
||||
return (
|
||||
<div className="time-series-view">
|
||||
<div className={cx('time-series-view', styles.timeSeriesView)}>
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{showHeader && (
|
||||
<div className="time-series-view__header">
|
||||
<div className={styles.header} data-testid="time-series-view-header">
|
||||
<div>
|
||||
{onYAxisUnitChange && (
|
||||
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
|
||||
)}
|
||||
</div>
|
||||
{showExport && data?.rawV5Response && (
|
||||
<ExportMenu
|
||||
dataSource={dataSource}
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.headerActions}>
|
||||
{headerActions}
|
||||
{showExport && data?.rawV5Response && (
|
||||
<ExportMenu
|
||||
dataSource={dataSource}
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -344,6 +350,8 @@ interface TimeSeriesViewProps {
|
||||
// Opt-in: render the y-axis unit selector in the header (views without their
|
||||
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
|
||||
onYAxisUnitChange?: (value: string) => void;
|
||||
// Rendered in the header ahead of the export menu.
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
TimeSeriesView.defaultProps = {
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('TimeSeriesView header gating', () => {
|
||||
});
|
||||
|
||||
it('renders no header row when neither export nor unit selector is enabled', () => {
|
||||
const { container } = renderView({ withRawV5: false });
|
||||
expect(container.querySelector('.time-series-view__header')).toBeNull();
|
||||
const { queryByTestId } = renderView({ withRawV5: false });
|
||||
expect(queryByTestId('time-series-view-header')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -55,6 +56,7 @@ interface ListViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
@@ -62,6 +64,7 @@ function ListView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
@@ -227,6 +230,7 @@ function ListView({
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
{headerActions}
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
@@ -272,6 +276,7 @@ function ListView({
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -30,10 +31,12 @@ function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -101,14 +104,17 @@ function TableView({
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
{!isError && (
|
||||
<div className="traces-table-view-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
{headerActions}
|
||||
{data && (
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
@@ -125,6 +131,7 @@ function TableView({
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -40,6 +41,7 @@ interface TracesViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
@@ -47,6 +49,7 @@ function TracesView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -155,6 +158,7 @@ function TracesView({
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
{headerActions}
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
@@ -187,6 +191,7 @@ function TracesView({
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -29,6 +30,7 @@ function TimeSeriesViewContainer({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -126,6 +128,7 @@ function TimeSeriesViewContainer({
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={headerActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -137,11 +140,13 @@ interface TimeSeriesViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
|
||||
@@ -13,6 +13,8 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import { getExportPanelType } from 'container/ExplorerActions/utils';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
@@ -194,6 +196,27 @@ function TracesExplorer(): JSX.Element {
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const exportDashboardQuery = useMemo(
|
||||
() =>
|
||||
getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
),
|
||||
[exportDefaultQuery, panelType, options],
|
||||
);
|
||||
|
||||
const explorerActions = useMemo(
|
||||
() => (
|
||||
<ExplorerActions
|
||||
query={stagedQuery ? exportDefaultQuery : null}
|
||||
dashboardQuery={stagedQuery ? exportDashboardQuery : null}
|
||||
sourcepage={DataSource.TRACES}
|
||||
/>
|
||||
),
|
||||
[stagedQuery, exportDefaultQuery, exportDashboardQuery],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
@@ -318,6 +341,7 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -329,6 +353,7 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -341,6 +366,7 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -351,6 +377,7 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
@@ -79,6 +80,14 @@ func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
|
||||
}}
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) Request() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addPrometheusRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/prometheus/api/v1/query", &prometheusOpenAPIHandler{
|
||||
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.Query, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
pkghandler "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -55,6 +56,14 @@ func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) Request() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
|
||||
provider.authzMiddleware.OpenAccess(provider.factoryHandler.Healthz),
|
||||
|
||||
@@ -358,10 +358,20 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
|
||||
if req.ServiceAccountID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.ServiceAccountID.StringValue()
|
||||
})),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
|
||||
if req.RoleID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.RoleID.StringValue()
|
||||
})),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/swaggest/openapi-go"
|
||||
"github.com/swaggest/openapi-go/openapi3"
|
||||
@@ -16,12 +19,15 @@ type Handler interface {
|
||||
http.Handler
|
||||
ServeOpenAPI(openapi.OperationContext)
|
||||
ResourceDefs() []ResourceDef
|
||||
Request() any
|
||||
BindBodyOptions() []binding.BindBodyOption
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
resourceDefs []ResourceDef
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
resourceDefs []ResourceDef
|
||||
bindBodyOptions []binding.BindBodyOption
|
||||
}
|
||||
|
||||
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
|
||||
@@ -47,6 +53,10 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Ha
|
||||
opt(handler)
|
||||
}
|
||||
|
||||
if RequiresBody(handler.resourceDefs) && (openAPIDef.Request == nil || reflect.TypeOf(openAPIDef.Request).Kind() != reflect.Pointer) {
|
||||
panic(fmt.Sprintf("handler %s: a body extractor needs OpenAPIDef.Request to be a pointer, got %T", openAPIDef.ID, openAPIDef.Request))
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -135,3 +145,11 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
func (handler *handler) ResourceDefs() []ResourceDef {
|
||||
return handler.resourceDefs
|
||||
}
|
||||
|
||||
func (handler *handler) Request() any {
|
||||
return handler.openAPIDef.Request
|
||||
}
|
||||
|
||||
func (handler *handler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return handler.bindBodyOptions
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -22,6 +24,41 @@ func (bespokeOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
|
||||
func (bespokeOpenAPIHandler) ResourceDefs() []ResourceDef { return nil }
|
||||
|
||||
func (bespokeOpenAPIHandler) Request() any { return nil }
|
||||
|
||||
func (bespokeOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption { return nil }
|
||||
|
||||
func TestNewPanicsWhenBodyExtractorHasNoPointerRequest(t *testing.T) {
|
||||
type body struct{ ID string }
|
||||
bodyDef := BasicResourceDef{Resource: coretypes.ResourceRole, Verb: coretypes.VerbRead, ID: coretypes.BodyField(func(req *body) string { return req.ID }), Selector: coretypes.IDSelector}
|
||||
pathDef := BasicResourceDef{Resource: coretypes.ResourceRole, Verb: coretypes.VerbRead, ID: coretypes.PathParam("id"), Selector: coretypes.IDSelector}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
request any
|
||||
def ResourceDef
|
||||
panics bool
|
||||
}{
|
||||
{name: "BodyExtractor_ValueRequest_Panics", request: body{}, def: bodyDef, panics: true},
|
||||
{name: "BodyExtractor_NilRequest_Panics", request: nil, def: bodyDef, panics: true},
|
||||
{name: "BodyExtractor_PointerRequest_Registers", request: new(body), def: bodyDef, panics: false},
|
||||
{name: "PathExtractor_ValueRequest_Registers", request: body{}, def: pathDef, panics: false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
register := func() {
|
||||
New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: testCase.name, Request: testCase.request}, WithResourceDefs(testCase.def))
|
||||
}
|
||||
if testCase.panics {
|
||||
assert.Panics(t, register)
|
||||
} else {
|
||||
assert.NotPanics(t, register)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachStabilities(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/development", New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: "Development", SuccessStatusCode: http.StatusOK, Stability: StabilityDevelopment})).Methods(http.MethodGet)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package handler
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/http/binding"
|
||||
|
||||
type Option func(*handler)
|
||||
|
||||
func WithResourceDefs(defs ...ResourceDef) Option {
|
||||
@@ -7,3 +9,9 @@ func WithResourceDefs(defs ...ResourceDef) Option {
|
||||
h.resourceDefs = append(h.resourceDefs, defs...)
|
||||
}
|
||||
}
|
||||
|
||||
func WithBindBodyOptions(opts ...binding.BindBodyOption) Option {
|
||||
return func(h *handler) {
|
||||
h.bindBodyOptions = append(h.bindBodyOptions, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type ResourceDef interface {
|
||||
// resolveRequest is unexported to seal the interface. It returns a slice so a
|
||||
// single def can fan out (e.g. a telemetry query touching multiple signals).
|
||||
resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource
|
||||
requiresBody() bool
|
||||
}
|
||||
|
||||
func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
@@ -20,6 +21,17 @@ func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretyp
|
||||
return resolved
|
||||
}
|
||||
|
||||
// RequiresBody reports whether any def needs the decoded request body.
|
||||
func RequiresBody(defs []ResourceDef) bool {
|
||||
for _, def := range defs {
|
||||
if def.requiresBody() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// BasicResourceDef checks a single resource for one verb.
|
||||
type BasicResourceDef struct {
|
||||
Resource coretypes.Resource
|
||||
@@ -42,6 +54,10 @@ func (def BasicResourceDef) resolveRequest(ec coretypes.ExtractorContext) []core
|
||||
}
|
||||
}
|
||||
|
||||
func (def BasicResourceDef) requiresBody() bool {
|
||||
return def.ID.RequiresBody
|
||||
}
|
||||
|
||||
// AttachDetachSiblingResourceDef checks an attach/detach between peer resources;
|
||||
// both source and target are authz-checked.
|
||||
type AttachDetachSiblingResourceDef struct {
|
||||
@@ -72,6 +88,10 @@ func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorC
|
||||
}
|
||||
}
|
||||
|
||||
func (def AttachDetachSiblingResourceDef) requiresBody() bool {
|
||||
return def.SourceIDs.RequiresBody || def.TargetIDs.RequiresBody
|
||||
}
|
||||
|
||||
// AttachDetachParentChildResourceDef authz-checks only the parent; the child
|
||||
// rides along for audit context.
|
||||
type AttachDetachParentChildResourceDef struct {
|
||||
@@ -101,11 +121,20 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
|
||||
}
|
||||
}
|
||||
|
||||
func (def AttachDetachParentChildResourceDef) requiresBody() bool {
|
||||
return def.ParentID.RequiresBody || def.ChildIDs.RequiresBody
|
||||
}
|
||||
|
||||
type TelemetryResourceDef struct {
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
RequiresBody bool
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) requiresBody() bool {
|
||||
return def.RequiresBody
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -23,8 +25,8 @@ func NewResource(logger *slog.Logger) *Resource {
|
||||
|
||||
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
defs := resourceDefsFromRequest(req)
|
||||
if len(defs) == 0 {
|
||||
provider := handlerFromRequest(req)
|
||||
if provider == nil || len(provider.ResourceDefs()) == 0 {
|
||||
next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
@@ -36,18 +38,40 @@ func (middleware *Resource) Wrap(next http.Handler) http.Handler {
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
extractorCtx := coretypes.ExtractorContext{
|
||||
Request: req,
|
||||
RequestBody: body,
|
||||
}
|
||||
resolved := handler.ResolveRequest(defs, extractorCtx)
|
||||
defs := provider.ResourceDefs()
|
||||
|
||||
ctx := coretypes.NewContextWithResolvedResources(req.Context(), resolved)
|
||||
var decoded any
|
||||
var decodeErr error
|
||||
if handler.RequiresBody(defs) {
|
||||
decoded, decodeErr = decodeBody(provider.Request(), body, provider.BindBodyOptions()...)
|
||||
}
|
||||
|
||||
extractorCtx := coretypes.ExtractorContext{Request: req, RequestBody: body, DecodedRequestBody: decoded}
|
||||
|
||||
var resolved []coretypes.ResolvedResource
|
||||
if decodeErr != nil {
|
||||
// authz renders the error inside the audit middleware, so the request is still logged
|
||||
resolved = []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(coretypes.Verb{}, coretypes.ActionCategory{}, decodeErr)}
|
||||
} else {
|
||||
resolved = handler.ResolveRequest(defs, extractorCtx)
|
||||
}
|
||||
|
||||
ctx := coretypes.NewContextWithExtractorContext(req.Context(), extractorCtx)
|
||||
ctx = coretypes.NewContextWithResolvedResources(ctx, resolved)
|
||||
next.ServeHTTP(rw, req.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
|
||||
func decodeBody(prototype any, body []byte, opts ...binding.BindBodyOption) (any, error) {
|
||||
decoded := reflect.New(reflect.TypeOf(prototype).Elem()).Interface()
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(body), decoded, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func handlerFromRequest(req *http.Request) handler.Handler {
|
||||
route := mux.CurrentRoute(req)
|
||||
if route == nil {
|
||||
return nil
|
||||
@@ -63,5 +87,5 @@ func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.ResourceDefs()
|
||||
return provider
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
"resourceType": "containerApps",
|
||||
"metrics": {},
|
||||
"logs": {
|
||||
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
|
||||
"categoryGroups": ["allLogs"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -222,8 +223,8 @@ func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.PostableServiceAccountRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
req, err := coretypes.BodyFromContext[serviceaccounttypes.PostableServiceAccountRole](r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
@@ -12,24 +14,71 @@ const (
|
||||
PhaseResponse
|
||||
)
|
||||
|
||||
var (
|
||||
errCodeExtractorContextNotFound = errors.MustNewCode("extractor_context_not_found")
|
||||
errCodeRequestTypeUndeclared = errors.MustNewCode("request_type_undeclared")
|
||||
errCodeRequestTypeMismatch = errors.MustNewCode("request_type_mismatch")
|
||||
)
|
||||
|
||||
type ExtractPhase int
|
||||
|
||||
type extractorContextKey struct{}
|
||||
|
||||
// ExtractorContext carries everything an extractor may read: Request + RequestBody
|
||||
// are filled pre-handler, ResponseBody post-handler.
|
||||
// are filled pre-handler, ResponseBody post-handler. DecodedRequestBody is
|
||||
// RequestBody decoded by the resource middleware into the route's declared request type.
|
||||
type ExtractorContext struct {
|
||||
Request *http.Request
|
||||
RequestBody []byte
|
||||
ResponseBody []byte
|
||||
Request *http.Request
|
||||
RequestBody []byte
|
||||
DecodedRequestBody any
|
||||
ResponseBody []byte
|
||||
}
|
||||
|
||||
func NewContextWithExtractorContext(ctx context.Context, ec ExtractorContext) context.Context {
|
||||
return context.WithValue(ctx, extractorContextKey{}, ec)
|
||||
}
|
||||
|
||||
func ExtractorContextFromContext(ctx context.Context) (ExtractorContext, error) {
|
||||
ec, ok := ctx.Value(extractorContextKey{}).(ExtractorContext)
|
||||
if !ok {
|
||||
return ExtractorContext{}, errors.New(errors.TypeInternal, errCodeExtractorContextNotFound, "extractor context not found in context")
|
||||
}
|
||||
|
||||
return ec, nil
|
||||
}
|
||||
|
||||
func BodyAs[T any](ec ExtractorContext) (*T, error) {
|
||||
if ec.DecodedRequestBody == nil {
|
||||
return nil, errors.New(errors.TypeInternal, errCodeRequestTypeUndeclared, "route does not declare a request type")
|
||||
}
|
||||
|
||||
typed, ok := ec.DecodedRequestBody.(*T)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInternal, errCodeRequestTypeMismatch, "route declares request type %T, expected %T", ec.DecodedRequestBody, (*T)(nil))
|
||||
}
|
||||
|
||||
return typed, nil
|
||||
}
|
||||
|
||||
func BodyFromContext[T any](ctx context.Context) (*T, error) {
|
||||
ec, err := ExtractorContextFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return BodyAs[T](ec)
|
||||
}
|
||||
|
||||
type ResourceIDExtractor struct {
|
||||
Phase ExtractPhase
|
||||
Fn func(ExtractorContext) (string, error)
|
||||
Phase ExtractPhase
|
||||
RequiresBody bool
|
||||
Fn func(ExtractorContext) (string, error)
|
||||
}
|
||||
|
||||
type ResourceIDsExtractor struct {
|
||||
Phase ExtractPhase
|
||||
Fn func(ExtractorContext) ([]string, error)
|
||||
Phase ExtractPhase
|
||||
RequiresBody bool
|
||||
Fn func(ExtractorContext) ([]string, error)
|
||||
}
|
||||
|
||||
func NewResourceIDExtractor(phase ExtractPhase, fn func(ExtractorContext) (string, error)) ResourceIDExtractor {
|
||||
@@ -50,7 +99,7 @@ func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
|
||||
return ResourceIDsExtractor{}
|
||||
}
|
||||
|
||||
return ResourceIDsExtractor{Phase: extractor.Phase, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
return ResourceIDsExtractor{Phase: extractor.Phase, RequiresBody: extractor.RequiresBody, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
id, err := extractor.Fn(ec)
|
||||
if err != nil || id == "" {
|
||||
return nil, err
|
||||
@@ -75,6 +124,28 @@ func PathParam(name string) ResourceIDExtractor {
|
||||
}}
|
||||
}
|
||||
|
||||
func BodyField[T any](pick func(*T) string) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: PhaseRequest, RequiresBody: true, Fn: func(ec ExtractorContext) (string, error) {
|
||||
req, err := BodyAs[T](ec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pick(req), nil
|
||||
}}
|
||||
}
|
||||
|
||||
func BodyFields[T any](pick func(*T) []string) ResourceIDsExtractor {
|
||||
return ResourceIDsExtractor{Phase: PhaseRequest, RequiresBody: true, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
req, err := BodyAs[T](ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pick(req), nil
|
||||
}}
|
||||
}
|
||||
|
||||
func BodyJSONPath(path string) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
|
||||
return gjson.GetBytes(ec.RequestBody, path).String(), nil
|
||||
|
||||
@@ -246,6 +246,15 @@ def test_attach_detach_dual_scoped(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign viewer to target: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# duplicate roleId: the server binds the last one (viewer) -> forbidden. Raw string, json= would collapse the key.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
|
||||
data=f'{{"serviceAccountId": "{target_id}", "roleId": "{editor_role_id}", "roleId": "{viewer_role_id}"}}',
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign duplicate roleId to target: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Both SA-detach (target id) and role-detach (editor) present -> remove allowed.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/service_account_roles/{editor_entry_id}"),
|
||||
|
||||
Reference in New Issue
Block a user