Compare commits

...

1 Commits

Author SHA1 Message Date
srikanthccv
474e1f02e9 chore: address several gap in summary tab 2026-03-01 20:35:35 +05:30
14 changed files with 626 additions and 252 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import {
Button,
@@ -6,7 +6,7 @@ import {
Input,
Menu,
Popover,
Skeleton,
Tooltip,
Typography,
} from 'antd';
import { ColumnsType } from 'antd/es/table';
@@ -15,7 +15,7 @@ import { useGetMetricAttributes } from 'api/generated/services/metrics';
import { ResizeTable } from 'components/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { useNotifications } from 'hooks/useNotifications';
import { Compass, Copy, Search } from 'lucide-react';
import { Check, Copy, Info, Search, SquareArrowOutUpRight } from 'lucide-react';
import { PANEL_TYPES } from '../../../constants/queryBuilder';
import ROUTES from '../../../constants/routes';
@@ -30,6 +30,8 @@ import {
import { getMetricDetailsQuery } from './utils';
const ALL_ATTRIBUTES_KEY = 'all-attributes';
const INITIAL_VISIBLE_COUNT = 5;
const COPY_FEEDBACK_DURATION_MS = 1500;
function AllAttributesEmptyText({
isErrorAttributes,
@@ -53,16 +55,27 @@ export function AllAttributesValue({
filterValue,
goToMetricsExploreWithAppliedAttribute,
}: AllAttributesValueProps): JSX.Element {
const [visibleIndex, setVisibleIndex] = useState(5);
const [attributePopoverKey, setAttributePopoverKey] = useState<string | null>(
null,
);
const [allValuesOpen, setAllValuesOpen] = useState(false);
const [allValuesSearch, setAllValuesSearch] = useState('');
const [copiedValue, setCopiedValue] = useState<string | null>(null);
const [, copyToClipboard] = useCopyToClipboard();
const { notifications } = useNotifications();
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const handleShowMore = (): void => {
setVisibleIndex(visibleIndex + 5);
};
const handleCopyWithFeedback = useCallback(
(value: string): void => {
copyToClipboard(value);
setCopiedValue(value);
clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => {
setCopiedValue(null);
}, COPY_FEEDBACK_DURATION_MS);
},
[copyToClipboard],
);
const handleMenuItemClick = useCallback(
(key: string, attribute: string): void => {
@@ -70,10 +83,10 @@ export function AllAttributesValue({
case 'open-in-explorer':
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
break;
case 'copy-attribute':
copyToClipboard(attribute);
case 'copy-value':
handleCopyWithFeedback(attribute);
notifications.success({
message: 'Attribute copied!',
message: 'Value copied!',
});
break;
default:
@@ -84,7 +97,7 @@ export function AllAttributesValue({
[
goToMetricsExploreWithAppliedAttribute,
filterKey,
copyToClipboard,
handleCopyWithFeedback,
notifications,
],
);
@@ -94,14 +107,14 @@ export function AllAttributesValue({
<Menu
items={[
{
icon: <Compass size={16} />,
label: 'Open in Explorer',
icon: <SquareArrowOutUpRight size={14} />,
label: 'Open in Metric Explorer',
key: 'open-in-explorer',
},
{
icon: <Copy size={16} />,
label: 'Copy Attribute',
key: 'copy-attribute',
icon: <Copy size={14} />,
label: 'Copy Value',
key: 'copy-value',
},
]}
onClick={(info): void => {
@@ -111,9 +124,75 @@ export function AllAttributesValue({
),
[handleMenuItemClick],
);
const filteredAllValues = useMemo(
() =>
allValuesSearch
? filterValue.filter((v) =>
v.toLowerCase().includes(allValuesSearch.toLowerCase()),
)
: filterValue,
[filterValue, allValuesSearch],
);
const allValuesPopoverContent = (
<div className="all-values-popover">
<Input
placeholder="Search values"
size="small"
prefix={<Search size={12} />}
value={allValuesSearch}
onChange={(e): void => setAllValuesSearch(e.target.value)}
allowClear
/>
<div className="all-values-list">
{allValuesOpen &&
filteredAllValues.map((attribute) => {
const isCopied = copiedValue === attribute;
return (
<div key={attribute} className="all-values-item">
<Typography.Text ellipsis className="all-values-item-text">
{attribute}
</Typography.Text>
<div className="all-values-item-actions">
<Tooltip title={isCopied ? 'Copied!' : 'Copy value'}>
<Button
type="text"
size="small"
className={isCopied ? 'copy-success' : ''}
icon={isCopied ? <Check size={12} /> : <Copy size={12} />}
onClick={(): void => {
handleCopyWithFeedback(attribute);
}}
/>
</Tooltip>
<Tooltip title="Open in Metric Explorer">
<Button
type="text"
size="small"
icon={<SquareArrowOutUpRight size={12} />}
onClick={(): void => {
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
setAllValuesOpen(false);
}}
/>
</Tooltip>
</div>
</div>
);
})}
{allValuesOpen && filteredAllValues.length === 0 && (
<Typography.Text type="secondary" className="all-values-empty">
No values found
</Typography.Text>
)}
</div>
</div>
);
return (
<div className="all-attributes-value">
{filterValue.slice(0, visibleIndex).map((attribute) => (
{filterValue.slice(0, INITIAL_VISIBLE_COUNT).map((attribute) => (
<Popover
key={attribute}
content={attributePopoverContent(attribute)}
@@ -132,10 +211,24 @@ export function AllAttributesValue({
</Button>
</Popover>
))}
{visibleIndex < filterValue.length && (
<Button type="text" onClick={handleShowMore}>
Show More
</Button>
{filterValue.length > INITIAL_VISIBLE_COUNT && (
<Popover
content={allValuesPopoverContent}
trigger="click"
open={allValuesOpen}
onOpenChange={(open): void => {
setAllValuesOpen(open);
if (!open) {
setAllValuesSearch('');
setCopiedValue(null);
}
}}
overlayClassName="all-values-popover-overlay"
>
<Button type="text" className="all-values-button">
All values ({filterValue.length})
</Button>
</Popover>
)}
</div>
);
@@ -144,18 +237,30 @@ export function AllAttributesValue({
function AllAttributes({
metricName,
metricType,
minTime,
maxTime,
}: AllAttributesProps): JSX.Element {
const [searchString, setSearchString] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
const [keyPopoverOpen, setKeyPopoverOpen] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [, copyToClipboard] = useCopyToClipboard();
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const {
data: attributesData,
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
refetch: refetchAttributes,
} = useGetMetricAttributes({
metricName,
});
} = useGetMetricAttributes(
{
metricName,
},
{
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
},
);
const attributes = useMemo(() => attributesData?.data.attributes ?? [], [
attributesData,
@@ -164,12 +269,14 @@ function AllAttributes({
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const goToMetricsExplorerwithAppliedSpaceAggregation = useCallback(
(groupBy: string) => {
(groupBy: string, valueCount?: number) => {
const limit = valueCount && valueCount > 250 ? 100 : undefined;
const compositeQuery = getMetricDetailsQuery(
metricName,
metricType,
undefined,
groupBy,
limit,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -216,6 +323,28 @@ function AllAttributes({
[metricName, metricType, handleExplorerTabChange],
);
const handleKeyMenuItemClick = useCallback(
(menuKey: string, attributeKey: string, valueCount?: number): void => {
switch (menuKey) {
case 'open-in-explorer':
goToMetricsExplorerwithAppliedSpaceAggregation(attributeKey, valueCount);
break;
case 'copy-key':
copyToClipboard(attributeKey);
setCopiedKey(attributeKey);
clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => {
setCopiedKey(null);
}, COPY_FEEDBACK_DURATION_MS);
break;
default:
break;
}
setKeyPopoverOpen(null);
},
[goToMetricsExplorerwithAppliedSpaceAggregation, copyToClipboard],
);
const filteredAttributes = useMemo(
() =>
attributes.filter(
@@ -254,21 +383,57 @@ function AllAttributes({
width: 50,
align: 'left',
className: 'metric-metadata-key',
render: (field: { label: string; contribution: number }): JSX.Element => (
<div className="all-attributes-key">
<Button
type="text"
onClick={(): void =>
goToMetricsExplorerwithAppliedSpaceAggregation(field.label)
}
>
<Typography.Text>{field.label}</Typography.Text>
</Button>
<Typography.Text className="all-attributes-contribution">
{field.contribution}
</Typography.Text>
</div>
),
render: (field: { label: string; contribution: number }): JSX.Element => {
const isCopied = copiedKey === field.label;
return (
<div className="all-attributes-key">
<Popover
content={
<Menu
items={[
{
icon: <SquareArrowOutUpRight size={14} />,
label: 'Open in Metric Explorer',
key: 'open-in-explorer',
},
{
icon: <Copy size={14} />,
label: 'Copy Key',
key: 'copy-key',
},
]}
onClick={(info): void => {
handleKeyMenuItemClick(info.key, field.label, field.contribution);
}}
/>
}
trigger="click"
placement="right"
overlayClassName="attribute-key-popover-overlay"
open={keyPopoverOpen === field.label}
onOpenChange={(open): void => {
if (!open) {
setKeyPopoverOpen(null);
} else {
setKeyPopoverOpen(field.label);
}
}}
>
<Button type="text">
<Typography.Text>{field.label}</Typography.Text>
</Button>
</Popover>
{isCopied && (
<span className="copy-feedback">
<Check size={12} />
</span>
)}
<Typography.Text className="all-attributes-contribution">
{field.contribution}
</Typography.Text>
</div>
);
},
},
{
title: 'Value',
@@ -291,7 +456,9 @@ function AllAttributes({
],
[
goToMetricsExploreWithAppliedAttribute,
goToMetricsExplorerwithAppliedSpaceAggregation,
handleKeyMenuItemClick,
keyPopoverOpen,
copiedKey,
],
);
@@ -300,7 +467,12 @@ function AllAttributes({
{
label: (
<div className="metrics-accordion-header">
<Typography.Text>All Attributes</Typography.Text>
<div className="all-attributes-header-title">
<Typography.Text>All Attributes</Typography.Text>
<Tooltip title="Showing attributes for the selected time range">
<Info size={14} />
</Tooltip>
</div>
<Input
className="all-attributes-search-input"
placeholder="Search"
@@ -329,7 +501,9 @@ function AllAttributes({
className="metrics-accordion-content all-attributes-content"
scroll={{ y: 600 }}
locale={{
emptyText: (
emptyText: isLoadingAttributes ? (
' '
) : (
<AllAttributesEmptyText
isErrorAttributes={isErrorAttributes}
refetchAttributes={refetchAttributes}
@@ -350,14 +524,6 @@ function AllAttributes({
],
);
if (isLoadingAttributes) {
return (
<div className="all-attributes-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<Collapse
bordered

View File

@@ -1,5 +1,5 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Skeleton, Tooltip, Typography } from 'antd';
import { Button, Spin, Tooltip, Typography } from 'antd';
import { useGetMetricHighlights } from 'api/generated/services/metrics';
import { InfoIcon } from 'lucide-react';
@@ -39,17 +39,6 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
metricHighlights?.lastReceived,
);
if (isLoadingMetricHighlights) {
return (
<div
className="metric-details-content-grid"
data-testid="metric-highlights-loading-state"
>
<Skeleton title={false} paragraph={{ rows: 2 }} active />
</div>
);
}
if (isErrorMetricHighlights) {
return (
<div className="metric-details-content-grid">
@@ -89,32 +78,41 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-data-points"
>
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
</Tooltip>
</Typography.Text>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-time-series-total"
>
<Tooltip
title="Active time series are those that have received data points in the last 1
hour."
placement="top"
>
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
</Tooltip>
</Typography.Text>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-last-received"
>
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
</Typography.Text>
{isLoadingMetricHighlights ? (
<div className="metric-highlights-loading-inline">
<Spin size="small" />
<Typography.Text type="secondary">Loading metric stats</Typography.Text>
</div>
) : (
<>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-data-points"
>
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
</Tooltip>
</Typography.Text>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-time-series-total"
>
<Tooltip
title="Active time series are those that have received data points in the last 1
hour."
placement="top"
>
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
</Tooltip>
</Typography.Text>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-last-received"
>
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
</Typography.Text>
</>
)}
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
import { Button, Collapse, Input, Select, Spin, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import logEvent from 'api/common/logEvent';
import {
@@ -355,11 +355,15 @@ function Metadata({
label: (
<div className="metrics-accordion-header metrics-metadata-header">
<Typography.Text>Metadata</Typography.Text>
{actionButton}
{!isLoadingMetricMetadata && actionButton}
</div>
),
key: 'metric-metadata',
children: isErrorMetricMetadata ? (
children: isLoadingMetricMetadata ? (
<div className="metrics-accordion-loading-state">
<Spin size="small" />
</div>
) : isErrorMetricMetadata ? (
<div className="metric-metadata-error-state">
<MetricDetailsErrorState
refetch={refetchMetricMetadata}
@@ -381,20 +385,13 @@ function Metadata({
[
actionButton,
columns,
isLoadingMetricMetadata,
isErrorMetricMetadata,
refetchMetricMetadata,
tableData,
],
);
if (isLoadingMetricMetadata) {
return (
<div className="metrics-metadata-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<Collapse
bordered

View File

@@ -52,6 +52,13 @@
align-items: center;
}
.metric-highlights-loading-inline {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
}
.metric-highlights-error-state {
display: flex;
gap: 8px;
@@ -120,12 +127,11 @@
}
}
.metrics-metadata-skeleton-container {
height: 330px;
}
.all-attributes-skeleton-container {
height: 600px;
.metrics-accordion-loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
}
.metrics-accordion {
@@ -153,6 +159,18 @@
justify-content: space-between;
align-items: center;
height: 36px;
.all-attributes-header-title {
display: flex;
align-items: center;
gap: 6px;
.lucide-info {
cursor: pointer;
color: var(--bg-vanilla-400);
}
}
.ant-typography {
font-family: 'Geist Mono';
color: var(--bg-robin-400);
@@ -186,6 +204,7 @@
.all-attributes-key {
display: flex;
justify-content: space-between;
align-items: center;
.ant-btn {
.ant-typography:first-child {
font-family: 'Geist Mono';
@@ -193,17 +212,15 @@
background-color: transparent;
}
}
.copy-feedback {
display: inline-flex;
align-items: center;
color: var(--bg-forest-500);
animation: fade-in-out 1.5s ease-in-out;
}
.all-attributes-contribution {
font-family: 'Geist Mono';
color: var(--bg-vanilla-400);
background-color: rgba(171, 189, 255, 0.1);
height: 24px;
min-width: 24px;
border-radius: 50%;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
}
}
@@ -259,10 +276,8 @@
}
.metric-metadata-key {
cursor: pointer;
padding-left: 10px;
vertical-align: middle;
text-align: center;
.field-renderer-container {
.label {
color: var(--bg-vanilla-400);
@@ -448,3 +463,138 @@
height: 100%;
width: 100%;
}
.attribute-key-popover-overlay {
.ant-popover-inner {
padding: 0 !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.ant-menu {
font-size: 12px;
background: transparent;
.ant-menu-item {
height: 32px;
line-height: 32px;
padding: 0 12px;
font-size: 12px;
}
}
}
.all-values-popover-overlay {
.ant-popover-inner {
padding: 0 !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
}
.all-values-popover {
width: 320px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
.all-values-list {
max-height: 300px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
&::-webkit-scrollbar {
width: 2px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
border-radius: 1px;
}
}
.all-values-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 4px;
gap: 8px;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
.all-values-item-text {
flex: 1;
min-width: 0;
font-family: 'Geist Mono';
font-size: 12px;
}
.all-values-item-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
.copy-success {
color: var(--bg-forest-500);
}
}
}
.all-values-empty {
padding: 8px;
text-align: center;
}
}
.all-values-button {
color: var(--bg-robin-400) !important;
}
.lightMode {
.attribute-key-popover-overlay,
.all-values-popover-overlay {
.ant-popover-inner {
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100) !important;
}
}
}
@keyframes fade-in-out {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
85% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -1,10 +1,14 @@
import { useCallback, useEffect, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricMetadata } from 'api/generated/services/metrics';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, Crosshair, X } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { PANEL_TYPES } from '../../../constants/queryBuilder';
import ROUTES from '../../../constants/routes';
@@ -29,6 +33,9 @@ function MetricDetails({
}: MetricDetailsProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const {
data: metricMetadataResponse,
@@ -100,6 +107,21 @@ function MetricDetails({
const isActionButtonDisabled =
!metricName || isLoadingMetricMetadata || isErrorMetricMetadata;
const handleDrawerClose = useCallback(
(e: React.MouseEvent | React.KeyboardEvent): void => {
if ('key' in e && e.key === 'Escape') {
const openPopover = document.querySelector(
'.ant-popover:not(.ant-popover-hidden)',
);
if (openPopover) {
return;
}
}
onClose();
},
[onClose],
);
return (
<Drawer
width="60%"
@@ -137,7 +159,7 @@ function MetricDetails({
</div>
}
placement="right"
onClose={onClose}
onClose={handleDrawerClose}
open={isOpen}
style={{
overscrollBehavior: 'contain',
@@ -157,7 +179,12 @@ function MetricDetails({
isLoadingMetricMetadata={isLoadingMetricMetadata}
refetchMetricMetadata={refetchMetricMetadata}
/>
<AllAttributes metricName={metricName} metricType={metadata?.type} />
<AllAttributes
metricName={metricName}
metricType={metadata?.type}
minTime={minTime}
maxTime={maxTime}
/>
</div>
</Drawer>
);

View File

@@ -1,8 +1,6 @@
import * as reactUseHooks from 'react-use';
import { render, screen } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import { userEvent } from 'tests/test-utils';
import ROUTES from '../../../../constants/routes';
@@ -15,17 +13,6 @@ jest.mock('react-router-dom', () => ({
pathname: `${ROUTES.METRICS_EXPLORER}`,
}),
}));
const mockHandleExplorerTabChange = jest.fn();
jest
.spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange')
.mockReturnValue({
handleExplorerTabChange: mockHandleExplorerTabChange,
});
const mockUseCopyToClipboard = jest.fn();
jest
.spyOn(reactUseHooks, 'useCopyToClipboard')
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
const useGetMetricAttributesMock = jest.spyOn(
metricsExplorerHooks,
@@ -34,12 +21,13 @@ const useGetMetricAttributesMock = jest.spyOn(
describe('AllAttributes', () => {
beforeEach(() => {
jest.clearAllMocks();
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData(),
});
});
it('renders attributes section with title', () => {
it('renders attribute keys, values, and value counts from API data', () => {
render(
<AllAttributes
metricName={MOCK_METRIC_NAME}
@@ -47,39 +35,13 @@ describe('AllAttributes', () => {
/>,
);
expect(screen.getByText('All Attributes')).toBeInTheDocument();
});
it('renders all attribute keys and values', () => {
render(
<AllAttributes
metricName={MOCK_METRIC_NAME}
metricType={MetrictypesTypeDTO.gauge}
/>,
);
// Check attribute keys are rendered
expect(screen.getByText('attribute1')).toBeInTheDocument();
expect(screen.getByText('attribute2')).toBeInTheDocument();
// Check attribute values are rendered
expect(screen.getByText('value1')).toBeInTheDocument();
expect(screen.getByText('value2')).toBeInTheDocument();
expect(screen.getByText('value3')).toBeInTheDocument();
});
it('renders value counts correctly', () => {
render(
<AllAttributes
metricName={MOCK_METRIC_NAME}
metricType={MetrictypesTypeDTO.gauge}
/>,
);
expect(screen.getByText('2')).toBeInTheDocument(); // For attribute1
expect(screen.getByText('1')).toBeInTheDocument(); // For attribute2
});
it('handles empty attributes array', () => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData({
@@ -100,7 +62,7 @@ describe('AllAttributes', () => {
expect(screen.getByText('No attributes found')).toBeInTheDocument();
});
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => {
it('clicking on an attribute key shows popover with Open in Metric Explorer option', async () => {
render(
<AllAttributes
metricName={MOCK_METRIC_NAME}
@@ -108,7 +70,8 @@ describe('AllAttributes', () => {
/>,
);
await userEvent.click(screen.getByText('attribute1'));
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
expect(screen.getByText('Open in Metric Explorer')).toBeInTheDocument();
expect(screen.getByText('Copy Key')).toBeInTheDocument();
});
it('filters attributes based on search input', async () => {
@@ -123,26 +86,66 @@ describe('AllAttributes', () => {
expect(screen.getByText('attribute1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
});
it('shows error state when attribute fetching fails', () => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData(
{
data: {
attributes: [],
totalKeys: 0,
},
},
{
isError: true,
},
),
});
render(
<AllAttributes
metricName={MOCK_METRIC_NAME}
metricType={MetrictypesTypeDTO.gauge}
/>,
);
expect(
screen.getByText('Something went wrong while fetching attributes'),
).toBeInTheDocument();
});
it('does not show misleading empty text while loading', () => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData(
{
data: {
attributes: [],
totalKeys: 0,
},
},
{
isLoading: true,
},
),
});
render(
<AllAttributes
metricName={MOCK_METRIC_NAME}
metricType={MetrictypesTypeDTO.gauge}
/>,
);
expect(screen.queryByText('No attributes found')).not.toBeInTheDocument();
});
});
describe('AllAttributesValue', () => {
const mockGoToMetricsExploreWithAppliedAttribute = jest.fn();
it('renders all attribute values', () => {
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={['value1', 'value2']}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
expect(screen.getByText('value2')).toBeInTheDocument();
beforeEach(() => {
jest.clearAllMocks();
});
it('loads more attributes when show more button is clicked', async () => {
it('shows All values button when there are more than 5 values', () => {
render(
<AllAttributesValue
filterKey="attribute1"
@@ -153,58 +156,59 @@ describe('AllAttributesValue', () => {
/>,
);
expect(screen.queryByText('value6')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Show More'));
expect(screen.getByText('value6')).toBeInTheDocument();
expect(screen.getByText('All values (6)')).toBeInTheDocument();
});
it('does not render show more button when there are no more attributes to show', () => {
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={['value1', 'value2']}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
});
it('copy button should copy the attribute value to the clipboard', async () => {
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={['value1', 'value2']}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
await userEvent.click(screen.getByText('value1'));
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
await userEvent.click(screen.getByText('Copy Attribute'));
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
});
it('explorer button should go to metrics explore with the attribute filter applied', async () => {
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={['value1', 'value2']}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
await userEvent.click(screen.getByText('value1'));
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
await userEvent.click(screen.getByText('Open in Explorer'));
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
'attribute1',
it('All values popover shows values beyond the initial 5', async () => {
const values = [
'value1',
'value2',
'value3',
'value4',
'value5',
'value6',
'value7',
];
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={values}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
await userEvent.click(screen.getByText('All values (7)'));
expect(screen.getByText('value6')).toBeInTheDocument();
expect(screen.getByText('value7')).toBeInTheDocument();
});
it('All values popover search filters the value list', async () => {
const values = [
'alpha',
'bravo',
'charlie',
'delta',
'echo',
'fig-special',
'golf-target',
];
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={values}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
await userEvent.click(screen.getByText('All values (7)'));
await userEvent.type(screen.getByPlaceholderText('Search values'), 'golf');
expect(screen.getByText('golf-target')).toBeInTheDocument();
expect(screen.queryByText('fig-special')).not.toBeInTheDocument();
});
});

View File

@@ -48,7 +48,7 @@ describe('Highlights', () => {
).toBeInTheDocument();
});
it('should render loading state when data is loading', () => {
it('should show labels and loading text but not stale data values while loading', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData(
{},
@@ -60,8 +60,19 @@ describe('Highlights', () => {
render(<Highlights metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText('SAMPLES')).toBeInTheDocument();
expect(screen.getByText('TIME SERIES')).toBeInTheDocument();
expect(screen.getByText('LAST RECEIVED')).toBeInTheDocument();
expect(screen.getByText('Loading metric stats')).toBeInTheDocument();
expect(
screen.getByTestId('metric-highlights-loading-state'),
).toBeInTheDocument();
screen.queryByTestId('metric-highlights-data-points'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('metric-highlights-time-series-total'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('metric-highlights-last-received'),
).not.toBeInTheDocument();
});
});

View File

@@ -324,6 +324,21 @@ describe('Metadata', () => {
expect(editButton2).toBeInTheDocument();
});
it('should show section header but not edit controls while loading', () => {
render(
<Metadata
metricName={MOCK_METRIC_NAME}
metadata={null}
isErrorMetricMetadata={false}
isLoadingMetricMetadata
refetchMetricMetadata={mockRefetchMetricMetadata}
/>,
);
expect(screen.getByText('Metadata')).toBeInTheDocument();
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
});
it('should not allow editing of unit if it is already set', async () => {
render(
<Metadata

View File

@@ -24,6 +24,13 @@ jest.mock('react-router-dom', () => ({
pathname: `${ROUTES.METRICS_EXPLORER}`,
}),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn().mockReturnValue({
maxTime: 1700000000000000000,
minTime: 1699900000000000000,
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),

View File

@@ -34,6 +34,8 @@ export interface MetadataProps {
export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
minTime?: number;
maxTime?: number;
}
export interface AllAttributesValueProps {

View File

@@ -87,6 +87,7 @@ export function getMetricDetailsQuery(
metricType: MetrictypesTypeDTO | undefined,
filter?: { key: string; value: string },
groupBy?: string,
limit?: number,
): Query {
let timeAggregation;
let spaceAggregation;
@@ -170,6 +171,7 @@ export function getMetricDetailsQuery(
},
]
: [],
...(limit ? { limit } : {}),
},
],
queryFormulas: [],

View File

@@ -1,9 +1,7 @@
import { useCallback } from 'react';
import { Tooltip } from 'antd';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { Info } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import { MetricsSearchProps } from './types';
@@ -26,15 +24,17 @@ function MetricsSearch({
onChange(currentQueryFilterExpression);
}, [currentQueryFilterExpression, onChange]);
const handleRunQuery = useCallback(
(expression: string): void => {
setCurrentQueryFilterExpression(expression);
onChange(expression);
},
[setCurrentQueryFilterExpression, onChange],
);
return (
<div className="metrics-search-container">
<div data-testid="qb-search-container" className="qb-search-container">
<Tooltip
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
placement="right"
>
<Info size={16} />
</Tooltip>
<QuerySearch
onChange={handleOnChange}
dataSource={DataSource.METRICS}
@@ -45,8 +45,9 @@ function MetricsSearch({
expression: currentQueryFilterExpression,
},
}}
onRun={handleOnChange}
onRun={handleRunQuery}
showFilterSuggestionsWithoutMetric
placeholder="Try metric_name CONTAINS 'http.server' to view all HTTP Server metrics being sent"
/>
</div>
<RunQueryBtn

View File

@@ -37,7 +37,7 @@
.metrics-search-container {
display: flex;
gap: 16px;
gap: 8px;
align-items: center;
.metrics-search-options {
@@ -51,10 +51,6 @@
gap: 8px;
flex: 1;
.lucide-info {
cursor: pointer;
}
.query-builder-search-container {
width: 100%;
}
@@ -66,8 +62,6 @@
margin-left: -16px;
margin-right: -16px;
max-height: 500px;
overflow-y: auto;
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;

View File

@@ -15,10 +15,7 @@ import {
Querybuildertypesv5OrderByDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
convertExpressionToFilters,
convertFiltersToExpression,
} from 'components/QueryBuilderV2/utils';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import NoLogs from 'container/NoLogs/NoLogs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -61,7 +58,7 @@ function Summary(): JSX.Element {
heatmapView,
setHeatmapView,
] = useState<MetricsexplorertypesTreemapModeDTO>(
MetricsexplorertypesTreemapModeDTO.timeseries,
MetricsexplorertypesTreemapModeDTO.samples,
);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -87,7 +84,14 @@ function Summary(): JSX.Element {
const [
currentQueryFilterExpression,
setCurrentQueryFilterExpression,
] = useState<string>(query?.filter?.expression || '');
] = useState<string>('');
const [appliedFilterExpression, setAppliedFilterExpression] = useState('');
const queryFilterExpression = useMemo(
() => ({ expression: appliedFilterExpression }),
[appliedFilterExpression],
);
useEffect(() => {
logEvent(MetricsExplorerEvents.TabChanged, {
@@ -100,11 +104,6 @@ function Summary(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const queryFilterExpression = useMemo(() => {
const filters = query.filters || { items: [], op: 'AND' };
return convertFiltersToExpression(filters);
}, [query.filters]);
const metricsListQuery: MetricsexplorertypesStatsRequestDTO = useMemo(() => {
return {
start: convertNanoToMilliseconds(minTime),
@@ -187,6 +186,7 @@ function Summary(): JSX.Element {
},
});
setCurrentQueryFilterExpression(expression);
setAppliedFilterExpression(expression);
setCurrentPage(1);
if (expression) {
logEvent(MetricsExplorerEvents.FilterApplied, {