Compare commits

..

24 Commits

Author SHA1 Message Date
Abhi Kumar
a84ddab840 fix(dashboard): wrap text panel background swatches in narrow panes
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
95639ebae9 style(dashboard): lighten section titles and steady config pane layout
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
7369a4c0d8 fix(dashboard): don't mark an explicit default precision as changed
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
cbbf568e0a refactor(dashboard): remove ConfigSegmented and segment icons
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
543836d183 feat(dashboard): add help text and card styling to thresholds and links
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
0393928e9a feat(dashboard): add text position grid and restyle text panel options
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
1e14be1d6f feat(dashboard): redesign buckets section
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
9ba1c3586e feat(dashboard): redesign chart appearance section
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
d0f434fcb9 feat(dashboard): redesign legend section and match series colors to legend font
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
a28598e818 feat(dashboard): redesign axes section
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
ff234653e7 feat(dashboard): redesign formatting section with decimal tiles and preview
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
09d5d0ea87 feat(dashboard): redesign visualization section
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:35 +05:30
Abhi Kumar
c87ca68bb6 feat(dashboard): summarise config sections and mark changed settings
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:35 +05:30
Abhi Kumar
c3beb3fe44 feat(dashboard): add change tracking and shared config pane controls
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:35 +05:30
Abhi Kumar
dc43d2fb2f test(dashboard): use userEvent in panel type switcher tests
Assisted-by: Claude Opus 5.5
2026-09-30 11:21:58 +05:30
Abhi Kumar
89090b85d3 feat(dashboard): add-to-section split button in the new panel drawer
Replaces the antd section select; the CTA names its destination.

Assisted-by: Claude Opus 5.5
2026-09-30 11:21:55 +05:30
Abhi Kumar
61a6fb5d1f feat(dashboard): switch panel type in the editor via the type browser
A revert button returns to the type the panel was opened with.
2026-09-27 17:22:24 +05:30
Abhi Kumar
c4a6ce85fa fix(dashboard): scroll to a placeholder that grows the dashboard
OverlayScrollbars marks its viewport scrollable only after noticing the
overflow, and the grid animates its height, so the reveal either scrolled
html or stopped short.
2026-09-27 17:06:36 +05:30
Abhi Kumar
45a062babc feat(dashboard): preview the new panel where the picker will add it 2026-09-27 17:06:25 +05:30
Abhi Kumar
276056c4ab feat(dashboard): preview a new section on the dashboard while naming it 2026-09-27 16:36:47 +05:30
Abhi Kumar
ffbba01e29 feat(dashboard): pick or create the section from the new-panel drawer footer
The main New Panel button defaults to the dashboard root.
2026-09-27 16:36:47 +05:30
Abhi Kumar
01f7f6869a feat(dashboard): let a new panel's save create its section or the root
Placement travels in the editor URL as a NewPanelTarget, so nothing is
written until the panel is saved.
2026-09-27 16:36:46 +05:30
Abhi Kumar
de3c3c268f feat(dashboard): highlight the picker's target section behind the drawer 2026-09-27 16:36:46 +05:30
Abhi Kumar
9a602d015a feat(dashboard): redesign the new-panel picker as a searchable drawer 2026-09-27 16:36:46 +05:30
264 changed files with 8648 additions and 4157 deletions

View File

@@ -33,6 +33,7 @@
"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",

View File

@@ -42,6 +42,7 @@
"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",

View File

@@ -300,6 +300,14 @@ 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' });
@@ -1588,6 +1596,7 @@ 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,

View File

@@ -154,6 +154,10 @@ 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'),
);

View File

@@ -23,6 +23,7 @@ import {
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LiveLogs,
Login,
Logs,
LogsIndexToFields,
@@ -281,6 +282,13 @@ 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,
@@ -513,6 +521,7 @@ 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',

View File

@@ -106,7 +106,7 @@ describe.each([
renderWithStore(dataSource);
const button = screen.getByTestId(testId);
expect(button).toBeInTheDocument();
expect(button).toHaveAccessibleName('Download');
expect(button).toHaveClass('periscope-btn', 'ghost');
});
it('shows popover with export options when download button is clicked', () => {

View File

@@ -1,12 +1,11 @@
import { useCallback, useMemo, useState } from 'react';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Button, Popover, Tooltip } from 'antd';
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 } from '@signozhq/icons';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -112,9 +111,8 @@ export default function DownloadOptionsMenu({
)}
<Button
variant="solid"
color="primary"
prefix={<Download size={16} />}
type="primary"
icon={<Download size={16} />}
onClick={handleExport}
className="export-button"
disabled={isDownloading}
@@ -146,14 +144,16 @@ export default function DownloadOptionsMenu({
>
<Tooltip title="Download" placement="top">
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<Download size={14} />}
aria-label="Download"
className="periscope-btn ghost"
icon={
isDownloading ? (
<LoaderCircle size={14} className="animate-spin" />
) : (
<Download size={14} />
)
}
data-testid={`periscope-btn-download-${dataSource}`}
disabled={isDownloading}
loading={isDownloading}
/>
</Tooltip>
</Popover>

View File

@@ -1,7 +1,7 @@
import { DrawerProps } from 'antd';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ActionItemProps } from 'container/LogDetailedView/ActionItem';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';

View File

@@ -19,6 +19,10 @@ 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',
@@ -54,7 +58,7 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
);
}
describe('LogDetail drawer — header', () => {
describe('LogDetail drawer — header (isLogDetailsV2)', () => {
afterEach(() => {
jest.clearAllMocks();
localStorage.clear();

View File

@@ -1,10 +1,16 @@
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 LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import cx from 'classnames';
import LogStateIndicator, {
LogType,
} from 'components/Logs/LogStateIndicator/LogStateIndicator';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
@@ -13,24 +19,33 @@ 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';
@@ -41,6 +56,7 @@ 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';
@@ -48,8 +64,11 @@ import './LogDetails.styles.scss';
function LogDetailInner({
log,
onClose,
onAddToQuery,
onClickActionItem,
selectedTab,
isListViewPanel = false,
listViewPanelSelectedFields,
handleChangeSelectedView,
logs,
onNavigateLog,
@@ -62,6 +81,7 @@ function LogDetailInner({
const [contextQuery, setContextQuery] = useState<Query | undefined>(
initialContextQuery,
);
const [, copyToClipboard] = useCopyToClipboard();
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
@@ -74,6 +94,8 @@ 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 => {
@@ -151,6 +173,12 @@ 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);
@@ -192,6 +220,13 @@ 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
@@ -260,6 +295,8 @@ function LogDetailInner({
}
};
const logType = log?.attributes_string?.log_level || LogType.INFO;
return (
<Drawer
width="60%"
@@ -267,15 +304,69 @@ function LogDetailInner({
maskClosable={false}
getContainer={getContainer}
title={
<LogDetailsHeader
log={log}
onNavigatePrev={goToPrev}
onNavigateNext={goToNext}
isPrevDisabled={isPrevDisabled}
isNextDisabled={isNextDisabled}
showOpenInExplorer={!!handleOpenInExplorer}
onOpenInExplorer={handleOpenInExplorer}
/>
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>
)
}
placement="right"
onClose={drawerCloseHandler}
@@ -294,11 +385,15 @@ function LogDetailInner({
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
{isLogDetailsV2 ? (
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
) : (
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
)}
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
@@ -310,9 +405,9 @@ function LogDetailInner({
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<LogHighlights log={log} />
{isLogDetailsV2 && <LogHighlights log={log} />}
<div className="log-detail-drawer__section-divider" />
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
<div className="tabs-and-search">
<ToggleGroupSimple
@@ -330,6 +425,21 @@ 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: (
@@ -368,6 +478,26 @@ 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] && (
@@ -384,11 +514,19 @@ 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}

View File

@@ -0,0 +1,11 @@
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}/`)
);
}

View File

@@ -0,0 +1,54 @@
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',
};

View File

@@ -47,5 +47,4 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
}

View File

@@ -38,6 +38,7 @@ 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',

View File

@@ -53,10 +53,6 @@
z-index: 0;
background: var(--l1-background);
// Column so the bottom strip sits under the scrolling content, not inside it.
display: flex;
flex-direction: column;
&.full-screen-content {
width: 100%;
}
@@ -74,9 +70,7 @@
.chat-support-gateway {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: calc(20px + var(--bottom-strip-height, 0px));
bottom: 20px;
right: 20px;
z-index: 1000;

View File

@@ -43,7 +43,6 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import BottomStrip from 'container/BottomStrip';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
@@ -52,7 +51,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { isNull } from 'lodash-es';
@@ -404,7 +402,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [pathname]);
const isToDisplayLayout = isLoggedIn;
const isSavedViewEnabled = useSavedViewEnabled();
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
@@ -871,10 +868,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</OverlayScrollbar>
</LayoutContent>
</Sentry.ErrorBoundary>
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
<BottomStrip />
)}
</div>
{isLoggedIn && isAIAssistantEnabled && (

View File

@@ -12,12 +12,8 @@ export const Layout = styled(LayoutComponent)`
}
`;
// Takes the height left in `.app-content` after the bottom strip.
// `min-height: 0` is not needed right now, overlayscrollbars already sets
// `overflow: auto` here. Kept so this does not break if that goes away.
export const LayoutContent = styled(LayoutComponent.Content)`
flex: 1;
min-height: 0;
height: 100%;
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -1,36 +0,0 @@
.strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
flex-shrink: 0;
height: var(--bottom-strip-height);
padding: 0 var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
font-family: var(--font-family-sf-mono, monospace);
// Above page content, below the body-portalled overlays that are meant to
// cover the strip.
position: relative;
z-index: 1;
}
.left,
.right {
display: flex;
align-items: center;
gap: var(--spacing-6);
min-width: 0;
}
// Temporary placeholder for the left slot. Replaced later.
.version {
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,49 +0,0 @@
import { render } from 'tests/test-utils';
import BottomStrip, {
BOTTOM_STRIP_HEIGHT,
BOTTOM_STRIP_HEIGHT_VAR,
BOTTOM_STRIP_ON_CLASS,
} from '..';
describe('BottomStrip', () => {
it('publishes the body class and height property while mounted', () => {
const { unmount } = render(<BottomStrip />);
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
`${BOTTOM_STRIP_HEIGHT}px`,
);
unmount();
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
'',
);
});
// The string is whatever the Go build injected, so it is rendered untouched —
// same as SideNav. Release tags carry the "v", local builds do not.
it.each([['v0.134.67'], ['main-64f1c2a']])(
'renders the build version %p exactly as given',
(version) => {
const { getByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: {
versionData: { version, ee: 'Y', setupCompleted: true },
},
});
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
},
);
it('renders the strip without a version when none is available', () => {
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: { versionData: null },
});
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
});
});

View File

@@ -1,42 +0,0 @@
import { useLayoutEffect } from 'react';
import { useAppContext } from 'providers/App/App';
import styles from './BottomStrip.module.scss';
export const BOTTOM_STRIP_HEIGHT = 24;
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
function BottomStrip(): JSX.Element {
const { versionData } = useAppContext();
const version = versionData?.version?.trim();
useLayoutEffect(() => {
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
document.body.style.setProperty(
BOTTOM_STRIP_HEIGHT_VAR,
`${BOTTOM_STRIP_HEIGHT}px`,
);
return (): void => {
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
};
}, []);
return (
<div className={styles.strip} data-testid="bottom-strip">
<div className={styles.left}>
{version && (
<span className={styles.version} data-testid="bottom-strip-version">
{version}
</span>
)}
</div>
<div className={styles.right} />
</div>
);
}
export default BottomStrip;

View File

@@ -1,8 +1,6 @@
.create-alert-v2-footer {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0;
left: 63px;
right: 0;
background-color: var(--l1-background);

View File

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

View File

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

View File

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

View File

@@ -1,320 +0,0 @@
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),
);
});
});

View File

@@ -1,245 +0,0 @@
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);
});
});

View File

@@ -1,144 +0,0 @@
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);
});
});

View File

@@ -1,85 +0,0 @@
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),
)}`;
}

View File

@@ -1,8 +1,6 @@
.explorer-options-container {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0px;
left: calc(50% + 240px);
transform: translate(calc(-50% - 120px), 0);
transition: left 0.2s linear;

View File

@@ -1,8 +1,6 @@
.explorer-option-droppable-container {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0;
width: -webkit-fill-available;
height: 24px;
display: flex;

View File

@@ -1,6 +1,7 @@
.home-container {
display: flex;
flex-direction: column;
min-height: 100vh;
overflow-y: auto;
height: 100%;
width: 100%;

View File

@@ -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/LogDetailedView.types';
import { DataType } from 'container/LogDetailedView/TableView';
import styles from './EventsContent.module.scss';

View File

@@ -1,4 +1,7 @@
.licenses-page {
max-height: 100vh;
overflow: hidden;
.licenses-page-header {
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
@@ -29,6 +32,7 @@
.licenses-page-content {
flex: 1;
height: calc(100vh - 48px);
background: var(--l1-background);
padding: 10px 8px;
overflow-y: auto;

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
height: calc(100vh - 62px);
min-height: 400px;
}

View File

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

View File

@@ -0,0 +1,14 @@
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;
}
`;

View File

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

View File

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

View File

@@ -1,5 +1,14 @@
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { ILog } from 'types/api/logs/log';
export interface BodyTitleRendererProps {
title: string;
nodeKey: string;
value: unknown;
parentIsArray?: boolean;
handleChangeSelectedView?: ChangeViewFunctionType;
}
export type AnyObject = { [key: string]: any };
@@ -13,21 +22,6 @@ export interface IFieldAttributes {
logType?: MetricsType;
}
/** 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;
export interface JSONViewProps {
logData: ILog;
}

View File

@@ -1,13 +1,32 @@
import { ReactNode } from 'react';
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 { 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';
@@ -19,58 +38,247 @@ 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,
}: OverviewProps): JSX.Element {
}: 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();
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
onApplyLogFilter,
});
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);
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);
};
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 }),
}}
/>
);
<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',
},
}}
jsonString={JSON.stringify(raw, null, 2)}
]}
/>
<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',
},
]}
/>
</div>
);
@@ -78,6 +286,7 @@ function Overview({
Overview.defaultProps = {
isListViewPanel: false,
listViewPanelSelectedFields: null,
handleChangeSelectedView: undefined,
};

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
.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;
}
}
}

View File

@@ -0,0 +1,532 @@
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,
};

View File

@@ -0,0 +1,366 @@
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',
});
});
});

View File

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

View File

@@ -0,0 +1,108 @@
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'),
}),
);
});
});
});

View File

@@ -1,7 +1,9 @@
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';
@@ -51,6 +53,7 @@ export function useLogAttributeActions({
isListViewPanel = false,
onApplyLogFilter,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
@@ -58,6 +61,8 @@ 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(
@@ -214,7 +219,8 @@ export function useLogAttributeActions({
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported,
.groupBySupported ||
isLiveLogs,
},
{
key: LogDetailsAction.REPLACE_FILTER,
@@ -222,7 +228,7 @@ export function useLogAttributeActions({
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView || isRestricted(fieldKeyPath),
!handleChangeSelectedView || isRestricted(fieldKeyPath) || isLiveLogs,
},
];
}, [
@@ -230,6 +236,7 @@ export function useLogAttributeActions({
groupBy,
replaceFilter,
isBodyJsonQueryEnabled,
isLiveLogs,
handleChangeSelectedView,
onApplyLogFilter,
]);

View File

@@ -1,7 +1,10 @@
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,
@@ -10,6 +13,7 @@ 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';
@@ -36,6 +40,135 @@ 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}.` : '';
@@ -121,6 +254,14 @@ 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,
@@ -277,6 +418,16 @@ export const escapeHtml = (unsafe: string): string =>
.replace(/'/g, '&#039;');
// 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, '')
@@ -307,6 +458,10 @@ 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,

View File

@@ -1,4 +1,4 @@
import { ReactNode, useState } from 'react';
import { useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
@@ -6,6 +6,7 @@ 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';
@@ -13,18 +14,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,
@@ -59,43 +60,48 @@ function LogsActionsContainer({
<div className="logs-actions-container">
<div className="tab-options">
<div className="tab-options-left">
<div className="frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
value={showFrequencyChart}
defaultValue
onChange={handleToggleFrequencyChart}
/>
</div>
{selectedPanelType === PANEL_TYPES.LIST && (
<div className="frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
value={showFrequencyChart}
defaultValue
onChange={handleToggleFrequencyChart}
/>
</div>
)}
</div>
<div className="tab-options-right">
{explorerActions}
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
{selectedPanelType === PANEL_TYPES.LIST && (
<>
<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 && (

View File

@@ -187,7 +187,6 @@
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -37,7 +37,6 @@ 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';
@@ -141,13 +140,6 @@ function LogsExplorerViewsContainer({
[selectedPanelType, requestData],
);
const explorerActions = useMemo(
() => (
<ExplorerActions query={exportDefaultQuery} sourcepage={DataSource.LOGS} />
),
[exportDefaultQuery],
);
const {
data: listChartData,
isFetching: isFetchingListChartData,
@@ -424,14 +416,14 @@ function LogsExplorerViewsContainer({
return (
<div className="logs-explorer-views-container">
<div className="logs-explorer-views-types">
{!showLiveLogs && selectedPanelType === PANEL_TYPES.LIST && (
{!showLiveLogs && (
<LogsActionsContainer
listQuery={listQuery}
selectedPanelType={selectedPanelType}
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
explorerActions={explorerActions}
/>
)}
@@ -482,23 +474,21 @@ function LogsExplorerViewsContainer({
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
headerActions={explorerActions}
/>
</div>
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
<div className="table-view-container">
<div className="table-view-container-header">
{explorerActions}
{data && !isError && (
{data && !isError && (
<div className="table-view-container-header">
<ExportMenu
dataSource={DataSource.LOGS}
data={data}
query={stagedQuery || initialQueriesMap.metrics}
fileName="logs-table"
/>
)}
</div>
</div>
)}
<LogsExplorerTable
data={
(data?.payload?.data?.newResult?.data?.result ||

View File

@@ -394,7 +394,6 @@ function Explorer(): JSX.Element {
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>

View File

@@ -19,7 +19,6 @@ 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';
@@ -52,7 +51,6 @@ function TimeSeries({
showYAxisUnitSelector,
metrics,
isCancelled = false,
exportDefaultQuery,
}: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder();
@@ -274,9 +272,6 @@ function TimeSeries({
metricName;
const currentYAxisUnit = yAxisUnit || metricUnit;
const exportQuery = changeLayoutForOneChartPerQuery
? queryPayloads[index]
: exportDefaultQuery;
return (
<div
@@ -317,14 +312,6 @@ 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>
);

View File

@@ -4,7 +4,6 @@ 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,
@@ -147,11 +146,9 @@ function renderExplorer(): void {
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<TooltipProvider>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</TooltipProvider>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,

View File

@@ -1,7 +1,6 @@
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';
@@ -72,7 +71,6 @@ function renderTimeSeries(
yAxisUnit="count"
setYAxisUnit={mockSetYAxisUnit}
showYAxisUnitSelector={false}
exportDefaultQuery={initialQueriesMap.metrics}
{...overrides}
/>,
);

View File

@@ -1,7 +1,6 @@
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;
@@ -18,5 +17,4 @@ export interface TimeSeriesProps {
setYAxisUnit: (unit: string) => void;
showYAxisUnitSelector: boolean;
isCancelled?: boolean;
exportDefaultQuery: Query;
}

View File

@@ -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/LogDetailedView.types';
import { DataType } from 'container/LogDetailedView/TableView';
import { CircleArrowDown, CircleArrowRight, Focus } from '@signozhq/icons';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';

View File

@@ -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/LogDetailedView.types';
import { DataType } from 'container/LogDetailedView/TableView';
import {
Check,
Copy,

View File

@@ -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/LogDetailedView.types';
import { DataType } from 'container/LogDetailedView/TableView';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Save, X } from '@signozhq/icons';

View File

@@ -181,9 +181,7 @@
.ant-pagination {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0;
width: calc(100% - 54px);
background: var(--l1-background);
padding: 16px;

View File

@@ -19,7 +19,6 @@ 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',

View File

@@ -1,25 +0,0 @@
.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;
}

View File

@@ -0,0 +1,19 @@
.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;
}
}

View File

@@ -1,6 +1,5 @@
import {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useEffect,
@@ -11,7 +10,6 @@ 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';
@@ -52,7 +50,7 @@ import { getTimeRange } from 'utils/getTimeRange';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import styles from './TimeSeriesView.module.scss';
import './TimeSeriesView.styles.scss';
function TimeSeriesView({
data,
@@ -68,7 +66,6 @@ function TimeSeriesView({
allowExport = false,
exportFileName,
onYAxisUnitChange,
headerActions,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -255,31 +252,28 @@ function TimeSeriesView({
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange || !!headerActions;
const showHeader = showExport || !!onYAxisUnitChange;
return (
<div className={cx('time-series-view', styles.timeSeriesView)}>
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className={styles.header} data-testid="time-series-view-header">
<div className="time-series-view__header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
<div className={styles.headerActions}>
{headerActions}
{showExport && data?.rawV5Response && (
<ExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
</div>
{showExport && data?.rawV5Response && (
<ExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
</div>
)}
@@ -350,8 +344,6 @@ 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 = {

View File

@@ -131,7 +131,7 @@ describe('TimeSeriesView header gating', () => {
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { queryByTestId } = renderView({ withRawV5: false });
expect(queryByTestId('time-series-view-header')).toBeNull();
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
});
});

View File

@@ -207,4 +207,4 @@ export const routesToSkip = [
ROUTES.AI_OBSERVABILITY_EXPLORER,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -2,7 +2,6 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useCallback,
useEffect,
@@ -56,7 +55,6 @@ interface ListViewProps {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
headerActions?: ReactNode;
}
function ListView({
@@ -64,7 +62,6 @@ function ListView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
@@ -230,7 +227,6 @@ 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} />
@@ -276,7 +272,6 @@ function ListView({
ListView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(ListView);

View File

@@ -2,7 +2,6 @@
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -2,7 +2,6 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useEffect,
useMemo,
@@ -31,12 +30,10 @@ 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();
@@ -104,17 +101,14 @@ function TableView({
return (
<Space.Compact block direction="vertical">
{isError && error && <ErrorInPlace error={error as APIError} />}
{!isError && (
{!isError && data && (
<div className="traces-table-view-header">
{headerActions}
{data && (
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
)}
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
</div>
)}
{!isError && (
@@ -131,7 +125,6 @@ function TableView({
TableView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(TableView);

View File

@@ -2,7 +2,6 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useEffect,
useMemo,
@@ -41,7 +40,6 @@ interface TracesViewProps {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
headerActions?: ReactNode;
}
function TracesView({
@@ -49,7 +47,6 @@ function TracesView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
@@ -158,7 +155,6 @@ function TracesView({
</Typography>
<div className="trace-explorer-controls">
{headerActions}
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
@@ -191,7 +187,6 @@ function TracesView({
TracesView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(TracesView);

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
height: calc(100vh - 62px);
min-height: 400px;
padding-top: var(--spacing-8);
}

View File

@@ -1,4 +1,7 @@
.version-container {
max-height: 100vh;
overflow: hidden;
.version-page-header {
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);

View File

@@ -0,0 +1,183 @@
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,
};
};

View File

@@ -1,11 +0,0 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useState } from 'react';
export function useSavedViewEnabled(): boolean {
const [isEnabled] = useState(
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
);
return isEnabled;
}

View File

@@ -0,0 +1,9 @@
export const fieldSearchFilter = (
searchSpace = '',
currentValue = '',
): boolean => {
if (!currentValue || !searchSpace) {
return true;
}
return searchSpace.toLowerCase().indexOf(currentValue.toLowerCase()) !== -1;
};

View File

@@ -1,29 +1,4 @@
.alerts-container {
// Hands the page height down to the active tab so its content can bound itself
// instead of guessing with 100vh. Child combinators only, nested Tabs
// (Configuration) must not be caught.
flex: 1;
min-height: 0;
> .ant-tabs-content-holder {
display: flex;
flex-direction: column;
> .ant-tabs-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
> .ant-tabs-tabpane-active {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
}
.top-level-tab.periscope-tab {
padding: 2px 0;
}
@@ -65,9 +40,5 @@
.alert-rules-container {
margin-top: 10px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}

View File

@@ -8,6 +8,7 @@
background-color: var(--l1-background);
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
padding-bottom: 44px;
@include custom-scrollbar;
@@ -42,11 +43,12 @@
.eyebrow {
display: block;
font-size: 11px;
font-weight: 600;
padding: 16px;
letter-spacing: 0.06em;
color: var(--l1-foreground);
padding: 18px 20px 12px;
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--l2-foreground);
}
.group {
@@ -73,9 +75,5 @@
.sections {
display: flex;
flex-direction: column;
& > * {
padding: 0 16px;
border-top: 1px solid var(--l1-border);
}
border-bottom: 1px solid var(--l1-border);
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
@@ -7,6 +8,7 @@ import type {
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { buildPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -25,6 +27,7 @@ interface ConfigPaneProps {
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
/**
* Active query type from the query-builder provider (the selected tab). Drives which
* panel types the visualization switcher disables — read from the provider, not the
@@ -57,6 +60,7 @@ function ConfigPane({
spec,
onChangeSpec,
onChangePanelKind,
originalPanelKind,
queryType,
legendSeries,
tableColumns,
@@ -76,6 +80,11 @@ function ConfigPane({
(config) => config.kind !== SectionKind.PanelHeader,
);
const defaults = useMemo(
() => buildPluginSpec(definition.sections),
[definition],
);
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
// Title/description are just a slice of the spec — edit them through the same
@@ -120,11 +129,13 @@ function ConfigPane({
config={headerSection}
spec={spec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
@@ -144,11 +155,13 @@ function ConfigPane({
config={config}
spec={spec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}

View File

@@ -1,6 +1,56 @@
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
@use '../../../../../../styles/scrollbar' as *;
.trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 5px 5px 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
border-color: var(--l3-border);
}
}
.triggerIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.triggerName {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--l1-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.triggerAction {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 3px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
}
.revert {
align-self: flex-start;
}
.drawerBody {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
}

View File

@@ -1,12 +1,17 @@
import { Typography } from '@signozhq/ui/typography';
import { useCallback, useState } from 'react';
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../controls/ConfigField/ConfigField';
import styles from './PanelTypeSwitcher.module.scss';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
@@ -15,33 +20,96 @@ interface PanelTypeSwitcherProps {
queryType: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
/** Kind the panel was opened with; a revert button appears once it differs. */
originalPanelKind?: PanelKind;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). A type is
* disabled when the active query type or signal is incompatible with it — resolved
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
* those query types still disable kinds that only support Query Builder (e.g. List).
* Visualization-type selector (rendered inside the Visualization section): opens the
* panel type browser in a drawer. A type is disabled when the active query type or
* signal is incompatible with it — resolved through the capabilities guard.
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
originalPanelKind,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const [isOpen, setIsOpen] = useState(false);
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
const getDisabledReason = useCallback(
(kind: PanelKind): string | undefined =>
getPanelTypeDisabledReason({
kind,
queryType,
signal,
label: getPanelDefinition(kind).displayName,
}),
[queryType, signal],
);
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
const revertBlockedReason = canRevert
? getDisabledReason(originalPanelKind)
: undefined;
const handleSelect = (kind: PanelKind): void => {
setIsOpen(false);
if (kind !== panelKind) {
onChange(kind);
}
};
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value)}
/>
</div>
<ConfigField label="Panel type" plain>
<button
type="button"
className={styles.trigger}
onClick={(): void => setIsOpen(true)}
data-testid="panel-editor-v2-type-switcher"
>
<Icon size={14} className={styles.triggerIcon} />
<span className={styles.triggerName}>{displayName}</span>
<span className={styles.triggerAction}>
<ArrowRightLeft size={14} />
Change
</span>
</button>
{canRevert && (
<Button
variant="link"
color="primary"
size="sm"
prefix={<Undo2 />}
className={styles.revert}
disabled={!!revertBlockedReason}
title={revertBlockedReason}
onClick={(): void => onChange(originalPanelKind)}
testId="panel-editor-v2-type-revert"
>
Revert to {getPanelDefinition(originalPanelKind).displayName}
</Button>
)}
<DrawerWrapper
open={isOpen}
onOpenChange={setIsOpen}
title="Change panel type"
subTitle="Pick a visualization for this panel."
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser
selectedKind={panelKind}
onSelect={handleSelect}
getDisabledReason={getDisabledReason}
/>
</DrawerWrapper>
</ConfigField>
);
}

View File

@@ -1,22 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
const OPTIONS = [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
{ kind: 'signoz/TextPanel', displayName: 'Text' },
].map((option) => ({ ...option, icon: (): null => null }));
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
get PANEL_OPTIONS(): unknown {
return OPTIONS;
},
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -28,14 +36,34 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
type User = ReturnType<typeof userEvent.setup>;
async function renderSwitcher(
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
): Promise<{ onChange: jest.Mock; user: User }> {
// The open drawer sets `pointer-events: none` on the body.
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<TooltipProvider>
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
return { onChange, user };
}
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
function disabledKinds(): (string | undefined)[] {
return Array.from(
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
)
.filter((el) => el.getAttribute('aria-disabled') === 'true')
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
}
describe('PanelTypeSwitcher', () => {
@@ -44,7 +72,8 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
@@ -57,83 +86,94 @@ describe('PanelTypeSwitcher', () => {
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
it('shows the current type and switches to the chosen one', async () => {
const { onChange, user } = await renderSwitcher();
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
'Time SeriesChange',
);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
it('does not fire onChange when the current type is picked again', async () => {
const { onChange, user } = await renderSwitcher();
openDropdown();
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Time Series');
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);
it('disables types whose supported signals exclude the current signal', async () => {
const { onChange, user } = await renderSwitcher({
signal: TelemetrytypesSignalDTO.metrics,
});
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
await renderSwitcher();
openDropdown();
// List/Table/Pie can't be authored in PromQL; Time Series can.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).toContain('Table');
expect(disabledLabels()).toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toHaveLength(0);
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
await renderSwitcher({ queryType: EQueryType.PROM });
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toStrictEqual(
expect.arrayContaining([
'signoz/ListPanel',
'signoz/TablePanel',
'signoz/PieChartPanel',
]),
);
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
expect(disabledKinds()).not.toContain('signoz/TextPanel');
});
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
await renderSwitcher({
panelKind: 'signoz/TablePanel',
queryType: EQueryType.CLICKHOUSE,
});
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
});
describe('revert', () => {
it('is hidden while the type is the original one', async () => {
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
expect(
screen.queryByTestId('panel-editor-v2-type-revert'),
).not.toBeInTheDocument();
});
it('switches back to the original type', async () => {
const { onChange, user } = await renderSwitcher({
panelKind: 'signoz/TablePanel',
originalPanelKind: 'signoz/TimeSeriesPanel',
});
const revert = screen.getByTestId('panel-editor-v2-type-revert');
expect(revert).toHaveTextContent('Revert to Time Series');
await user.click(revert);
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
});
it('is disabled when the original type no longer fits the query', async () => {
await renderSwitcher({
panelKind: 'signoz/TimeSeriesPanel',
originalPanelKind: 'signoz/ListPanel',
queryType: EQueryType.PROM,
});
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
});
});
});

View File

@@ -18,8 +18,7 @@ interface UsePanelTypeSelectItemsArgs {
/**
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
* tooltip) when the active query type or signal is incompatible — resolved through
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
*/
export function usePanelTypeSelectItems({
queryType,

View File

@@ -6,16 +6,20 @@ import {
type SectionConfig,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import type { SectionEditorContext } from '../sectionContext';
import { resolveSectionEditor } from '../sectionRegistry';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isChanged } from '../utils/changes';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** The kind's seeded plugin spec (`buildPluginSpec`). */
defaults: SeededPluginSpec;
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
bare?: boolean;
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
@@ -52,12 +56,14 @@ function SectionSlot({
config,
spec,
onChangeSpec,
defaults,
bare,
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
@@ -103,8 +109,10 @@ function SectionSlot({
return null;
}
const { title, icon: Icon } = SECTION_METADATA[config.kind];
const { Component, get, update } = editor;
const { title } = SECTION_METADATA[config.kind];
const { Component, get, update, summarize } = editor;
const value = get(spec);
const defaultValue = getSectionDefault(editor, spec, defaults);
// Atomic sections carry no `controls`; controlled ones do.
const controls = 'controls' in config ? config.controls : undefined;
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
@@ -113,20 +121,26 @@ function SectionSlot({
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
const context: SectionEditorContext = {
legendSeries,
yAxisUnit,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
};
const editorElement = (
<Component
value={get(spec)}
value={value}
defaultValue={defaultValue}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
{...context}
registerHeaderAction={registerHeaderAction}
/>
);
@@ -138,7 +152,8 @@ function SectionSlot({
return (
<SettingsSection
title={title}
icon={<Icon size={15} />}
summary={summarize?.(value, controls, context)}
changed={isChanged(value, defaultValue)}
open={open}
onOpenChange={setOpen}
headerSlot={headerSlot}

View File

@@ -28,7 +28,12 @@ function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
makeSpec(initial),
);
return (
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
<SectionSlot
config={THRESHOLDS_CONFIG}
spec={spec}
defaults={{}}
onChangeSpec={setSpec}
/>
);
}
@@ -70,3 +75,48 @@ describe('SectionSlot header action', () => {
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
});
});
describe('SectionSlot header summary', () => {
it('summarises a collapsed section and marks it changed', async () => {
const user = userEvent.setup();
render(
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
);
await user.click(screen.getByTestId('config-section-thresholds'));
expect(screen.getByText('1 threshold')).toBeInTheDocument();
expect(screen.getByTestId('config-changed-dot')).toBeInTheDocument();
});
it('shows no summary or changed marker for an untouched section', () => {
render(<Harness />);
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
expect(screen.queryByText(/threshold$/)).not.toBeInTheDocument();
});
});
describe('SectionSlot rendered defaults', () => {
it('does not mark an explicit value that matches what unset renders as', () => {
const spec = {
display: { name: 'CPU' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { decimalPrecision: '2' } },
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
render(
<SectionSlot
config={{ kind: SectionKind.Formatting, controls: { decimals: true } }}
spec={spec}
defaults={{}}
onChangeSpec={jest.fn()}
/>,
);
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
});
});

View File

@@ -1,51 +1,53 @@
.section {
border-top: 1px solid var(--l1-border);
}
.header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 44px;
padding: 0 20px 0 0;
&:hover {
background: var(--l2-background);
}
}
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
.toggle {
display: flex;
flex: 1;
align-items: center;
gap: 11px;
gap: 10px;
min-width: 0;
padding: 0 !important;
padding: 18px 0 18px 20px;
border: none;
background: transparent;
font: inherit;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
}
.iconTile {
display: grid;
place-items: center;
width: 27px;
height: 27px;
flex: none;
border-radius: 3px;
background: var(--l3-background);
color: var(--l3-foreground);
transition: all 0.15s ease;
}
.iconTileOpen {
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
color: var(--bg-robin-400);
}
.title {
font-size: 13px;
font-weight: 400;
white-space: nowrap;
color: var(--l1-foreground);
}
.summary {
flex: 1;
text-align: left;
font-weight: 600;
min-width: 0;
overflow: hidden;
font-size: 12px;
text-align: right;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--l2-foreground);
}
.chevron {
flex: none;
color: var(--l3-foreground);
cursor: pointer;
transition: transform 0.15s ease;
&.open {
@@ -56,6 +58,6 @@
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 2px 0 18px;
gap: 26px;
padding: 4px 20px 26px;
}

View File

@@ -1,18 +1,20 @@
import { type ReactNode, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import ChangedDot from '../controls/ChangedDot/ChangedDot';
import styles from './SettingsSection.module.scss';
interface SettingsSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
/** Controlled open state; when set, the section defers to `onOpenChange`. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** One-line digest of the section's values, shown while collapsed. */
summary?: string;
changed?: boolean;
/** Rendered between the title and the chevron. */
headerSlot?: ReactNode;
children: ReactNode;
@@ -23,10 +25,11 @@ interface SettingsSectionProps {
*/
function SettingsSection({
title,
icon,
defaultOpen = false,
open,
onOpenChange,
summary,
changed,
headerSlot,
children,
}: SettingsSectionProps): JSX.Element {
@@ -51,30 +54,18 @@ function SettingsSection({
type="button"
className={styles.toggle}
aria-expanded={isOpen}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
data-testid={`config-section-${serializedTitle}`}
onClick={toggle}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
<span className={styles.title}>{title}</span>
{changed && <ChangedDot title="Has changed settings" />}
<span className={styles.summary}>{isOpen ? '' : summary}</span>
</button>
{headerSlot}
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
prefix={
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
tabIndex={-1}
<ChevronDown
size={14}
className={cx(styles.chevron, { [styles.open]: isOpen })}
onClick={toggle}
/>
</div>

View File

@@ -108,7 +108,7 @@ describe('ConfigPane', () => {
const toggle = screen.getByTestId('panel-header-hide');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hide header')).toBeInTheDocument();
expect(screen.getByText('Hide panel header')).toBeInTheDocument();
// No collapsible wrapper of its own.
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,80 @@
.row {
display: flex;
align-items: center;
gap: 16px;
}
.grid {
display: grid;
flex: none;
grid-template-columns: repeat(3, 28px);
gap: 4px;
padding: 6px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.cell {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 22px;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
cursor: pointer;
&:hover {
background: var(--l3-background);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.mark {
width: 10px;
height: 3px;
border-radius: 2px;
background: var(--l3-foreground);
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, transparent);
.mark {
width: 14px;
background: var(--bg-robin-400);
}
}
.text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.label {
font-size: 14px;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,64 @@
import { useId } from 'react';
import cx from 'classnames';
import { type Alignment, ALIGNMENT_CELLS, alignmentLabel } from './alignment';
import styles from './AlignmentGrid.module.scss';
interface AlignmentGridProps {
testId: string;
value: Alignment;
description?: string;
onChange: (next: Alignment) => void;
}
function AlignmentGrid({
testId,
value,
description,
onChange,
}: AlignmentGridProps): JSX.Element {
const name = useId();
return (
<div className={styles.row}>
<div
role="radiogroup"
aria-label="Text position"
data-testid={testId}
className={styles.grid}
>
{ALIGNMENT_CELLS.map((cell) => {
const label = alignmentLabel(cell);
const selected =
cell.textAlign === value.textAlign &&
cell.verticalAlign === value.verticalAlign;
return (
<label
key={label}
title={label}
data-testid={`${testId}-${cell.verticalAlign}-${cell.textAlign}`}
className={cx(styles.cell, { [styles.selected]: selected })}
>
<input
type="radio"
className={styles.input}
name={name}
checked={selected}
aria-label={label}
onChange={(): void => onChange(cell)}
/>
<span className={styles.mark} />
</label>
);
})}
</div>
<div className={styles.text}>
<span className={styles.label}>{alignmentLabel(value)}</span>
{description && <span className={styles.description}>{description}</span>}
</div>
</div>
);
}
export default AlignmentGrid;

View File

@@ -0,0 +1,37 @@
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
const VERTICAL_LABELS: Record<DashboardtypesVerticalAlignDTO, string> = {
[DashboardtypesVerticalAlignDTO.top]: 'Top',
[DashboardtypesVerticalAlignDTO.center]: 'Middle',
[DashboardtypesVerticalAlignDTO.bottom]: 'Bottom',
};
const HORIZONTAL_LABELS: Record<DashboardtypesTextAlignDTO, string> = {
[DashboardtypesTextAlignDTO.left]: 'left',
[DashboardtypesTextAlignDTO.center]: 'center',
[DashboardtypesTextAlignDTO.right]: 'right',
};
export interface Alignment {
textAlign: DashboardtypesTextAlignDTO;
verticalAlign: DashboardtypesVerticalAlignDTO;
}
export const ALIGNMENT_CELLS: Alignment[] = Object.values(
DashboardtypesVerticalAlignDTO,
).flatMap((verticalAlign) =>
Object.values(DashboardtypesTextAlignDTO).map((textAlign) => ({
textAlign,
verticalAlign,
})),
);
export function alignmentLabel({
textAlign,
verticalAlign,
}: Alignment): string {
return `${VERTICAL_LABELS[verticalAlign]} ${HORIZONTAL_LABELS[textAlign]}`;
}

View File

@@ -1,7 +1,8 @@
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
gap: 8px 6px;
}
.swatch {

View File

@@ -8,31 +8,14 @@ import {
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import type {
PanelTheme,
TextBackgroundPreset,
TextBackgroundSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { BASE_TITLES, type BaseSelection, PRESET_TITLES } from './titles';
import styles from './BackgroundSwatches.module.scss';
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
robin: 'Robin',
purple: 'Purple',
sakura: 'Sakura',
cherry: 'Cherry',
amber: 'Amber',
forest: 'Forest',
sienna: 'Sienna',
slate: 'Slate',
};
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
const BASE_TITLES: Record<BaseSelection, string> = {
none: 'Transparent',
default: 'Default panel',
};
/** Neither base swatch shows a colour, so its tooltip says what it does. */
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
none: 'Transparent — no card, border or title bar',

View File

@@ -0,0 +1,38 @@
import {
type ResolvedTextBackground,
TextBackgroundKind,
type TextBackgroundPreset,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
export const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
robin: 'Robin',
purple: 'Purple',
sakura: 'Sakura',
cherry: 'Cherry',
amber: 'Amber',
forest: 'Forest',
sienna: 'Sienna',
slate: 'Slate',
};
export type BaseSelection =
| TextBackgroundKind.None
| TextBackgroundKind.Default;
export const BASE_TITLES: Record<BaseSelection, string> = {
none: 'Transparent',
default: 'Default panel',
};
export function backgroundTitle(background: ResolvedTextBackground): string {
switch (background.kind) {
case TextBackgroundKind.Preset:
return background.preset ? PRESET_TITLES[background.preset] : 'Custom';
case TextBackgroundKind.Custom:
return `Custom ${background.surface?.toUpperCase() ?? ''}`.trim();
case TextBackgroundKind.None:
return BASE_TITLES.none;
default:
return BASE_TITLES.default;
}
}

View File

@@ -0,0 +1,7 @@
.dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-robin-500);
}

View File

@@ -0,0 +1,18 @@
import styles from './ChangedDot.module.scss';
function ChangedDot({
title = 'Changed from default',
}: {
title?: string;
}): JSX.Element {
return (
<span
className={styles.dot}
title={title}
aria-label={title}
data-testid="config-changed-dot"
/>
);
}
export default ChangedDot;

View File

@@ -0,0 +1,48 @@
.field {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}
.label {
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
color: var(--l2-foreground);
}
.plainLabel {
font-size: 13px;
color: var(--l1-foreground);
}
.reset {
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-size: 12px;
color: var(--bg-robin-400);
cursor: pointer;
&:hover {
color: var(--bg-robin-300);
}
}
.help {
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
import cx from 'classnames';
import styles from './ConfigField.module.scss';
interface ConfigFieldProps {
label: ReactNode;
/** Shown under the control. */
help?: ReactNode;
changed?: boolean;
onReset?: () => void;
/** Plain body label instead of the uppercase field heading. */
plain?: boolean;
className?: string;
children: ReactNode;
}
function ConfigField({
label,
help,
changed,
onReset,
plain,
className,
children,
}: ConfigFieldProps): JSX.Element {
return (
<div className={cx(styles.field, className)}>
<div className={styles.header}>
<span className={plain ? styles.plainLabel : styles.label}>{label}</span>
{changed && onReset && (
<button
type="button"
className={styles.reset}
data-testid="config-field-reset"
onClick={onReset}
>
Reset
</button>
)}
</div>
{children}
{help && <span className={styles.help}>{help}</span>}
</div>
);
}
export default ConfigField;

View File

@@ -0,0 +1,5 @@
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import styles from './ConfigFieldRow.module.scss';
/** Side-by-side fields that wrap when the pane is narrow. */
function ConfigFieldRow({ children }: { children: ReactNode }): JSX.Element {
return <div className={styles.row}>{children}</div>;
}
export default ConfigFieldRow;

Some files were not shown because too many files have changed in this diff Show More