Compare commits

..

4 Commits

Author SHA1 Message Date
Nikhil Mantri
c77b5b80fd Merge branch 'main' into fix/remove_decoding_url 2026-02-24 20:51:10 +05:30
nikhilmantri0902
43509681fa chore: remove comments 2026-02-22 17:35:49 +05:30
nikhilmantri0902
ff5fcc0e98 chore: remove explicit URL decoding that causes crashes on K8s parameters 2026-02-21 11:21:37 +05:30
nikhilmantri0902
122d88c4d2 chore: fixed all failing sites where double decoding is happening in infra monitoring 2026-02-21 11:07:50 +05:30
185 changed files with 2857 additions and 7649 deletions

View File

@@ -320,4 +320,3 @@ user:
# The name of the organization to create or look up for the root user. # The name of the organization to create or look up for the root user.
org: org:
name: default name: default
id: 00000000-0000-0000-0000-000000000000

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.113.0 image: signoz/signoz:v0.112.1
ports: ports:
- "8080:8080" # signoz port - "8080:8080" # signoz port
# - "6060:6060" # pprof port # - "6060:6060" # pprof port
@@ -213,7 +213,7 @@ services:
retries: 3 retries: 3
otel-collector: otel-collector:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.1 image: signoz/signoz-otel-collector:v0.142.1
entrypoint: entrypoint:
- /bin/sh - /bin/sh
command: command:
@@ -241,7 +241,7 @@ services:
replicas: 3 replicas: 3
signoz-telemetrystore-migrator: signoz-telemetrystore-migrator:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.1 image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
environment: environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000 - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.113.0 image: signoz/signoz:v0.112.1
ports: ports:
- "8080:8080" # signoz port - "8080:8080" # signoz port
volumes: volumes:
@@ -139,7 +139,7 @@ services:
retries: 3 retries: 3
otel-collector: otel-collector:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.1 image: signoz/signoz-otel-collector:v0.142.1
entrypoint: entrypoint:
- /bin/sh - /bin/sh
command: command:
@@ -167,7 +167,7 @@ services:
replicas: 3 replicas: 3
signoz-telemetrystore-migrator: signoz-telemetrystore-migrator:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.1 image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
environment: environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000 - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -82,12 +82,6 @@ exporters:
timeout: 45s timeout: 45s
sending_queue: sending_queue:
enabled: false enabled: false
metadataexporter:
cache:
provider: in_memory
dsn: tcp://clickhouse:9000/signoz_metadata
enabled: true
timeout: 45s
service: service:
telemetry: telemetry:
logs: logs:
@@ -99,19 +93,19 @@ service:
traces: traces:
receivers: [otlp] receivers: [otlp]
processors: [signozspanmetrics/delta, batch] processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces, metadataexporter, signozmeter] exporters: [clickhousetraces, signozmeter]
metrics: metrics:
receivers: [otlp] receivers: [otlp]
processors: [batch] processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter] exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus: metrics/prometheus:
receivers: [prometheus] receivers: [prometheus]
processors: [batch] processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter] exporters: [signozclickhousemetrics, signozmeter]
logs: logs:
receivers: [otlp] receivers: [otlp]
processors: [batch] processors: [batch]
exporters: [clickhouselogsexporter, metadataexporter, signozmeter] exporters: [clickhouselogsexporter, signozmeter]
metrics/meter: metrics/meter:
receivers: [signozmeter] receivers: [signozmeter]
processors: [batch/meter] processors: [batch/meter]

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.113.0} image: signoz/signoz:${VERSION:-v0.112.1}
container_name: signoz container_name: signoz
ports: ports:
- "8080:8080" # signoz port - "8080:8080" # signoz port
@@ -204,7 +204,7 @@ services:
retries: 3 retries: 3
otel-collector: otel-collector:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.1}
container_name: signoz-otel-collector container_name: signoz-otel-collector
entrypoint: entrypoint:
- /bin/sh - /bin/sh
@@ -229,7 +229,7 @@ services:
- "4318:4318" # OTLP HTTP receiver - "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator: signoz-telemetrystore-migrator:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
container_name: signoz-telemetrystore-migrator container_name: signoz-telemetrystore-migrator
environment: environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000 - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.113.0} image: signoz/signoz:${VERSION:-v0.112.1}
container_name: signoz container_name: signoz
ports: ports:
- "8080:8080" # signoz port - "8080:8080" # signoz port
@@ -132,7 +132,7 @@ services:
retries: 3 retries: 3
otel-collector: otel-collector:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.1}
container_name: signoz-otel-collector container_name: signoz-otel-collector
entrypoint: entrypoint:
- /bin/sh - /bin/sh
@@ -157,7 +157,7 @@ services:
- "4318:4318" # OTLP HTTP receiver - "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator: signoz-telemetrystore-migrator:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
container_name: signoz-telemetrystore-migrator container_name: signoz-telemetrystore-migrator
environment: environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000 - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -82,12 +82,6 @@ exporters:
timeout: 45s timeout: 45s
sending_queue: sending_queue:
enabled: false enabled: false
metadataexporter:
cache:
provider: in_memory
dsn: tcp://clickhouse:9000/signoz_metadata
enabled: true
timeout: 45s
service: service:
telemetry: telemetry:
logs: logs:
@@ -99,19 +93,19 @@ service:
traces: traces:
receivers: [otlp] receivers: [otlp]
processors: [signozspanmetrics/delta, batch] processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces, metadataexporter, signozmeter] exporters: [clickhousetraces, signozmeter]
metrics: metrics:
receivers: [otlp] receivers: [otlp]
processors: [batch] processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter] exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus: metrics/prometheus:
receivers: [prometheus] receivers: [prometheus]
processors: [batch] processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter] exporters: [signozclickhousemetrics, signozmeter]
logs: logs:
receivers: [otlp] receivers: [otlp]
processors: [batch] processors: [batch]
exporters: [clickhouselogsexporter, metadataexporter, signozmeter] exporters: [clickhouselogsexporter, signozmeter]
metrics/meter: metrics/meter:
receivers: [signozmeter] receivers: [signozmeter]
processors: [batch/meter] processors: [batch/meter]

View File

@@ -842,17 +842,6 @@ components:
- temporality - temporality
- isMonotonic - isMonotonic
type: object type: object
MetrictypesComparisonSpaceAggregationParam:
properties:
operator:
type: string
threshold:
format: double
type: number
required:
- operator
- threshold
type: object
MetrictypesSpaceAggregation: MetrictypesSpaceAggregation:
enum: enum:
- sum - sum
@@ -1149,8 +1138,6 @@ components:
type: object type: object
Querybuildertypesv5MetricAggregation: Querybuildertypesv5MetricAggregation:
properties: properties:
comparisonSpaceAggregationParam:
$ref: '#/components/schemas/MetrictypesComparisonSpaceAggregationParam'
metricName: metricName:
type: string type: string
reduceTo: reduceTo:

View File

@@ -26,7 +26,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/utils/times" "github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp" "github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/units" "github.com/SigNoz/signoz/pkg/query-service/formatter"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules" baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
@@ -335,7 +335,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
prevState := r.State() prevState := r.State()
valueFormatter := units.FormatterFromUnit(r.Unit()) valueFormatter := formatter.FromUnit(r.Unit())
var res ruletypes.Vector var res ruletypes.Vector
var err error var err error

View File

@@ -117,7 +117,6 @@
"lucide-react": "0.498.0", "lucide-react": "0.498.0",
"mini-css-extract-plugin": "2.4.5", "mini-css-extract-plugin": "2.4.5",
"motion": "12.4.13", "motion": "12.4.13",
"nuqs": "2.8.8",
"overlayscrollbars": "^2.8.1", "overlayscrollbars": "^2.8.1",
"overlayscrollbars-react": "^0.5.6", "overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1", "papaparse": "5.4.1",
@@ -131,7 +130,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-drag-listview": "2.0.0", "react-drag-listview": "2.0.0",
"react-error-boundary": "4.0.11", "react-error-boundary": "4.0.11",
"react-force-graph-2d": "^1.29.1", "react-force-graph": "^1.43.0",
"react-full-screen": "1.1.1", "react-full-screen": "1.1.1",
"react-grid-layout": "^1.3.4", "react-grid-layout": "^1.3.4",
"react-helmet-async": "1.3.0", "react-helmet-async": "1.3.0",
@@ -163,8 +162,7 @@
"webpack": "5.94.0", "webpack": "5.94.0",
"webpack-dev-server": "^5.2.1", "webpack-dev-server": "^5.2.1",
"webpack-retry-chunk-load-plugin": "3.1.1", "webpack-retry-chunk-load-plugin": "3.1.1",
"xstate": "^4.31.0", "xstate": "^4.31.0"
"zustand": "5.0.11"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@@ -1006,18 +1006,6 @@ export interface MetricsexplorertypesUpdateMetricMetadataRequestDTO {
unit: string; unit: string;
} }
export interface MetrictypesComparisonSpaceAggregationParamDTO {
/**
* @type string
*/
operator: string;
/**
* @type number
* @format double
*/
threshold: number;
}
export enum MetrictypesSpaceAggregationDTO { export enum MetrictypesSpaceAggregationDTO {
sum = 'sum', sum = 'sum',
avg = 'avg', avg = 'avg',
@@ -1379,7 +1367,6 @@ export interface Querybuildertypesv5LogAggregationDTO {
} }
export interface Querybuildertypesv5MetricAggregationDTO { export interface Querybuildertypesv5MetricAggregationDTO {
comparisonSpaceAggregationParam?: MetrictypesComparisonSpaceAggregationParamDTO;
/** /**
* @type string * @type string
*/ */

View File

@@ -50,7 +50,6 @@ export interface HostListResponse {
total: number; total: number;
sentAnyHostMetricsData: boolean; sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean; isSendingK8SAgentMetrics: boolean;
endTimeBeforeRetention: boolean;
}; };
} }

View File

@@ -0,0 +1,26 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';
export const getMetricMetadata = async (
metricName: string,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
try {
const encodedMetricName = encodeURIComponent(metricName);
const response = await axios.get(`/metrics/${encodedMetricName}/metadata`, {
signal,
headers,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@@ -248,11 +248,6 @@ declare module 'chart.js' {
} }
} }
const intlNumberFormatter = new Intl.NumberFormat('en-US', {
useGrouping: false,
maximumFractionDigits: 20,
});
/** /**
* Formats a number for display, preserving leading zeros after the decimal point * Formats a number for display, preserving leading zeros after the decimal point
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit. * and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
@@ -275,7 +270,10 @@ export const formatDecimalWithLeadingZeros = (
} }
// Use toLocaleString to get a full decimal representation without scientific notation. // Use toLocaleString to get a full decimal representation without scientific notation.
const numStr = intlNumberFormatter.format(value); const numStr = value.toLocaleString('en-US', {
useGrouping: false,
maximumFractionDigits: 20,
});
const [integerPart, decimalPart = ''] = numStr.split('.'); const [integerPart, decimalPart = ''] = numStr.split('.');

View File

@@ -86,13 +86,8 @@ function LogDetailInner({
const handleClickOutside = (e: MouseEvent): void => { const handleClickOutside = (e: MouseEvent): void => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// Don't close if clicking on drawer content, overlays, or portal elements // Don't close if clicking on explicitly ignored regions
if ( if (target.closest('[data-log-detail-ignore="true"]')) {
target.closest('[data-log-detail-ignore="true"]') ||
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover')
) {
return; return;
} }
@@ -405,11 +400,7 @@ function LogDetailInner({
<div className="log-detail-drawer__content" data-log-detail-ignore="true"> <div className="log-detail-drawer__content" data-log-detail-ignore="true">
<div className="log-detail-drawer__log"> <div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} /> <Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip <Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
title={removeEscapeCharacters(log?.body)}
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} /> <div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip> </Tooltip>
@@ -475,7 +466,6 @@ function LogDetailInner({
title="Show Filters" title="Show Filters"
placement="topLeft" placement="topLeft"
aria-label="Show Filters" aria-label="Show Filters"
mouseLeaveDelay={0}
> >
<Button <Button
className="action-btn" className="action-btn"
@@ -491,7 +481,6 @@ function LogDetailInner({
aria-label={ aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link' selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
} }
mouseLeaveDelay={0}
> >
<Button <Button
className="action-btn" className="action-btn"

View File

@@ -27,11 +27,7 @@ function AddToQueryHOC({
return ( return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className={cx('addToQueryContainer', fontSize)} onClick={handleQueryAdd}> <div className={cx('addToQueryContainer', fontSize)} onClick={handleQueryAdd}>
<Popover <Popover placement="top" content={popOverContent}>
overlayClassName="drawer-popover"
placement="top"
content={popOverContent}
>
{children} {children}
</Popover> </Popover>
</div> </div>

View File

@@ -32,7 +32,6 @@ function CopyClipboardHOC({
<span onClick={onClick} role="presentation" tabIndex={-1}> <span onClick={onClick} role="presentation" tabIndex={-1}>
<Popover <Popover
placement="top" placement="top"
overlayClassName="drawer-popover"
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>} content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
> >
{children} {children}

View File

@@ -21,7 +21,7 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
export const defaultTableStyle: CSSProperties = { export const defaultTableStyle: CSSProperties = {
minWidth: '40rem', minWidth: '40rem',
maxWidth: '90rem', maxWidth: '60rem',
}; };
export const defaultListViewPanelStyle: CSSProperties = { export const defaultListViewPanelStyle: CSSProperties = {

View File

@@ -11,6 +11,7 @@ import {
startCompletion, startCompletion,
} from '@codemirror/autocomplete'; } from '@codemirror/autocomplete';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { copilot } from '@uiw/codemirror-theme-copilot'; import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github'; import { githubLight } from '@uiw/codemirror-theme-github';
@@ -85,7 +86,6 @@ interface QuerySearchProps {
signalSource?: string; signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[]; hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void; onRun?: (query: string) => void;
showFilterSuggestionsWithoutMetric?: boolean;
} }
function QuerySearch({ function QuerySearch({
@@ -96,7 +96,6 @@ function QuerySearch({
onRun, onRun,
signalSource, signalSource,
hardcodedAttributeKeys, hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
}: QuerySearchProps): JSX.Element { }: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]); const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -253,8 +252,7 @@ function QuerySearch({
async (searchText?: string): Promise<void> => { async (searchText?: string): Promise<void> => {
if ( if (
dataSource === DataSource.METRICS && dataSource === DataSource.METRICS &&
!queryData.aggregateAttribute?.key && !queryData.aggregateAttribute?.key
!showFilterSuggestionsWithoutMetric
) { ) {
setKeySuggestions([]); setKeySuggestions([]);
return; return;
@@ -303,7 +301,6 @@ function QuerySearch({
queryData.aggregateAttribute?.key, queryData.aggregateAttribute?.key,
signalSource, signalSource,
hardcodedAttributeKeys, hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
], ],
); );
@@ -567,7 +564,15 @@ function QuerySearch({
const lastPos = lastPosRef.current; const lastPos = lastPosRef.current;
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) { if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
setCursorPos(newPos); setCursorPos((lastPos) => {
if (newPos.ch !== lastPos.ch && newPos.ch === 0) {
Sentry.captureEvent({
message: `Cursor jumped to start of line from ${lastPos.ch} to ${newPos.ch}`,
level: 'warning',
});
}
return newPos;
});
lastPosRef.current = newPos; lastPosRef.current = newPos;
if (doc) { if (doc) {
@@ -1323,10 +1328,7 @@ function QuerySearch({
)} )}
<div className="query-where-clause-editor-container"> <div className="query-where-clause-editor-container">
<Tooltip <Tooltip title={getTooltipContent()} placement="left">
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a <a
href="https://signoz.io/docs/userguide/search-syntax/" href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank" target="_blank"
@@ -1560,7 +1562,6 @@ QuerySearch.defaultProps = {
hardcodedAttributeKeys: undefined, hardcodedAttributeKeys: undefined,
placeholder: placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')", "Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
}; };
export default QuerySearch; export default QuerySearch;

View File

@@ -40,7 +40,6 @@ function ValueGraph({
value, value,
rawValue, rawValue,
thresholds, thresholds,
yAxisUnit,
}: ValueGraphProps): JSX.Element { }: ValueGraphProps): JSX.Element {
const { t } = useTranslation(['valueGraph']); const { t } = useTranslation(['valueGraph']);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -88,7 +87,7 @@ function ValueGraph({
const { const {
threshold, threshold,
isConflictingThresholds, isConflictingThresholds,
} = getBackgroundColorAndThresholdCheck(thresholds, rawValue, yAxisUnit); } = getBackgroundColorAndThresholdCheck(thresholds, rawValue);
return ( return (
<div <div
@@ -156,7 +155,6 @@ interface ValueGraphProps {
value: string; value: string;
rawValue: number; rawValue: number;
thresholds: ThresholdProps[]; thresholds: ThresholdProps[];
yAxisUnit?: string;
} }
export default ValueGraph; export default ValueGraph;

View File

@@ -1,10 +1,9 @@
import { evaluateThresholdWithConvertedValue } from 'container/GridTableComponent/utils'; import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
function doesValueSatisfyThreshold( function compareThreshold(
rawValue: number, rawValue: number,
threshold: ThresholdProps, threshold: ThresholdProps,
yAxisUnit?: string,
): boolean { ): boolean {
if ( if (
threshold.thresholdOperator === undefined || threshold.thresholdOperator === undefined ||
@@ -12,14 +11,31 @@ function doesValueSatisfyThreshold(
) { ) {
return false; return false;
} }
switch (threshold.thresholdOperator) {
case '>':
return rawValue > threshold.thresholdValue;
case '>=':
return rawValue >= threshold.thresholdValue;
case '<':
return rawValue < threshold.thresholdValue;
case '<=':
return rawValue <= threshold.thresholdValue;
case '=':
return rawValue === threshold.thresholdValue;
default:
return false;
}
}
return evaluateThresholdWithConvertedValue( function extractNumbersFromString(inputString: string): number[] {
rawValue, const regex = /[+-]?\d+(\.\d+)?/g;
threshold.thresholdValue, const matches = inputString.match(regex);
threshold.thresholdOperator,
threshold.thresholdUnit, if (matches) {
yAxisUnit, return matches.map(Number);
); }
return [];
} }
function getHighestPrecedenceThreshold( function getHighestPrecedenceThreshold(
@@ -44,32 +60,21 @@ function getHighestPrecedenceThreshold(
return highestPrecedenceThreshold; return highestPrecedenceThreshold;
} }
function extractNumbersFromString(inputString: string): number[] {
const regex = /[+-]?\d+(\.\d+)?/g;
const matches = inputString.match(regex);
if (matches) {
return matches.map(Number);
}
return [];
}
export function getBackgroundColorAndThresholdCheck( export function getBackgroundColorAndThresholdCheck(
thresholds: ThresholdProps[], thresholds: ThresholdProps[],
rawValue: number, rawValue: number,
yAxisUnit?: string,
): { ): {
threshold: ThresholdProps; threshold: ThresholdProps;
isConflictingThresholds: boolean; isConflictingThresholds: boolean;
} { } {
const matchingThresholds = thresholds.filter((threshold) => { const matchingThresholds = thresholds.filter((threshold) =>
const numbers = extractNumbersFromString(rawValue.toString()); compareThreshold(
if (numbers.length === 0) { extractNumbersFromString(
return false; getYAxisFormattedValue(rawValue.toString(), threshold.thresholdUnit || ''),
} )[0],
return doesValueSatisfyThreshold(numbers[0], threshold, yAxisUnit); threshold,
}); ),
);
if (matchingThresholds.length === 0) { if (matchingThresholds.length === 0) {
return { return {

View File

@@ -22,8 +22,6 @@ function YAxisUnitSelector({
'data-testid': dataTestId, 'data-testid': dataTestId,
source, source,
initialValue, initialValue,
categoriesOverride,
containerClassName,
}: YAxisUnitSelectorProps): JSX.Element { }: YAxisUnitSelectorProps): JSX.Element {
const universalUnit = mapMetricUnitToUniversalUnit(value); const universalUnit = mapMetricUnitToUniversalUnit(value);
@@ -68,14 +66,10 @@ function YAxisUnitSelector({
return aliases.some((alias) => alias.toLowerCase().includes(search)); return aliases.some((alias) => alias.toLowerCase().includes(search));
}; };
const categoriesToRender = useMemo(() => { const categories = getYAxisCategories(source);
return categoriesOverride || getYAxisCategories(source);
}, [categoriesOverride, source]);
return ( return (
<div <div className="y-axis-unit-selector-component">
className={classNames('y-axis-unit-selector-component', containerClassName)}
>
<Select <Select
showSearch showSearch
value={universalUnit} value={universalUnit}
@@ -96,7 +90,7 @@ function YAxisUnitSelector({
data-testid={dataTestId} data-testid={dataTestId}
allowClear allowClear
> >
{categoriesToRender.map((category) => ( {categories.map((category) => (
<Select.OptGroup key={category.name} label={category.name}> <Select.OptGroup key={category.name} label={category.name}>
{category.units.map((unit) => ( {category.units.map((unit) => (
<Select.Option key={unit.id} value={unit.id}> <Select.Option key={unit.id} value={unit.id}>

View File

@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { YAxisCategoryNames } from '../constants'; import { YAxisSource } from '../types';
import { UniversalYAxisUnit, YAxisSource } from '../types';
import YAxisUnitSelector from '../YAxisUnitSelector'; import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => { describe('YAxisUnitSelector', () => {
@@ -124,34 +123,4 @@ describe('YAxisUnitSelector', () => {
const warningIcon = screen.queryByLabelText('warning'); const warningIcon = screen.queryByLabelText('warning');
expect(warningIcon).not.toBeInTheDocument(); expect(warningIcon).not.toBeInTheDocument();
}); });
it('uses categories override to render custom units', () => {
const customCategories = [
{
name: YAxisCategoryNames.Data,
units: [
{
id: UniversalYAxisUnit.BYTES,
name: 'Custom Bytes (B)',
},
],
},
];
render(
<YAxisUnitSelector
value=""
onChange={mockOnChange}
source={YAxisSource.ALERTS}
categoriesOverride={customCategories}
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();
});
}); });

View File

@@ -9,8 +9,6 @@ export interface YAxisUnitSelectorProps {
'data-testid'?: string; 'data-testid'?: string;
source: YAxisSource; source: YAxisSource;
initialValue?: string; initialValue?: string;
categoriesOverride?: YAxisCategory[];
containerClassName?: string;
} }
export enum UniversalYAxisUnit { export enum UniversalYAxisUnit {

View File

@@ -117,7 +117,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const [showSlowApiWarning, setShowSlowApiWarning] = useState(false); const [showSlowApiWarning, setShowSlowApiWarning] = useState(false);
const [slowApiWarningShown, setSlowApiWarningShown] = useState(false); const [slowApiWarningShown, setSlowApiWarningShown] = useState(false);
const { currentVersion } = useSelector<AppState, AppReducer>( const { latestVersion } = useSelector<AppState, AppReducer>(
(state) => state.app, (state) => state.app,
); );
@@ -213,9 +213,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, },
{ {
queryFn: (): Promise<SuccessResponse<ChangelogSchema> | ErrorResponse> => queryFn: (): Promise<SuccessResponse<ChangelogSchema> | ErrorResponse> =>
getChangelogByVersion(currentVersion, changelogForTenant), getChangelogByVersion(latestVersion, changelogForTenant),
queryKey: ['getChangelogByVersion', currentVersion, changelogForTenant], queryKey: ['getChangelogByVersion', latestVersion, changelogForTenant],
enabled: isLoggedIn && Boolean(currentVersion), enabled: isLoggedIn && Boolean(latestVersion),
}, },
]); ]);
@@ -226,7 +226,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
!changelog && !changelog &&
!getChangelogByVersionResponse.isLoading && !getChangelogByVersionResponse.isLoading &&
isLoggedIn && isLoggedIn &&
Boolean(currentVersion) Boolean(latestVersion)
) { ) {
getChangelogByVersionResponse.refetch(); getChangelogByVersionResponse.refetch();
} }
@@ -237,9 +237,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
if ( if (
isCloudUserVal && isCloudUserVal &&
Boolean(currentVersion) && Boolean(latestVersion) &&
seenChangelogVersion != null && seenChangelogVersion != null &&
currentVersion !== seenChangelogVersion && latestVersion !== seenChangelogVersion &&
daysSinceAccountCreation > MIN_ACCOUNT_AGE_FOR_CHANGELOG && // Show to only users older than 2 weeks daysSinceAccountCreation > MIN_ACCOUNT_AGE_FOR_CHANGELOG && // Show to only users older than 2 weeks
!isWorkspaceAccessRestricted !isWorkspaceAccessRestricted
) { ) {
@@ -255,7 +255,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
isCloudUserVal, isCloudUserVal,
currentVersion, latestVersion,
seenChangelogVersion, seenChangelogVersion,
toggleChangelogModal, toggleChangelogModal,
isWorkspaceAccessRestricted, isWorkspaceAccessRestricted,

View File

@@ -9,7 +9,6 @@ import {
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl'; import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables'; import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { import {
enqueueDescendantsOfVariable, enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables, enqueueFetchOfAllVariables,
@@ -32,9 +31,6 @@ function DashboardVariableSelection(): JSX.Element | null {
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl(); const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables(); const { dashboardVariables } = useDashboardVariables();
const dashboardId = useDashboardVariablesSelector(
(state) => state.dashboardId,
);
const sortedVariablesArray = useDashboardVariablesSelector( const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray, (state) => state.sortedVariablesArray,
); );
@@ -100,28 +96,6 @@ function DashboardVariableSelection(): JSX.Element | null {
updateUrlVariable(name || id, value); updateUrlVariable(name || id, value);
} }
// Synchronously update the external store with the new variable value so that
// child variables see the updated parent value when they refetch, rather than
// waiting for setSelectedDashboard → useEffect → updateDashboardVariablesStore.
const updatedVariables = { ...dashboardVariables };
if (updatedVariables[id]) {
updatedVariables[id] = {
...updatedVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (updatedVariables[name]) {
updatedVariables[name] = {
...updatedVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
setSelectedDashboard((prev) => { setSelectedDashboard((prev) => {
if (prev) { if (prev) {
const oldVariables = { ...prev?.data.variables }; const oldVariables = { ...prev?.data.variables };
@@ -156,12 +130,10 @@ function DashboardVariableSelection(): JSX.Element | null {
return prev; return prev;
}); });
// Cascade: enqueue query-type descendants for refetching. // Cascade: enqueue query-type descendants for refetching
// Safe to call synchronously now that the store already has the updated value.
enqueueDescendantsOfVariable(name); enqueueDescendantsOfVariable(name);
}, },
[ [
dashboardId,
dashboardVariables, dashboardVariables,
updateLocalStorageDashboardVariables, updateLocalStorageDashboardVariables,
updateUrlVariable, updateUrlVariable,

View File

@@ -5,7 +5,7 @@ import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQ
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState'; import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import sortValues from 'lib/dashboardVariables/sortVariableValues'; import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isEmpty } from 'lodash-es'; import { isArray, isEmpty, isString } from 'lodash-es';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query'; import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
@@ -54,7 +54,7 @@ function QueryVariableInput({
onChange, onChange,
onDropdownVisibleChange, onDropdownVisibleChange,
handleClear, handleClear,
getDefaultValue, applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({ } = useDashboardVariableSelectHelper({
variableData, variableData,
optionsData, optionsData,
@@ -68,93 +68,81 @@ function QueryVariableInput({
try { try {
setErrorMessage(null); setErrorMessage(null);
// This is just a check given the previously undefined typed name prop. Not significant
// This will be changed when we change the schema
// TODO: @AshwinBhatkal Perses
if (!variableData.name) {
return;
}
// if the response is not an array, premature return
if ( if (
!variablesRes?.variableValues || variablesRes?.variableValues &&
!Array.isArray(variablesRes?.variableValues) Array.isArray(variablesRes?.variableValues)
) { ) {
return; const newOptionsData = sortValues(
} variablesRes?.variableValues,
variableData.sort,
const sortedNewOptions = sortValues(
variablesRes.variableValues,
variableData.sort,
);
const sortedOldOptions = sortValues(optionsData, variableData.sort);
// if options are the same as before, no need to update state or check for selected value validity
// ! selectedValue needs to be set in the first pass though, as options are initially empty array and we need to apply default if needed
// Expecatation is that when oldOptions are not empty, then there is always some selectedValue
if (areArraysEqual(sortedNewOptions, sortedOldOptions)) {
return;
}
setOptionsData(sortedNewOptions);
let isSelectedValueMissingInNewOptions = false;
// Check if currently selected value(s) are present in the new options list
if (isArray(variableData.selectedValue)) {
isSelectedValueMissingInNewOptions = variableData.selectedValue.some(
(val) => !sortedNewOptions.includes(val),
); );
} else if (
variableData.selectedValue &&
!sortedNewOptions.includes(variableData.selectedValue)
) {
isSelectedValueMissingInNewOptions = true;
}
// If multi-select with ALL option enabled, and ALL is currently selected, we want to maintain that state and select all new options const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
// This block does not depend on selected value because of ALL and also because we would only come here if options are different from the previous
if (
variableData.multiSelect &&
variableData.showALLOption &&
variableData.allSelected &&
isSelectedValueMissingInNewOptions
) {
onValueUpdate(variableData.name, variableData.id, sortedNewOptions, true);
// Update tempSelection to maintain ALL state when dropdown is open if (!areArraysEqual(newOptionsData, oldOptionsData)) {
if (tempSelection !== undefined) { let valueNotInList = false;
setTempSelection(sortedNewOptions.map((option) => option.toString()));
}
return;
}
const value = variableData.selectedValue; if (isArray(variableData.selectedValue)) {
let allSelected = false; variableData.selectedValue.forEach((val) => {
if (!newOptionsData.includes(val)) {
valueNotInList = true;
}
});
} else if (
isString(variableData.selectedValue) &&
!newOptionsData.includes(variableData.selectedValue)
) {
valueNotInList = true;
}
if (variableData.multiSelect) { if (variableData.name && (valueNotInList || variableData.allSelected)) {
const { selectedValue } = variableData; if (
allSelected = variableData.allSelected &&
sortedNewOptions.length > 0 && variableData.multiSelect &&
Array.isArray(selectedValue) && variableData.showALLOption
sortedNewOptions.every((option) => selectedValue.includes(option)); ) {
} if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(
variableData.name,
variableData.id,
newOptionsData,
true,
);
}
if ( // Update tempSelection to maintain ALL state when dropdown is open
variableData.name && if (tempSelection !== undefined) {
variableData.id && setTempSelection(newOptionsData.map((option) => option.toString()));
!isEmpty(variableData.selectedValue) }
) { } else {
onValueUpdate(variableData.name, variableData.id, value, allSelected); const value = variableData.selectedValue;
} else { let allSelected = false;
const defaultValue = getDefaultValue(sortedNewOptions);
if (defaultValue !== undefined) { if (variableData.multiSelect) {
onValueUpdate( const { selectedValue } = variableData;
variableData.name, allSelected =
variableData.id, newOptionsData.length > 0 &&
defaultValue, Array.isArray(selectedValue) &&
allSelected, newOptionsData.every((option) => selectedValue.includes(option));
); }
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
setOptionsData(newOptionsData);
// Apply default if no value is selected (e.g., new variable, first load)
applyDefaultIfNeeded(newOptionsData);
} }
} }
} catch (e) { } catch (e) {
@@ -167,7 +155,7 @@ function QueryVariableInput({
onValueUpdate, onValueUpdate,
tempSelection, tempSelection,
setTempSelection, setTempSelection,
getDefaultValue, applyDefaultIfNeeded,
], ],
); );

View File

@@ -1,6 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable sonarjs/no-duplicate-string */
import { act, render } from '@testing-library/react'; import { act, render } from '@testing-library/react';
import * as dashboardVariablesStoreModule from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { import {
dashboardVariablesStore, dashboardVariablesStore,
setDashboardVariablesStore, setDashboardVariablesStore,
@@ -11,7 +10,6 @@ import {
IDashboardVariablesStoreState, IDashboardVariablesStoreState,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes'; } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables, enqueueFetchOfAllVariables,
initializeVariableFetchStore, initializeVariableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore'; } from 'providers/Dashboard/store/variableFetchStore';
@@ -19,17 +17,6 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DashboardVariableSelection from '../DashboardVariableSelection'; import DashboardVariableSelection from '../DashboardVariableSelection';
// Mutable container to capture the onValueUpdate callback from VariableItem
const mockVariableItemCallbacks: {
onValueUpdate?: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
} = {};
// Mock providers/Dashboard/Dashboard // Mock providers/Dashboard/Dashboard
const mockSetSelectedDashboard = jest.fn(); const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn(); const mockUpdateLocalStorageDashboardVariables = jest.fn();
@@ -69,14 +56,10 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }), useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
})); }));
// VariableItem mock captures the onValueUpdate prop for use in onValueUpdate tests // Mock VariableItem to avoid rendering complexity
jest.mock('../VariableItem', () => ({ jest.mock('../VariableItem', () => ({
__esModule: true, __esModule: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any default: (): JSX.Element => <div data-testid="variable-item" />,
default: (props: any): JSX.Element => {
mockVariableItemCallbacks.onValueUpdate = props.onValueUpdate;
return <div data-testid="variable-item" />;
},
})); }));
function createVariable( function createVariable(
@@ -217,162 +200,4 @@ describe('DashboardVariableSelection', () => {
expect(initializeVariableFetchStore).not.toHaveBeenCalled(); expect(initializeVariableFetchStore).not.toHaveBeenCalled();
expect(enqueueFetchOfAllVariables).not.toHaveBeenCalled(); expect(enqueueFetchOfAllVariables).not.toHaveBeenCalled();
}); });
describe('onValueUpdate', () => {
let updateStoreSpy: jest.SpyInstance;
beforeEach(() => {
resetStore();
jest.clearAllMocks();
// Real implementation pass-through — we just want to observe calls
updateStoreSpy = jest.spyOn(
dashboardVariablesStoreModule,
'updateDashboardVariablesStore',
);
});
afterEach(() => {
updateStoreSpy.mockRestore();
});
it('updates dashboardVariablesStore synchronously before enqueueDescendantsOfVariable', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
},
});
render(<DashboardVariableSelection />);
const callOrder: string[] = [];
updateStoreSpy.mockImplementation(() => {
callOrder.push('updateDashboardVariablesStore');
});
(enqueueDescendantsOfVariable as jest.Mock).mockImplementation(() => {
callOrder.push('enqueueDescendantsOfVariable');
});
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
expect(callOrder).toEqual([
'updateDashboardVariablesStore',
'enqueueDescendantsOfVariable',
]);
});
it('passes updated variable value to dashboardVariablesStore', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
id: 'env-id',
order: 0,
selectedValue: 'staging',
}),
},
});
render(<DashboardVariableSelection />);
// Clear spy calls that happened during setup/render
updateStoreSpy.mockClear();
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
expect(updateStoreSpy).toHaveBeenCalledWith(
expect.objectContaining({
dashboardId: 'dash-1',
variables: expect.objectContaining({
env: expect.objectContaining({
selectedValue: 'production',
allSelected: false,
}),
}),
}),
);
});
it('calls enqueueDescendantsOfVariable synchronously without a timer', () => {
jest.useFakeTimers();
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
},
});
render(<DashboardVariableSelection />);
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
// Must be called immediately — no timer advancement needed
expect(enqueueDescendantsOfVariable).toHaveBeenCalledWith('env');
jest.useRealTimers();
});
it('propagates allSelected and haveCustomValuesSelected to the store', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
id: 'env-id',
order: 0,
multiSelect: true,
showALLOption: true,
}),
},
});
render(<DashboardVariableSelection />);
updateStoreSpy.mockClear();
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
['production', 'staging'],
true,
false,
);
});
expect(updateStoreSpy).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({
env: expect.objectContaining({
selectedValue: ['production', 'staging'],
allSelected: true,
haveCustomValuesSelected: false,
}),
}),
}),
);
});
});
}); });

View File

@@ -1,275 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, render, waitFor } from '@testing-library/react';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { variableFetchStore } from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import QueryVariableInput from '../QueryVariableInput';
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
}));
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
},
});
}
function Wrapper({
children,
queryClient,
}: {
children: React.ReactNode;
queryClient: QueryClient;
}): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
function createVariable(
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable {
return {
id: 'env-id',
name: 'env',
description: '',
type: 'QUERY',
sort: 'DISABLED',
showALLOption: false,
multiSelect: false,
order: 0,
queryValue: 'SELECT env FROM table',
...overrides,
};
}
/** Put the named variable into 'loading' state so useQuery fires on mount */
function setVariableLoading(name: string): void {
variableFetchStore.update((draft) => {
draft.states[name] = 'loading';
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
});
}
function resetFetchStore(): void {
variableFetchStore.set(() => ({
states: {},
lastUpdated: {},
cycleIds: {},
}));
}
describe('QueryVariableInput - getOptions logic', () => {
const mockOnValueUpdate = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
resetFetchStore();
});
afterEach(() => {
resetFetchStore();
});
it('applies default value (first option) when selectedValue is empty on first load', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging', 'dev'] },
});
const variable = createVariable({ selectedValue: undefined });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
'production', // first option by default
false,
);
});
});
it('keeps existing selectedValue when it is present in new options', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
const variable = createVariable({ selectedValue: 'staging' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
'staging',
false,
);
});
});
it('selects all new options when allSelected=true and value is missing from new options', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
const variable = createVariable({
selectedValue: ['old-env'],
allSelected: true,
multiSelect: true,
showALLOption: true,
});
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
['production', 'staging'],
true,
);
});
});
it('does not call onValueUpdate a second time when options have not changed', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
(dashboardVariablesQuery as jest.Mock).mockImplementation(mockQueryFn);
const variable = createVariable({ selectedValue: 'production' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
// Wait for first fetch and onValueUpdate call
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
});
mockOnValueUpdate.mockClear();
// Trigger a second fetch cycle with the same API response
act(() => {
variableFetchStore.update((draft) => {
draft.states['env'] = 'revalidating';
draft.cycleIds['env'] = (draft.cycleIds['env'] || 0) + 1;
});
});
// Wait for second query to fire
await waitFor(() => {
expect(mockQueryFn).toHaveBeenCalledTimes(2);
});
// Options are unchanged, so onValueUpdate must not fire again
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
it('does not call onValueUpdate when API returns a non-array response', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: null },
});
const variable = createVariable({ selectedValue: 'production' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(dashboardVariablesQuery).toHaveBeenCalled();
});
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
it('does not fire the query when variableData.name is empty', () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production'] },
});
// Variable with no name — useVariableFetchState will be called with ''
// and the query key will have an empty name, leaving it disabled
const variable = createVariable({ name: '' });
// Note: we do NOT put it in 'loading' state since name is empty
// (no variableFetchStore entry for '' means isVariableFetching=false)
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
expect(dashboardVariablesQuery).not.toHaveBeenCalled();
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
});

View File

@@ -46,9 +46,6 @@ interface UseDashboardVariableSelectHelperReturn {
applyDefaultIfNeeded: ( applyDefaultIfNeeded: (
overrideOptions?: (string | number | boolean)[], overrideOptions?: (string | number | boolean)[],
) => void; ) => void;
getDefaultValue: (
overrideOptions?: (string | number | boolean)[],
) => string | string[] | undefined;
} }
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
@@ -251,6 +248,5 @@ export function useDashboardVariableSelectHelper({
defaultValue, defaultValue,
onChange, onChange,
applyDefaultIfNeeded, applyDefaultIfNeeded,
getDefaultValue,
}; };
} }

View File

@@ -49,7 +49,7 @@ function evaluateCondition(
* @param columnUnit - The current unit of the value. * @param columnUnit - The current unit of the value.
* @returns A boolean indicating whether the value meets the threshold condition. * @returns A boolean indicating whether the value meets the threshold condition.
*/ */
export function evaluateThresholdWithConvertedValue( function evaluateThresholdWithConvertedValue(
value: number, value: number,
thresholdValue: number, thresholdValue: number,
thresholdOperator?: string, thresholdOperator?: string,

View File

@@ -99,7 +99,6 @@ function GridValueComponent({
<ValueGraph <ValueGraph
thresholds={thresholds || []} thresholds={thresholds || []}
rawValue={value} rawValue={value}
yAxisUnit={yAxisUnit}
value={ value={
yAxisUnit yAxisUnit
? getYAxisFormattedValue( ? getYAxisFormattedValue(

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { LoadingOutlined } from '@ant-design/icons'; import { LoadingOutlined } from '@ant-design/icons';
import { import {
Skeleton, Skeleton,
@@ -14,93 +14,12 @@ import { InfraMonitoringEvents } from 'constants/events';
import HostsEmptyOrIncorrectMetrics from './HostsEmptyOrIncorrectMetrics'; import HostsEmptyOrIncorrectMetrics from './HostsEmptyOrIncorrectMetrics';
import { import {
EmptyOrLoadingViewProps,
formatDataForTable, formatDataForTable,
getHostsListColumns, getHostsListColumns,
HostRowData, HostRowData,
HostsListTableProps, HostsListTableProps,
} from './utils'; } from './utils';
function EmptyOrLoadingView(
viewState: EmptyOrLoadingViewProps,
): React.ReactNode {
const { isError, errorMessage } = viewState;
if (isError) {
return <Typography>{errorMessage || 'Something went wrong'}</Typography>;
}
if (viewState.showHostsEmptyState) {
return (
<HostsEmptyOrIncorrectMetrics
noData={!viewState.sentAnyHostMetricsData}
incorrectData={viewState.isSendingIncorrectK8SAgentMetrics}
/>
);
}
if (viewState.showEndTimeBeforeRetentionMessage) {
return (
<div className="hosts-empty-state-container">
<div className="hosts-empty-state-container-content">
<img className="eyes-emoji" src="/Images/eyesEmoji.svg" alt="eyes emoji" />
<div className="no-hosts-message">
<Typography.Title level={5} className="no-hosts-message-title">
Queried time range is before earliest host metrics
</Typography.Title>
<Typography.Text className="no-hosts-message-text">
Your requested end time is earlier than the earliest detected time of
host metrics data, please adjust your end time.
</Typography.Text>
</div>
</div>
</div>
);
}
if (viewState.showNoRecordsInSelectedTimeRangeMessage) {
return (
<div className="no-filtered-hosts-message-container">
<div className="no-filtered-hosts-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Title level={5} className="no-filtered-hosts-title">
No host metrics found
</Typography.Title>
<Typography.Text className="no-filtered-hosts-message">
No host metrics in the selected time range and filters. Please adjust your
time range or filters.
</Typography.Text>
</div>
</div>
);
}
if (viewState.showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
}
return null;
}
export default function HostsListTable({ export default function HostsListTable({
isLoading, isLoading,
isFetching, isFetching,
@@ -127,11 +46,6 @@ export default function HostsListTable({
[data], [data],
); );
const endTimeBeforeRetention = useMemo(
() => data?.payload?.data?.endTimeBeforeRetention || false,
[data],
);
const formattedHostMetricsData = useMemo( const formattedHostMetricsData = useMemo(
() => formatDataForTable(hostMetricsData), () => formatDataForTable(hostMetricsData),
[hostMetricsData], [hostMetricsData],
@@ -170,6 +84,12 @@ export default function HostsListTable({
}); });
}; };
const showNoFilteredHostsMessage =
!isFetching &&
!isLoading &&
formattedHostMetricsData.length === 0 &&
filters.items.length > 0;
const showHostsEmptyState = const showHostsEmptyState =
!isFetching && !isFetching &&
!isLoading && !isLoading &&
@@ -177,36 +97,63 @@ export default function HostsListTable({
(!sentAnyHostMetricsData || isSendingIncorrectK8SAgentMetrics) && (!sentAnyHostMetricsData || isSendingIncorrectK8SAgentMetrics) &&
!filters.items.length; !filters.items.length;
const showEndTimeBeforeRetentionMessage =
!isFetching &&
!isLoading &&
formattedHostMetricsData.length === 0 &&
endTimeBeforeRetention &&
!filters.items.length;
const showNoRecordsInSelectedTimeRangeMessage =
!isFetching &&
!isLoading &&
formattedHostMetricsData.length === 0 &&
!showEndTimeBeforeRetentionMessage &&
!showHostsEmptyState;
const showTableLoadingState = const showTableLoadingState =
(isLoading || isFetching) && formattedHostMetricsData.length === 0; (isLoading || isFetching) && formattedHostMetricsData.length === 0;
const emptyOrLoadingView = EmptyOrLoadingView({ if (isError) {
isError, return <Typography>{data?.error || 'Something went wrong'}</Typography>;
errorMessage: data?.error ?? '', }
showHostsEmptyState,
sentAnyHostMetricsData,
isSendingIncorrectK8SAgentMetrics,
showEndTimeBeforeRetentionMessage,
showNoRecordsInSelectedTimeRangeMessage,
showTableLoadingState,
});
if (emptyOrLoadingView) { if (showHostsEmptyState) {
return <>{emptyOrLoadingView}</>; return (
<HostsEmptyOrIncorrectMetrics
noData={!sentAnyHostMetricsData}
incorrectData={isSendingIncorrectK8SAgentMetrics}
/>
);
}
if (showNoFilteredHostsMessage) {
return (
<div className="no-filtered-hosts-message-container">
<div className="no-filtered-hosts-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-hosts-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
);
}
if (showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
} }
return ( return (

View File

@@ -1,16 +1,12 @@
/* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { HostData, HostListResponse } from 'api/infraMonitoring/getHostLists';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import HostsListTable from '../HostsListTable'; import HostsListTable from '../HostsListTable';
import { HostsListTableProps } from '../utils';
const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container'; const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container';
const createMockHost = (): HostData => describe('HostsListTable', () => {
({ const mockHost = {
hostName: 'test-host-1', hostName: 'test-host-1',
active: true, active: true,
cpu: 0.75, cpu: 0.75,
@@ -18,46 +14,20 @@ const createMockHost = (): HostData =>
wait: 0.03, wait: 0.03,
load15: 1.5, load15: 1.5,
os: 'linux', os: 'linux',
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] }, };
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
} as HostData);
const createMockTableData = ( const mockTableData = {
overrides: Partial<HostListResponse['data']> = {},
): SuccessResponse<HostListResponse> => {
const mockHost = createMockHost();
return {
statusCode: 200,
message: 'Success',
error: null,
payload: { payload: {
status: 'success',
data: { data: {
type: 'list', hosts: [mockHost],
records: [mockHost],
groups: null,
total: 1,
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: false,
...overrides,
}, },
}, },
}; };
};
describe('HostsListTable', () => {
const mockHost = createMockHost();
const mockTableData = createMockTableData();
const mockOnHostClick = jest.fn(); const mockOnHostClick = jest.fn();
const mockSetCurrentPage = jest.fn(); const mockSetCurrentPage = jest.fn();
const mockSetOrderBy = jest.fn(); const mockSetOrderBy = jest.fn();
const mockSetPageSize = jest.fn(); const mockSetPageSize = jest.fn();
const mockProps = {
const mockProps: HostsListTableProps = {
isLoading: false, isLoading: false,
isError: false, isError: false,
isFetching: false, isFetching: false,
@@ -73,7 +43,7 @@ describe('HostsListTable', () => {
pageSize: 10, pageSize: 10,
setOrderBy: mockSetOrderBy, setOrderBy: mockSetOrderBy,
setPageSize: mockSetPageSize, setPageSize: mockSetPageSize,
}; } as any;
it('renders loading state if isLoading is true and tableData is empty', () => { it('renders loading state if isLoading is true and tableData is empty', () => {
const { container } = render( const { container } = render(
@@ -81,7 +51,7 @@ describe('HostsListTable', () => {
{...mockProps} {...mockProps}
isLoading isLoading
hostMetricsData={[]} hostMetricsData={[]}
tableData={createMockTableData({ records: [] })} tableData={{ payload: { data: { hosts: [] } } }}
/>, />,
); );
expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy(); expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy();
@@ -93,7 +63,7 @@ describe('HostsListTable', () => {
{...mockProps} {...mockProps}
isFetching isFetching
hostMetricsData={[]} hostMetricsData={[]}
tableData={createMockTableData({ records: [] })} tableData={{ payload: { data: { hosts: [] } } }}
/>, />,
); );
expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy(); expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy();
@@ -104,56 +74,19 @@ describe('HostsListTable', () => {
expect(screen.getByText('Something went wrong')).toBeTruthy(); expect(screen.getByText('Something went wrong')).toBeTruthy();
}); });
it('renders "Something went wrong" fallback when isError is true and error message is empty', () => {
const tableDataWithEmptyError: ErrorResponse = {
statusCode: 500,
payload: null,
error: '',
message: null,
};
render(
<HostsListTable
{...mockProps}
isError
hostMetricsData={[]}
tableData={tableDataWithEmptyError}
/>,
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
it('renders custom error message when isError is true and error message is provided', () => {
const customErrorMessage = 'Failed to fetch host metrics';
const tableDataWithError: ErrorResponse = {
statusCode: 500,
payload: null,
error: customErrorMessage,
message: null,
};
render(
<HostsListTable
{...mockProps}
isError
hostMetricsData={[]}
tableData={tableDataWithError}
/>,
);
expect(screen.getByText(customErrorMessage)).toBeInTheDocument();
});
it('renders empty state if no hosts are found', () => { it('renders empty state if no hosts are found', () => {
const { container } = render( const { container } = render(
<HostsListTable <HostsListTable
{...mockProps} {...mockProps}
hostMetricsData={[]} hostMetricsData={[]}
tableData={createMockTableData({ tableData={{
records: [], payload: {
})} data: { hosts: [] },
},
}}
/>, />,
); );
expect( expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
container.querySelector('.no-filtered-hosts-message-container'),
).toBeTruthy();
}); });
it('renders empty state if sentAnyHostMetricsData is false', () => { it('renders empty state if sentAnyHostMetricsData is false', () => {
@@ -161,114 +94,58 @@ describe('HostsListTable', () => {
<HostsListTable <HostsListTable
{...mockProps} {...mockProps}
hostMetricsData={[]} hostMetricsData={[]}
tableData={createMockTableData({ tableData={{
sentAnyHostMetricsData: false, ...mockTableData,
records: [], payload: {
})} ...mockTableData.payload,
/>, data: {
); ...mockTableData.payload.data,
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy(); sentAnyHostMetricsData: false,
}); hosts: [],
it('renders empty state if isSendingK8SAgentMetrics is true', () => {
const { container } = render(
<HostsListTable
{...mockProps}
hostMetricsData={[]}
tableData={createMockTableData({
isSendingK8SAgentMetrics: true,
records: [],
})}
/>,
);
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
});
it('renders end time before retention message when endTimeBeforeRetention is true', () => {
const { container } = render(
<HostsListTable
{...mockProps}
hostMetricsData={[]}
tableData={createMockTableData({
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: true,
records: [],
})}
/>,
);
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
expect(
screen.getByText(
/Your requested end time is earlier than the earliest detected time of host metrics data, please adjust your end time\./,
),
).toBeInTheDocument();
});
it('renders no records message when noRecordsInSelectedTimeRangeAndFilters is true', () => {
const { container } = render(
<HostsListTable
{...mockProps}
hostMetricsData={[]}
tableData={createMockTableData({
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
records: [],
})}
/>,
);
expect(
container.querySelector('.no-filtered-hosts-message-container'),
).toBeTruthy();
expect(
screen.getByText(/No host metrics in the selected time range and filters/),
).toBeInTheDocument();
});
it('renders no filtered hosts message when filters are present and no hosts are found', () => {
const { container } = render(
<HostsListTable
{...mockProps}
hostMetricsData={[]}
filters={{
items: [
{
id: 'host_name',
key: {
key: 'host_name',
dataType: DataTypes.String,
type: 'tag',
isIndexed: true,
},
op: '=',
value: 'unknown',
}, },
], },
op: 'AND',
}} }}
tableData={createMockTableData({
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
records: [],
})}
/>, />,
); );
expect(container.querySelector('.no-filtered-hosts-message')).toBeTruthy(); expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
expect( });
screen.getByText(
/No host metrics in the selected time range and filters\. Please adjust your time range or filters\./, it('renders empty state if isSendingIncorrectK8SAgentMetrics is true', () => {
), const { container } = render(
).toBeInTheDocument(); <HostsListTable
{...mockProps}
hostMetricsData={[]}
tableData={{
...mockTableData,
payload: {
...mockTableData.payload,
data: {
...mockTableData.payload.data,
isSendingIncorrectK8SAgentMetrics: true,
hosts: [],
},
},
}}
/>,
);
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
}); });
it('renders table data', () => { it('renders table data', () => {
const { container } = render( const { container } = render(
<HostsListTable <HostsListTable
{...mockProps} {...mockProps}
tableData={createMockTableData({ tableData={{
isSendingK8SAgentMetrics: false, ...mockTableData,
sentAnyHostMetricsData: true, payload: {
})} ...mockTableData.payload,
data: {
...mockTableData.payload.data,
isSendingIncorrectK8SAgentMetrics: false,
sentAnyHostMetricsData: true,
},
},
}}
/>, />,
); );
expect(container.querySelector('.hosts-list-table')).toBeTruthy(); expect(container.querySelector('.hosts-list-table')).toBeTruthy();

View File

@@ -107,17 +107,6 @@ export interface HostsListTableProps {
setPageSize: (pageSize: number) => void; setPageSize: (pageSize: number) => void;
} }
export interface EmptyOrLoadingViewProps {
isError: boolean;
errorMessage: string;
showHostsEmptyState: boolean;
sentAnyHostMetricsData: boolean;
isSendingIncorrectK8SAgentMetrics: boolean;
showEndTimeBeforeRetentionMessage: boolean;
showNoRecordsInSelectedTimeRangeMessage: boolean;
showTableLoadingState: boolean;
}
export const getHostListsQuery = (): HostListPayload => ({ export const getHostListsQuery = (): HostListPayload => ({
filters: { filters: {
items: [], items: [],

View File

@@ -27,7 +27,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -103,9 +103,10 @@ function K8sClustersList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,9 +105,10 @@ function K8sDaemonSetsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -106,9 +106,10 @@ function K8sDeploymentsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -101,9 +101,10 @@ function K8sJobsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -10,6 +10,7 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { safeParseJSON } from './commonUtils';
import { INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory } from './constants'; import { INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory } from './constants';
import K8sFiltersSidePanel from './K8sFiltersSidePanel/K8sFiltersSidePanel'; import K8sFiltersSidePanel from './K8sFiltersSidePanel/K8sFiltersSidePanel';
import { IEntityColumn } from './utils'; import { IEntityColumn } from './utils';
@@ -58,9 +59,10 @@ function K8sHeader({
const urlFilters = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS); const urlFilters = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS);
let { filters } = currentQuery.builder.queryData[0]; let { filters } = currentQuery.builder.queryData[0];
if (urlFilters) { if (urlFilters) {
const decoded = decodeURIComponent(urlFilters); const parsed = safeParseJSON<IBuilderQuery['filters']>(urlFilters);
const parsed = JSON.parse(decoded); if (parsed) {
filters = parsed; filters = parsed;
}
} }
return { return {
...currentQuery, ...currentQuery,

View File

@@ -27,7 +27,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -104,9 +104,10 @@ function K8sNamespacesList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -27,7 +27,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -99,9 +99,10 @@ function K8sNodesList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -29,7 +29,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -92,9 +92,10 @@ function K8sPodsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,9 +105,10 @@ function K8sStatefulSetsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,9 +105,10 @@ function K8sVolumesList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -5,6 +5,7 @@
/* eslint-disable prefer-destructuring */ /* eslint-disable prefer-destructuring */
import { useMemo } from 'react'; import { useMemo } from 'react';
import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Table, Tooltip, Typography } from 'antd'; import { Table, Tooltip, Typography } from 'antd';
import { Progress } from 'antd/lib'; import { Progress } from 'antd/lib';
@@ -260,6 +261,19 @@ export const filterDuplicateFilters = (
return uniqueFilters; return uniqueFilters;
}; };
export const safeParseJSON = <T,>(value: string): T | null => {
if (!value) {
return null;
}
try {
return JSON.parse(value) as T;
} catch (e) {
console.error('Error parsing JSON from URL parameter:', e);
// TODO: Should we capture this error in Sentry?
return null;
}
};
export const getOrderByFromParams = ( export const getOrderByFromParams = (
searchParams: URLSearchParams, searchParams: URLSearchParams,
returnNullAsDefault = false, returnNullAsDefault = false,
@@ -271,9 +285,12 @@ export const getOrderByFromParams = (
INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY, INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
); );
if (orderByFromParams) { if (orderByFromParams) {
const decoded = decodeURIComponent(orderByFromParams); const parsed = safeParseJSON<{ columnName: string; order: 'asc' | 'desc' }>(
const parsed = JSON.parse(decoded); orderByFromParams,
return parsed as { columnName: string; order: 'asc' | 'desc' }; );
if (parsed) {
return parsed;
}
} }
if (returnNullAsDefault) { if (returnNullAsDefault) {
return null; return null;
@@ -287,13 +304,7 @@ export const getFiltersFromParams = (
): IBuilderQuery['filters'] | null => { ): IBuilderQuery['filters'] | null => {
const filtersFromParams = searchParams.get(queryKey); const filtersFromParams = searchParams.get(queryKey);
if (filtersFromParams) { if (filtersFromParams) {
try { return safeParseJSON<IBuilderQuery['filters']>(filtersFromParams);
const decoded = decodeURIComponent(filtersFromParams);
const parsed = JSON.parse(decoded);
return parsed as IBuilderQuery['filters'];
} catch (error) {
return null;
}
} }
return null; return null;
}; };

View File

@@ -121,23 +121,9 @@ function BodyTitleRenderer({
return ( return (
<TitleWrapper onClick={handleNodeClick}> <TitleWrapper onClick={handleNodeClick}>
{typeof value !== 'object' && ( {typeof value !== 'object' && (
<span <Dropdown menu={menu} trigger={['click']}>
onClick={(e): void => { <SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
e.stopPropagation(); </Dropdown>
e.preventDefault();
}}
onMouseDown={(e): void => e.preventDefault()}
>
<Dropdown
menu={menu}
trigger={['click']}
dropdownRender={(originNode): React.ReactNode => (
<div data-log-detail-ignore="true">{originNode}</div>
)}
>
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
</Dropdown>
</span>
)} )}
{title.toString()}{' '} {title.toString()}{' '}
{!parentIsArray && typeof value !== 'object' && ( {!parentIsArray && typeof value !== 'object' && (

View File

@@ -13,7 +13,7 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
<span className="field-renderer-container"> <span className="field-renderer-container">
{dataType && newField && logType ? ( {dataType && newField && logType ? (
<> <>
<Tooltip placement="left" title={newField} mouseLeaveDelay={0}> <Tooltip placement="left" title={newField}>
<Typography.Text ellipsis className="label"> <Typography.Text ellipsis className="label">
{newField}{' '} {newField}{' '}
</Typography.Text> </Typography.Text>

View File

@@ -46,7 +46,7 @@ function Overview({
handleChangeSelectedView, handleChangeSelectedView,
}: Props): JSX.Element { }: Props): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true); const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true); const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
const [isAttributesExpanded, setIsAttributesExpanded] = useState<boolean>( const [isAttributesExpanded, setIsAttributesExpanded] = useState<boolean>(
true, true,
); );

View File

@@ -245,7 +245,7 @@ function TableView({
<Typography.Text>{renderedField}</Typography.Text> <Typography.Text>{renderedField}</Typography.Text>
{traceId && ( {traceId && (
<Tooltip title="Inspect in Trace" mouseLeaveDelay={0}> <Tooltip title="Inspect in Trace">
<Button <Button
className="periscope-btn" className="periscope-btn"
onClick={( onClick={(

View File

@@ -1,34 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { getColorsForSeverityLabels, isRedLike } from '../utils';
describe('getColorsForSeverityLabels', () => {
it('should return slate for blank labels', () => {
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300);
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300);
});
it('should return correct colors for known severity variants', () => {
expect(getColorsForSeverityLabels('INFO', 0)).toBe(Color.BG_ROBIN_600);
expect(getColorsForSeverityLabels('ERROR', 0)).toBe(Color.BG_CHERRY_600);
expect(getColorsForSeverityLabels('WARN', 0)).toBe(Color.BG_AMBER_600);
expect(getColorsForSeverityLabels('DEBUG', 0)).toBe(Color.BG_AQUA_600);
expect(getColorsForSeverityLabels('TRACE', 0)).toBe(Color.BG_FOREST_600);
expect(getColorsForSeverityLabels('FATAL', 0)).toBe(Color.BG_SAKURA_600);
});
it('should return non-red colors for unrecognized labels at any index', () => {
for (let i = 0; i < 30; i++) {
const color = getColorsForSeverityLabels('4', i);
expect(isRedLike(color)).toBe(false);
}
});
it('should return non-red colors for numeric severity text', () => {
const numericLabels = ['1', '2', '4', '9', '13', '17', '21'];
numericLabels.forEach((label) => {
const color = getColorsForSeverityLabels(label, 0);
expect(isRedLike(color)).toBe(false);
});
});
});

View File

@@ -1,16 +1,7 @@
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { themeColors } from 'constants/theme';
import { colors } from 'lib/getRandomColor'; import { colors } from 'lib/getRandomColor';
// Function to determine if a color is "red-like" based on its RGB values
export function isRedLike(hex: string): boolean {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return r > 180 && r > g * 1.4 && r > b * 1.4;
}
const SAFE_FALLBACK_COLORS = colors.filter((c) => !isRedLike(c));
const SEVERITY_VARIANT_COLORS: Record<string, string> = { const SEVERITY_VARIANT_COLORS: Record<string, string> = {
TRACE: Color.BG_FOREST_600, TRACE: Color.BG_FOREST_600,
Trace: Color.BG_FOREST_500, Trace: Color.BG_FOREST_500,
@@ -76,13 +67,8 @@ export function getColorsForSeverityLabels(
label: string, label: string,
index: number, index: number,
): string { ): string {
const trimmed = label.trim(); // Check if we have a direct mapping for this severity variant
const variantColor = SEVERITY_VARIANT_COLORS[label.trim()];
if (!trimmed) {
return Color.BG_SLATE_300;
}
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
if (variantColor) { if (variantColor) {
return variantColor; return variantColor;
} }
@@ -117,8 +103,5 @@ export function getColorsForSeverityLabels(
return Color.BG_SAKURA_500; return Color.BG_SAKURA_500;
} }
return ( return colors[index % colors.length] || themeColors.red;
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
Color.BG_SLATE_400
);
} }

View File

@@ -111,19 +111,23 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
); );
const itemContent = useCallback( const itemContent = useCallback(
(index: number, log: Record<string, unknown>): JSX.Element => ( (index: number, log: Record<string, unknown>): JSX.Element => {
<TableRow return (
tableColumns={tableColumns} <div key={log.id as string}>
index={index} <TableRow
log={log} tableColumns={tableColumns}
logs={tableViewProps.logs} index={index}
hasActions log={log}
fontSize={tableViewProps.fontSize} logs={tableViewProps.logs}
onShowLogDetails={onSetActiveLog} hasActions
isActiveLog={activeLog?.id === log.id} fontSize={tableViewProps.fontSize}
onClearActiveLog={onCloseActiveLog} onShowLogDetails={onSetActiveLog}
/> isActiveLog={activeLog?.id === log.id}
), onClearActiveLog={onCloseActiveLog}
/>
</div>
);
},
[ [
tableColumns, tableColumns,
onSetActiveLog, onSetActiveLog,

View File

@@ -87,7 +87,7 @@ function Explorer(): JSX.Element {
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>(); const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const unitsLength = useMemo(() => units.length, [units]); const unitsLength = useMemo(() => units.length, [units]);
const firstUnit = useMemo(() => units[0], [units]); const firstUnit = useMemo(() => units?.[0], [units]);
useEffect(() => { useEffect(() => {
// Set the y axis unit to the first metric unit if // Set the y axis unit to the first metric unit if
@@ -349,7 +349,7 @@ function Explorer(): JSX.Element {
isOneChartPerQuery={showOneChartPerQuery} isOneChartPerQuery={showOneChartPerQuery}
splitedQueries={splitedQueries} splitedQueries={splitedQueries}
/> />
{isMetricDetailsOpen && selectedMetricName && ( {isMetricDetailsOpen && (
<MetricDetails <MetricDetails
metricName={selectedMetricName} metricName={selectedMetricName}
isOpen={isMetricDetailsOpen} isOpen={isMetricDetailsOpen}

View File

@@ -39,7 +39,10 @@ function RelatedMetricsCard({ metric }: RelatedMetricsCardProps): JSX.Element {
dataSource={DataSource.METRICS} dataSource={DataSource.METRICS}
/> />
)} )}
<DashboardsAndAlertsPopover metricName={metric.name} /> <DashboardsAndAlertsPopover
dashboards={metric.dashboards}
alerts={metric.alerts}
/>
</div> </div>
); );
} }

View File

@@ -1,13 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQueries, useQueryClient } from 'react-query'; import { useQueries } from 'react-query';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { toast } from '@signozhq/sonner'; import { Tooltip, Typography } from 'antd';
import { Button, Tooltip, Typography } from 'antd';
import {
invalidateGetMetricMetadata,
useUpdateMetricMetadata,
} from 'api/generated/services/metrics';
import { isAxiosError } from 'axios'; import { isAxiosError } from 'axios';
import classNames from 'classnames'; import classNames from 'classnames';
import YAxisUnitSelector from 'components/YAxisUnitSelector'; import YAxisUnitSelector from 'components/YAxisUnitSelector';
@@ -28,10 +23,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import { TimeSeriesProps } from './types'; import { TimeSeriesProps } from './types';
import { import { splitQueryIntoOneChartPerQuery } from './utils';
buildUpdateMetricYAxisUnitPayload,
splitQueryIntoOneChartPerQuery,
} from './utils';
function TimeSeries({ function TimeSeries({
showOneChartPerQuery, showOneChartPerQuery,
@@ -43,7 +35,6 @@ function TimeSeries({
yAxisUnit, yAxisUnit,
setYAxisUnit, setYAxisUnit,
showYAxisUnitSelector, showYAxisUnitSelector,
metrics,
}: TimeSeriesProps): JSX.Element { }: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder(); const { stagedQuery, currentQuery } = useQueryBuilder();
@@ -51,7 +42,6 @@ function TimeSeries({
AppState, AppState,
GlobalReducer GlobalReducer
>((state) => state.globalTime); >((state) => state.globalTime);
const queryClient = useQueryClient();
const isValidToConvertToMs = useMemo(() => { const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = []; const isValid: boolean[] = [];
@@ -148,51 +138,54 @@ function TimeSeries({
setYAxisUnit(value); setYAxisUnit(value);
}; };
// TODO: Enable once we have resolved all related metrics v2 api issues
// Show the save unit button if // Show the save unit button if
// 1. There is only one metric // 1. There is only one metric
// 2. The metric has no saved unit // 2. The metric has no saved unit
// 3. The user has selected a unit // 3. The user has selected a unit
const showSaveUnitButton = useMemo( // const showSaveUnitButton = useMemo(
() => // () =>
metricUnits.length === 1 && // metricUnits.length === 1 &&
Boolean(metrics[0]) && // Boolean(metrics?.[0]) &&
!metricUnits[0] && // !metricUnits[0] &&
yAxisUnit, // yAxisUnit,
[metricUnits, metrics, yAxisUnit], // [metricUnits, metrics, yAxisUnit],
); // );
const { // const {
mutate: updateMetricMetadata, // mutate: updateMetricMetadata,
isLoading: isUpdatingMetricMetadata, // isLoading: isUpdatingMetricMetadata,
} = useUpdateMetricMetadata(); // } = useUpdateMetricMetadata();
const handleSaveUnit = (): void => { // const handleSaveUnit = (): void => {
if (metrics[0] && yAxisUnit) { // updateMetricMetadata(
updateMetricMetadata( // {
{ // metricName: metricNames[0],
pathParams: { // payload: {
metricName: metricNames[0], // unit: yAxisUnit,
}, // description: metrics[0]?.description ?? '',
data: buildUpdateMetricYAxisUnitPayload( // metricType: metrics[0]?.type as MetricType,
metricNames[0], // temporality: metrics[0]?.temporality,
metrics[0], // },
yAxisUnit, // },
), // {
}, // onSuccess: () => {
{ // notifications.success({
onSuccess: () => { // message: 'Unit saved successfully',
toast.success('Unit saved successfully'); // });
invalidateGetMetricMetadata(queryClient, { // queryClient.invalidateQueries([
metricName: metricNames[0], // REACT_QUERY_KEY.GET_METRIC_DETAILS,
}); // metricNames[0],
}, // ]);
onError: () => { // },
toast.error('Failed to save unit'); // onError: () => {
}, // notifications.error({
}, // message: 'Failed to save unit',
); // });
} // },
}; // },
// );
// };
return ( return (
<> <>
@@ -205,7 +198,8 @@ function TimeSeries({
source={YAxisSource.EXPLORER} source={YAxisSource.EXPLORER}
data-testid="y-axis-unit-selector" data-testid="y-axis-unit-selector"
/> />
{showSaveUnitButton && ( {/* TODO: Enable once we have resolved all related metrics v2 api issues */}
{/* {showSaveUnitButton && (
<div className="save-unit-container"> <div className="save-unit-container">
<Typography.Text> <Typography.Text>
Save the selected unit for this metric? Save the selected unit for this metric?
@@ -219,7 +213,7 @@ function TimeSeries({
<Typography.Paragraph>Yes</Typography.Paragraph> <Typography.Paragraph>Yes</Typography.Paragraph>
</Button> </Button>
</div> </div>
)} )} */}
</> </>
)} )}
</div> </div>

View File

@@ -3,10 +3,8 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom-v5-compat'; import { useSearchParams } from 'react-router-dom-v5-compat';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { import { Temporality } from 'api/metricsExplorer/getMetricDetails';
MetrictypesTemporalityDTO, import { MetricType } from 'api/metricsExplorer/getMetricsList';
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import * as useOptionsMenuHooks from 'container/OptionsMenu'; import * as useOptionsMenuHooks from 'container/OptionsMenu';
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard'; import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
@@ -16,12 +14,12 @@ import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import * as timezoneHooks from 'providers/Timezone'; import * as timezoneHooks from 'providers/Timezone';
import store from 'store'; import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive'; import { LicenseEvent } from 'types/api/licensesV3/getActive';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder'; import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import Explorer from '../Explorer'; import Explorer from '../Explorer';
import * as useGetMetricsHooks from '../utils'; import * as useGetMetricsHooks from '../utils';
import { MOCK_METRIC_METADATA } from './testUtils';
const mockSetSearchParams = jest.fn(); const mockSetSearchParams = jest.fn();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -137,6 +135,14 @@ jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector'; const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector';
const mockMetric: MetricMetadata = {
type: MetricType.SUM,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: Temporality.CUMULATIVE,
isMonotonic: true,
};
function renderExplorer(): void { function renderExplorer(): void {
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -184,7 +190,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA], metrics: [mockMetric, mockMetric],
}); });
renderExplorer(); renderExplorer();
@@ -201,7 +207,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA], metrics: [mockMetric, mockMetric],
}); });
renderExplorer(); renderExplorer();
@@ -214,7 +220,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [MOCK_METRIC_METADATA], metrics: [mockMetric],
}); });
renderExplorer(); renderExplorer();
@@ -231,7 +237,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA], metrics: [mockMetric, mockMetric],
}); });
renderExplorer(); renderExplorer();
@@ -244,7 +250,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA], metrics: [mockMetric, mockMetric],
}); });
renderExplorer(); renderExplorer();
@@ -263,10 +269,10 @@ describe('Explorer', () => {
isError: false, isError: false,
metrics: [ metrics: [
{ {
type: MetrictypesTypeDTO.sum, type: MetricType.SUM,
description: 'metric1 description', description: 'metric1 description',
unit: '', unit: '',
temporality: MetrictypesTemporalityDTO.cumulative, temporality: Temporality.CUMULATIVE,
isMonotonic: true, isMonotonic: true,
}, },
], ],
@@ -283,7 +289,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [MOCK_METRIC_METADATA], metrics: [mockMetric],
}); });
renderExplorer(); renderExplorer();
@@ -318,7 +324,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA], metrics: [mockMetric, mockMetric],
}); });
renderExplorer(); renderExplorer();

View File

@@ -1,19 +1,29 @@
import { UseMutationResult } from 'react-query';
import { render, RenderResult, screen, waitFor } from '@testing-library/react'; import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import * as metricsExplorerHooks from 'api/generated/services/metrics'; import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataResponse } from 'api/metricsExplorer/updateMetricMetadata';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { UseUpdateMetricMetadataProps } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import TimeSeries from '../TimeSeries'; import TimeSeries from '../TimeSeries';
import { TimeSeriesProps } from '../types'; import { TimeSeriesProps } from '../types';
import { MOCK_METRIC_METADATA } from './testUtils';
const mockUpdateMetricMetadata = jest.fn(); type MockUpdateMetricMetadata = UseMutationResult<
const updateMetricMetadataSpy = jest.spyOn( SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
metricsExplorerHooks, Error,
'useUpdateMetricMetadata', UseUpdateMetricMetadataProps
);
type UseUpdateMetricMetadataReturnType = ReturnType<
typeof metricsExplorerHooks.useUpdateMetricMetadata
>; >;
const mockUpdateMetricMetadata = jest.fn();
jest
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
.mockReturnValue(({
mutate: mockUpdateMetricMetadata,
isLoading: false,
} as Partial<MockUpdateMetricMetadata>) as MockUpdateMetricMetadata);
jest.mock('container/TimeSeriesView/TimeSeriesView', () => ({ jest.mock('container/TimeSeriesView/TimeSeriesView', () => ({
__esModule: true, __esModule: true,
@@ -50,6 +60,14 @@ jest.mock('react-redux', () => ({
}), }),
})); }));
const mockMetric: MetricMetadata = {
type: MetricType.SUM,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: Temporality.CUMULATIVE,
isMonotonic: true,
};
const mockSetWarning = jest.fn(); const mockSetWarning = jest.fn();
const mockSetIsMetricDetailsOpen = jest.fn(); const mockSetIsMetricDetailsOpen = jest.fn();
const mockSetYAxisUnit = jest.fn(); const mockSetYAxisUnit = jest.fn();
@@ -78,13 +96,6 @@ function renderTimeSeries(
} }
describe('TimeSeries', () => { describe('TimeSeries', () => {
beforeEach(() => {
updateMetricMetadataSpy.mockReturnValue(({
mutate: mockUpdateMetricMetadata,
isLoading: false,
} as Partial<UseUpdateMetricMetadataReturnType>) as UseUpdateMetricMetadataReturnType);
});
it('should render a warning icon when a metric has no unit among multiple metrics', () => { it('should render a warning icon when a metric has no unit among multiple metrics', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { container } = renderTimeSeries({ const { container } = renderTimeSeries({
@@ -107,7 +118,7 @@ describe('TimeSeries', () => {
const { container } = renderTimeSeries({ const { container } = renderTimeSeries({
metricUnits: ['', 'count'], metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'], metricNames: ['metric1', 'metric2'],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA], metrics: [mockMetric, mockMetric],
yAxisUnit: 'seconds', yAxisUnit: 'seconds',
}); });
@@ -122,17 +133,18 @@ describe('TimeSeries', () => {
); );
}); });
it('shows Save unit button when metric had no unit but one is selected', async () => { // TODO: Unskip this test once the save unit button is implemented
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
it.skip('shows Save unit button when metric had no unit but one is selected', () => {
const { findByText, getByRole } = renderTimeSeries({ const { findByText, getByRole } = renderTimeSeries({
metricUnits: [undefined], metricUnits: [undefined],
metricNames: ['metric1'], metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA], metrics: [mockMetric],
yAxisUnit: 'seconds', yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
}); });
expect( expect(
await findByText('Save the selected unit for this metric?'), findByText('Save the selected unit for this metric?'),
).toBeInTheDocument(); ).toBeInTheDocument();
const yesButton = getByRole('button', { name: 'Yes' }); const yesButton = getByRole('button', { name: 'Yes' });
@@ -140,25 +152,24 @@ describe('TimeSeries', () => {
expect(yesButton).toBeEnabled(); expect(yesButton).toBeEnabled();
}); });
it('clicking on save unit button shoould upated metric metadata', async () => { // TODO: Unskip this test once the save unit button is implemented
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
it.skip('clicking on save unit button shoould upated metric metadata', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderTimeSeries({ const { getByRole } = renderTimeSeries({
metricUnits: [''], metricUnits: [''],
metricNames: ['metric1'], metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA], metrics: [mockMetric],
yAxisUnit: 'seconds', yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
}); });
const yesButton = getByRole('button', { name: /Yes/i }); const yesButton = getByRole('button', { name: /Yes/i });
await user.click(yesButton); user.click(yesButton);
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith( expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
{ {
pathParams: { metricName: 'metric1',
metricName: 'metric1', payload: expect.objectContaining({ unit: 'seconds' }),
},
data: expect.objectContaining({ unit: 'seconds' }),
}, },
expect.objectContaining({ expect.objectContaining({
onSuccess: expect.any(Function), onSuccess: expect.any(Function),

View File

@@ -1,13 +0,0 @@
import {
MetricsexplorertypesMetricMetadataDTO,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
export const MOCK_METRIC_METADATA: MetricsexplorertypesMetricMetadataDTO = {
type: MetrictypesTypeDTO.sum,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
};

View File

@@ -1,8 +1,14 @@
import { UseQueryResult } from 'react-query'; import { UseQueryResult } from 'react-query';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { GetMetricMetadata200 } from 'api/generated/services/sigNoz.schemas'; import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { initialQueriesMap } from 'constants/queryBuilder'; import { initialQueriesMap } from 'constants/queryBuilder';
import * as useGetMultipleMetricsHook from 'hooks/metricsExplorer/useGetMultipleMetrics'; import * as useGetMultipleMetricsHook from 'hooks/metricsExplorer/useGetMultipleMetrics';
import { SuccessResponseV2 } from 'types/api';
import {
MetricMetadata,
MetricMetadataResponse,
} from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { import {
IBuilderFormula, IBuilderFormula,
@@ -16,7 +22,6 @@ import {
splitQueryIntoOneChartPerQuery, splitQueryIntoOneChartPerQuery,
useGetMetrics, useGetMetrics,
} from '../utils'; } from '../utils';
import { MOCK_METRIC_METADATA } from './testUtils';
const MOCK_QUERY_DATA_1: IBuilderQuery = { const MOCK_QUERY_DATA_1: IBuilderQuery = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0], ...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
@@ -86,19 +91,32 @@ describe('splitQueryIntoOneChartPerQuery', () => {
}); });
}); });
const MOCK_METRIC_METADATA: MetricMetadata = {
description: 'Metric 1 description',
unit: 'unit1',
type: MetricType.GAUGE,
temporality: Temporality.DELTA,
isMonotonic: true,
};
describe('useGetMetrics', () => { describe('useGetMetrics', () => {
beforeEach(() => { beforeEach(() => {
jest jest
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics') .spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
.mockReturnValue([ .mockReturnValue([
{ ({
isLoading: false, isLoading: false,
isError: false, isError: false,
data: { data: {
data: MOCK_METRIC_METADATA, httpStatusCode: 200,
status: 'success', data: {
status: 'success',
data: MOCK_METRIC_METADATA,
},
}, },
} as UseQueryResult<GetMetricMetadata200, Error>, } as Partial<
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
]); ]);
}); });
@@ -115,11 +133,12 @@ describe('useGetMetrics', () => {
jest jest
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics') .spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
.mockReturnValue([ .mockReturnValue([
{ ({
isLoading: true, isLoading: true,
isError: false, isError: false,
data: undefined, } as Partial<
} as UseQueryResult<GetMetricMetadata200, Error>, UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
]); ]);
const { result } = renderHook(() => useGetMetrics(['metric1'])); const { result } = renderHook(() => useGetMetrics(['metric1']));
expect(result.current.metrics).toHaveLength(1); expect(result.current.metrics).toHaveLength(1);

View File

@@ -1,9 +1,9 @@
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query'; import { UseQueryResult } from 'react-query';
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics'; import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
import { SuccessResponse, Warning } from 'types/api'; import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
export enum ExplorerTabs { export enum ExplorerTabs {
TIME_SERIES = 'time-series', TIME_SERIES = 'time-series',
@@ -18,7 +18,7 @@ export interface TimeSeriesProps {
isMetricUnitsError: boolean; isMetricUnitsError: boolean;
metricUnits: (string | undefined)[]; metricUnits: (string | undefined)[];
metricNames: string[]; metricNames: string[];
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[]; metrics: (MetricMetadata | undefined)[];
handleOpenMetricDetails: (metricName: string) => void; handleOpenMetricDetails: (metricName: string) => void;
yAxisUnit: string | undefined; yAxisUnit: string | undefined;
setYAxisUnit: (unit: string) => void; setYAxisUnit: (unit: string) => void;

View File

@@ -1,12 +1,9 @@
import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics';
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import { mapMetricUnitToUniversalUnit } from 'components/YAxisUnitSelector/utils'; import { mapMetricUnitToUniversalUnit } from 'components/YAxisUnitSelector/utils';
import { useGetMultipleMetrics } from 'hooks/metricsExplorer/useGetMultipleMetrics'; import { useGetMultipleMetrics } from 'hooks/metricsExplorer/useGetMultipleMetrics';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { determineIsMonotonic } from '../MetricDetails/utils';
/** /**
* Split a query with multiple queryData to multiple distinct queries, each with a single queryData. * Split a query with multiple queryData to multiple distinct queries, each with a single queryData.
* @param query - The query to split * @param query - The query to split
@@ -71,14 +68,16 @@ export function useGetMetrics(
): { ): {
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[]; metrics: (MetricMetadata | undefined)[];
} { } {
const metricsData = useGetMultipleMetrics(metricNames, { const metricsData = useGetMultipleMetrics(metricNames, {
enabled: metricNames.length > 0 && isEnabled, enabled: metricNames.length > 0 && isEnabled,
}); });
return { return {
isLoading: metricsData.some((metric) => metric.isLoading), isLoading: metricsData.some((metric) => metric.isLoading),
metrics: metricsData.map((metric) => metric.data?.data), metrics: metricsData
.map((metric) => metric.data?.data)
.map((data) => data?.data),
isError: metricsData.some((metric) => metric.isError), isError: metricsData.some((metric) => metric.isError),
}; };
} }
@@ -90,24 +89,9 @@ export function useGetMetrics(
* @returns The units of the metrics, can be undefined if the metric has no unit * @returns The units of the metrics, can be undefined if the metric has no unit
*/ */
export function getMetricUnits( export function getMetricUnits(
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[], metrics: (MetricMetadata | undefined)[],
): (string | undefined)[] { ): (string | undefined)[] {
return metrics return metrics
.map((metric) => metric?.unit) .map((metric) => metric?.unit)
.map((unit) => mapMetricUnitToUniversalUnit(unit) || undefined); .map((unit) => mapMetricUnitToUniversalUnit(unit) || undefined);
} }
export function buildUpdateMetricYAxisUnitPayload(
metricName: string,
metric: MetricsexplorertypesMetricMetadataDTO,
yAxisUnit: string,
): UpdateMetricMetadataMutationBody {
return {
metricName,
type: metric.type,
description: metric.description,
unit: yAxisUnit || '',
temporality: metric.temporality,
isMonotonic: determineIsMonotonic(metric?.type, metric?.temporality),
};
}

View File

@@ -2,8 +2,8 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Card, Input, Select, Typography } from 'antd'; import { Card, Input, Select, Typography } from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails'; import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import classNames from 'classnames'; import classNames from 'classnames';
import { initialQueriesMap } from 'constants/queryBuilder'; import { initialQueriesMap } from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters'; import { AggregatorFilter } from 'container/QueryBuilder/filters';
@@ -40,10 +40,8 @@ import {
* returns true if the feature flag is enabled, false otherwise * returns true if the feature flag is enabled, false otherwise
* Show the inspect button in metrics explorer if the feature flag is enabled * Show the inspect button in metrics explorer if the feature flag is enabled
*/ */
export function isInspectEnabled( export function isInspectEnabled(metricType: MetricType | undefined): boolean {
metricType: MetrictypesTypeDTO | undefined, return metricType === MetricType.GAUGE;
): boolean {
return metricType === MetrictypesTypeDTO.gauge;
} }
export function getAllTimestampsOfMetrics( export function getAllTimestampsOfMetrics(

View File

@@ -1,17 +1,8 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use'; import { useCopyToClipboard } from 'react-use';
import { import { Button, Collapse, Input, Menu, Popover, Typography } from 'antd';
Button,
Collapse,
Input,
Menu,
Popover,
Skeleton,
Typography,
} from 'antd';
import { ColumnsType } from 'antd/es/table'; import { ColumnsType } from 'antd/es/table';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { useGetMetricAttributes } from 'api/generated/services/metrics';
import { ResizeTable } from 'components/ResizeTable'; import { ResizeTable } from 'components/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView'; import { DataType } from 'container/LogDetailedView/TableView';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
@@ -21,33 +12,9 @@ import { PANEL_TYPES } from '../../../constants/queryBuilder';
import ROUTES from '../../../constants/routes'; import ROUTES from '../../../constants/routes';
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange'; import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events'; import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
import MetricDetailsErrorState from './MetricDetailsErrorState'; import { AllAttributesProps, AllAttributesValueProps } from './types';
import {
AllAttributesEmptyTextProps,
AllAttributesProps,
AllAttributesValueProps,
} from './types';
import { getMetricDetailsQuery } from './utils'; import { getMetricDetailsQuery } from './utils';
const ALL_ATTRIBUTES_KEY = 'all-attributes';
function AllAttributesEmptyText({
isErrorAttributes,
refetchAttributes,
}: AllAttributesEmptyTextProps): JSX.Element {
if (isErrorAttributes) {
return (
<div className="all-attributes-error-state">
<MetricDetailsErrorState
refetch={refetchAttributes}
errorMessage="Something went wrong while fetching attributes"
/>
</div>
);
}
return <Typography.Text>No attributes found</Typography.Text>;
}
export function AllAttributesValue({ export function AllAttributesValue({
filterKey, filterKey,
filterValue, filterValue,
@@ -143,23 +110,13 @@ export function AllAttributesValue({
function AllAttributes({ function AllAttributes({
metricName, metricName,
attributes,
metricType, metricType,
}: AllAttributesProps): JSX.Element { }: AllAttributesProps): JSX.Element {
const [searchString, setSearchString] = useState(''); const [searchString, setSearchString] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]); const [activeKey, setActiveKey] = useState<string | string[]>(
'all-attributes',
const { );
data: attributesData,
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
refetch: refetchAttributes,
} = useGetMetricAttributes({
metricName,
});
const attributes = useMemo(() => attributesData?.data.attributes ?? [], [
attributesData,
]);
const { handleExplorerTabChange } = useHandleExplorerTabChange(); const { handleExplorerTabChange } = useHandleExplorerTabChange();
@@ -221,7 +178,7 @@ function AllAttributes({
attributes.filter( attributes.filter(
(attribute) => (attribute) =>
attribute.key.toLowerCase().includes(searchString.toLowerCase()) || attribute.key.toLowerCase().includes(searchString.toLowerCase()) ||
attribute.values?.some((value) => attribute.value.some((value) =>
value.toLowerCase().includes(searchString.toLowerCase()), value.toLowerCase().includes(searchString.toLowerCase()),
), ),
), ),
@@ -238,7 +195,7 @@ function AllAttributes({
}, },
value: { value: {
key: attribute.key, key: attribute.key,
value: attribute.values, value: attribute.value,
}, },
})) }))
: [], : [],
@@ -313,7 +270,6 @@ function AllAttributes({
onClick={(e): void => { onClick={(e): void => {
e.stopPropagation(); e.stopPropagation();
}} }}
disabled={isLoadingAttributes || isErrorAttributes}
/> />
</div> </div>
), ),
@@ -321,49 +277,25 @@ function AllAttributes({
children: ( children: (
<ResizeTable <ResizeTable
columns={columns} columns={columns}
loading={isLoadingAttributes}
tableLayout="fixed" tableLayout="fixed"
dataSource={tableData} dataSource={tableData}
pagination={false} pagination={false}
showHeader={false} showHeader={false}
className="metrics-accordion-content all-attributes-content" className="metrics-accordion-content all-attributes-content"
scroll={{ y: 600 }} scroll={{ y: 600 }}
locale={{
emptyText: (
<AllAttributesEmptyText
isErrorAttributes={isErrorAttributes}
refetchAttributes={refetchAttributes}
/>
),
}}
/> />
), ),
}, },
], ],
[ [columns, tableData, searchString],
searchString,
isLoadingAttributes,
isErrorAttributes,
columns,
tableData,
refetchAttributes,
],
); );
if (isLoadingAttributes) {
return (
<div className="all-attributes-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return ( return (
<Collapse <Collapse
bordered bordered
className="metrics-accordion" className="metrics-accordion metrics-metadata-accordion"
activeKey={activeKey} activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys as string[])} onChange={(keys): void => setActiveKey(keys)}
items={items} items={items}
/> />
); );

View File

@@ -2,84 +2,36 @@ import { useMemo } from 'react';
import { generatePath } from 'react-router-dom'; import { generatePath } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Dropdown, Typography } from 'antd'; import { Dropdown, Typography } from 'antd';
import { Skeleton } from 'antd/lib';
import {
useGetMetricAlerts,
useGetMetricDashboards,
} from 'api/generated/services/metrics';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate'; import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery'; import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history'; import history from 'lib/history';
import { Bell, Grid } from 'lucide-react'; import { Bell, Grid } from 'lucide-react';
import { pluralize } from 'utils/pluralize';
import { DashboardsAndAlertsPopoverProps } from './types'; import { DashboardsAndAlertsPopoverProps } from './types';
function DashboardsAndAlertsPopover({ function DashboardsAndAlertsPopover({
metricName, alerts,
dashboards,
}: DashboardsAndAlertsPopoverProps): JSX.Element | null { }: DashboardsAndAlertsPopoverProps): JSX.Element | null {
const { safeNavigate } = useSafeNavigate(); const { safeNavigate } = useSafeNavigate();
const params = useUrlQuery(); const params = useUrlQuery();
const {
data: alertsData,
isLoading: isLoadingAlerts,
isError: isErrorAlerts,
} = useGetMetricAlerts(
{
metricName,
},
{
query: {
enabled: !!metricName,
},
},
);
const {
data: dashboardsData,
isLoading: isLoadingDashboards,
isError: isErrorDashboards,
} = useGetMetricDashboards(
{
metricName,
},
{
query: {
enabled: !!metricName,
},
},
);
const alerts = useMemo(() => {
return alertsData?.data.alerts ?? [];
}, [alertsData]);
const dashboards = useMemo(() => {
const currentDashboards = dashboardsData?.data.dashboards ?? [];
// Remove duplicate dashboards
return currentDashboards.filter(
(dashboard, index, self) =>
index === self.findIndex((t) => t.dashboardId === dashboard.dashboardId),
);
}, [dashboardsData]);
const alertsPopoverContent = useMemo(() => { const alertsPopoverContent = useMemo(() => {
if (alerts && alerts.length > 0) { if (alerts && alerts.length > 0) {
return alerts.map((alert) => ({ return alerts.map((alert) => ({
key: alert.alertId, key: alert.alert_id,
label: ( label: (
<Typography.Link <Typography.Link
key={alert.alertId} key={alert.alert_id}
onClick={(): void => { onClick={(): void => {
params.set(QueryParams.ruleId, alert.alertId); params.set(QueryParams.ruleId, alert.alert_id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}} }}
className="dashboards-popover-content-item" className="dashboards-popover-content-item"
> >
{alert.alertName || alert.alertId} {alert.alert_name || alert.alert_id}
</Typography.Link> </Typography.Link>
), ),
})); }));
@@ -87,44 +39,41 @@ function DashboardsAndAlertsPopover({
return null; return null;
}, [alerts, params]); }, [alerts, params]);
const uniqueDashboards = useMemo(
() =>
dashboards?.filter(
(item, index, self) =>
index === self.findIndex((t) => t.dashboard_id === item.dashboard_id),
),
[dashboards],
);
const dashboardsPopoverContent = useMemo(() => { const dashboardsPopoverContent = useMemo(() => {
if (dashboards && dashboards.length > 0) { if (uniqueDashboards && uniqueDashboards.length > 0) {
return dashboards.map((dashboard) => ({ return uniqueDashboards.map((dashboard) => ({
key: dashboard.dashboardId, key: dashboard.dashboard_id,
label: ( label: (
<Typography.Link <Typography.Link
key={dashboard.dashboardId} key={dashboard.dashboard_id}
onClick={(): void => { onClick={(): void => {
safeNavigate( safeNavigate(
generatePath(ROUTES.DASHBOARD, { generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboardId, dashboardId: dashboard.dashboard_id,
}), }),
); );
}} }}
className="dashboards-popover-content-item" className="dashboards-popover-content-item"
> >
{dashboard.dashboardName || dashboard.dashboardId} {dashboard.dashboard_name || dashboard.dashboard_id}
</Typography.Link> </Typography.Link>
), ),
})); }));
} }
return null; return null;
}, [dashboards, safeNavigate]); }, [uniqueDashboards, safeNavigate]);
if (isLoadingAlerts || isLoadingDashboards) { if (!dashboardsPopoverContent && !alertsPopoverContent) {
return ( return null;
<div className="dashboards-and-alerts-popover-container">
<Skeleton title={false} paragraph={{ rows: 1 }} active />
</div>
);
}
// If there are no dashboards or alerts or both have errors, don't show the popover
const hidePopover =
(!dashboardsPopoverContent && !alertsPopoverContent) ||
(isErrorAlerts && isErrorDashboards);
if (hidePopover) {
return <div className="dashboards-and-alerts-popover-container" />;
} }
return ( return (
@@ -143,7 +92,8 @@ function DashboardsAndAlertsPopover({
> >
<Grid size={12} color={Color.BG_SIENNA_500} /> <Grid size={12} color={Color.BG_SIENNA_500} />
<Typography.Text> <Typography.Text>
{pluralize(dashboards.length, 'dashboard')} {uniqueDashboards?.length} dashboard
{uniqueDashboards?.length === 1 ? '' : 's'}
</Typography.Text> </Typography.Text>
</div> </div>
</Dropdown> </Dropdown>
@@ -162,7 +112,7 @@ function DashboardsAndAlertsPopover({
> >
<Bell size={12} color={Color.BG_SAKURA_500} /> <Bell size={12} color={Color.BG_SAKURA_500} />
<Typography.Text> <Typography.Text>
{pluralize(alerts.length, 'alert rule')} {alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'}
</Typography.Text> </Typography.Text>
</div> </div>
</Dropdown> </Dropdown>

View File

@@ -1,123 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Skeleton, Tooltip, Typography } from 'antd';
import { useGetMetricHighlights } from 'api/generated/services/metrics';
import { InfoIcon } from 'lucide-react';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import { HighlightsProps } from './types';
import {
formatNumberToCompactFormat,
formatTimestampToReadableDate,
} from './utils';
function Highlights({ metricName }: HighlightsProps): JSX.Element {
const {
data: metricHighlightsData,
isLoading: isLoadingMetricHighlights,
isError: isErrorMetricHighlights,
refetch: refetchMetricHighlights,
} = useGetMetricHighlights(
{
metricName,
},
{
query: {
enabled: !!metricName,
},
},
);
const metricHighlights = metricHighlightsData?.data;
const timeSeriesActive = formatNumberToCompactFormat(
metricHighlights?.activeTimeSeries,
);
const timeSeriesTotal = formatNumberToCompactFormat(
metricHighlights?.totalTimeSeries,
);
const lastReceivedText = formatTimestampToReadableDate(
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">
<div
className="metric-highlights-error-state"
data-testid="metric-highlights-error-state"
>
<InfoIcon size={16} color={Color.BG_CHERRY_500} />
<Typography.Text>
Something went wrong while fetching metric highlights
</Typography.Text>
<Button
type="link"
size="large"
onClick={(): void => {
refetchMetricHighlights();
}}
>
Retry ?
</Button>
</div>
</div>
);
}
return (
<div className="metric-details-content-grid">
<div className="labels-row">
<Typography.Text type="secondary" className="metric-details-grid-label">
SAMPLES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
TIME SERIES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
LAST RECEIVED
</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>
</div>
</div>
);
}
export default Highlights;

View File

@@ -1,58 +1,45 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query'; import { useQueryClient } from 'react-query';
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd'; import { Button, Collapse, Input, Select, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table'; import { ColumnsType } from 'antd/es/table';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { import { Temporality } from 'api/metricsExplorer/getMetricDetails';
invalidateGetMetricMetadata, import { MetricType } from 'api/metricsExplorer/getMetricsList';
invalidateListMetrics, import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
useUpdateMetricMetadata,
} from 'api/generated/services/metrics';
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { ResizeTable } from 'components/ResizeTable'; import { ResizeTable } from 'components/ResizeTable';
import YAxisUnitSelector from 'components/YAxisUnitSelector'; import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types'; import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils'; import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer'; import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView'; import { DataType } from 'container/LogDetailedView/TableView';
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import { Edit2, Save, X } from 'lucide-react'; import { Edit2, Save, X } from 'lucide-react';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events'; import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
import MetricTypeRendererV2 from '../Summary/MetricTypeViewRenderer';
import { import {
METRIC_METADATA_KEYS, METRIC_TYPE_LABEL_MAP,
METRIC_METADATA_TEMPORALITY_OPTIONS, METRIC_TYPE_VALUES_MAP,
METRIC_METADATA_TYPE_OPTIONS, } from '../Summary/constants';
METRIC_METADATA_UPDATE_ERROR_MESSAGE, import { MetricTypeRenderer } from '../Summary/utils';
} from './constants'; import { METRIC_METADATA_KEYS } from './constants';
import MetricDetailsErrorState from './MetricDetailsErrorState'; import { MetadataProps } from './types';
import { MetadataProps, MetricMetadataFormState, TableFields } from './types'; import { determineIsMonotonic } from './utils';
import { transformUpdateMetricMetadataRequest } from './utils';
function Metadata({ function Metadata({
metricName, metricName,
metadata, metadata,
isErrorMetricMetadata, refetchMetricDetails,
isLoadingMetricMetadata,
refetchMetricMetadata,
}: MetadataProps): JSX.Element { }: MetadataProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [ const [
metricMetadataState, metricMetadata,
setMetricMetadataState, setMetricMetadata,
] = useState<MetricMetadataFormState>({ ] = useState<UpdateMetricMetadataProps>({
type: MetrictypesTypeDTO.sum, metricType: metadata?.metric_type || MetricType.SUM,
description: '', description: metadata?.description || '',
temporality: MetrictypesTemporalityDTO.unspecified, temporality: metadata?.temporality,
unit: '', unit: metadata?.unit,
isMonotonic: false,
}); });
const { notifications } = useNotifications(); const { notifications } = useNotifications();
const { const {
@@ -64,135 +51,110 @@ function Metadata({
); );
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Initialize state from metadata api data
useEffect(() => {
if (metadata) {
setMetricMetadataState({
type: metadata.type,
description: metadata.description,
temporality: metadata.temporality,
unit: metadata.unit,
isMonotonic: metadata.isMonotonic,
});
}
}, [metadata]);
const tableData = useMemo( const tableData = useMemo(
() => () =>
metadata metadata
? Object.keys(metadata).map((key) => ({ ? Object.keys({
key, ...metadata,
value: { temporality: metadata?.temporality,
value: metadata[key as keyof typeof metadata], })
// Filter out monotonic as user input is not required
.filter((key) => key !== 'monotonic')
.map((key) => ({
key, key,
}, value: {
})) value: metadata[key as keyof typeof metadata],
key,
},
}))
: [], : [],
[metadata], [metadata],
); );
// Render un-editable field value // Render un-editable field value
const renderUneditableField = useCallback( const renderUneditableField = useCallback((key: string, value: string) => {
(key: keyof MetricMetadataFormState, value: string) => { if (key === 'metric_type') {
if (isErrorMetricMetadata) { return <MetricTypeRenderer type={value as MetricType} />;
return <FieldRenderer field="-" />; }
} let fieldValue = value;
if (key === TableFields.TYPE) { if (key === 'unit') {
return <MetricTypeRendererV2 type={value as MetrictypesTypeDTO} />; fieldValue = getUniversalNameFromMetricUnit(value);
} }
if (key === TableFields.IS_MONOTONIC) { return <FieldRenderer field={fieldValue || '-'} />;
return <FieldRenderer field={value ? 'Yes' : 'No'} />; }, []);
}
if (key === TableFields.Temporality) {
const temporality = METRIC_METADATA_TEMPORALITY_OPTIONS.find(
(option) => option.value === value,
);
return <FieldRenderer field={temporality?.label || '-'} />;
}
let fieldValue = value;
if (key === TableFields.UNIT) {
fieldValue = getUniversalNameFromMetricUnit(value);
}
return <FieldRenderer field={fieldValue || '-'} />;
},
[isErrorMetricMetadata],
);
const renderColumnValue = useCallback( const renderColumnValue = useCallback(
(field: { (field: { value: string; key: string }): JSX.Element => {
value: string;
key: keyof MetricMetadataFormState;
}): JSX.Element => {
if (!isEditing) { if (!isEditing) {
return renderUneditableField(field.key, field.value); return renderUneditableField(field.key, field.value);
} }
// Don't allow editing of unit if it's already set // Don't allow editing of unit if it's already set
const metricUnitAlreadySet = const metricUnitAlreadySet = field.key === 'unit' && Boolean(metadata?.unit);
field.key === TableFields.UNIT && Boolean(metadata?.unit);
if (metricUnitAlreadySet) { if (metricUnitAlreadySet) {
return renderUneditableField(field.key, field.value); return renderUneditableField(field.key, field.value);
} }
// Monotonic is not editable if (field.key === 'metric_type') {
if (field.key === TableFields.IS_MONOTONIC) {
return renderUneditableField(field.key, field.value);
}
if (field.key === TableFields.TYPE) {
return ( return (
<Select <Select
data-testid="metric-type-select" data-testid="metric-type-select"
options={METRIC_METADATA_TYPE_OPTIONS} options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
value={metricMetadataState.type} value: key,
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
}))}
value={metricMetadata.metricType}
onChange={(value): void => { onChange={(value): void => {
setMetricMetadataState((prev) => ({ setMetricMetadata((prev) => ({
...prev, ...prev,
type: value, metricType: value as MetricType,
})); }));
}} }}
/> />
); );
} }
if (field.key === TableFields.UNIT) { if (field.key === 'unit') {
return ( return (
<YAxisUnitSelector <YAxisUnitSelector
value={metricMetadataState.unit} value={metricMetadata.unit}
onChange={(value): void => { onChange={(value): void => {
setMetricMetadataState((prev) => ({ ...prev, unit: value })); setMetricMetadata((prev) => ({ ...prev, unit: value }));
}} }}
data-testid="unit-select" data-testid="unit-select"
source={YAxisSource.EXPLORER} source={YAxisSource.EXPLORER}
/> />
); );
} }
if (field.key === TableFields.Temporality) { if (field.key === 'temporality') {
const temporalityValue =
metricMetadataState.temporality === MetrictypesTemporalityDTO.unspecified
? undefined
: metricMetadataState.temporality;
return ( return (
<Select <Select
data-testid="temporality-select" data-testid="temporality-select"
options={METRIC_METADATA_TEMPORALITY_OPTIONS} options={Object.values(Temporality).map((key) => ({
value={temporalityValue} value: key,
label: key,
}))}
value={metricMetadata.temporality}
onChange={(value): void => { onChange={(value): void => {
setMetricMetadataState((prev) => ({ setMetricMetadata((prev) => ({
...prev, ...prev,
temporality: value, temporality: value as Temporality,
})); }));
}} }}
/> />
); );
} }
if (field.key === TableFields.DESCRIPTION) { if (field.key === 'description') {
return ( return (
<Input <Input
data-testid="description-input" data-testid="description-input"
name={field.key} name={field.key}
defaultValue={metricMetadataState.description} defaultValue={
metricMetadata[
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
]
}
onChange={(e): void => { onChange={(e): void => {
setMetricMetadataState((prev) => ({ setMetricMetadata((prev) => ({
...prev, ...prev,
[field.key]: e.target.value, [field.key]: e.target.value,
})); }));
@@ -202,7 +164,7 @@ function Metadata({
} }
return <FieldRenderer field="-" />; return <FieldRenderer field="-" />;
}, },
[isEditing, metadata?.unit, metricMetadataState, renderUneditableField], [isEditing, metadata?.unit, metricMetadata, renderUneditableField],
); );
const columns: ColumnsType<DataType> = useMemo( const columns: ColumnsType<DataType> = useMemo(
@@ -239,61 +201,52 @@ function Metadata({
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
updateMetricMetadata( updateMetricMetadata(
{ {
pathParams: { metricName,
metricName, payload: {
...metricMetadata,
isMonotonic: determineIsMonotonic(
metricMetadata.metricType,
metricMetadata.temporality,
),
}, },
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
}, },
{ {
onSuccess: (): void => { onSuccess: (response): void => {
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, { if (response?.statusCode === 200) {
[MetricsExplorerEventKeys.MetricName]: metricName, logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
[MetricsExplorerEventKeys.Tab]: 'summary', [MetricsExplorerEventKeys.MetricName]: metricName,
[MetricsExplorerEventKeys.Modal]: 'metric-details', [MetricsExplorerEventKeys.Tab]: 'summary',
}); [MetricsExplorerEventKeys.Modal]: 'metric-details',
notifications.success({ });
message: 'Metadata updated successfully', notifications.success({
}); message: 'Metadata updated successfully',
setIsEditing(false); });
invalidateListMetrics(queryClient); refetchMetricDetails();
invalidateGetMetricMetadata(queryClient, { setIsEditing(false);
metricName, queryClient.invalidateQueries(['metricsList']);
}); } else {
notifications.error({
message:
'Failed to update metadata, please try again. If the issue persists, please contact support.',
});
}
}, },
onError: (error): void => { onError: (): void =>
const errorMessage = (error as AxiosError<RenderErrorResponseDTO>).response
?.data.error?.message;
notifications.error({ notifications.error({
message: errorMessage || METRIC_METADATA_UPDATE_ERROR_MESSAGE, message:
}); 'Failed to update metadata, please try again. If the issue persists, please contact support.',
}, }),
}, },
); );
}, [ }, [
updateMetricMetadata, updateMetricMetadata,
metricName, metricName,
metricMetadataState, metricMetadata,
notifications, notifications,
refetchMetricDetails,
queryClient, queryClient,
]); ]);
const cancelEdit = useCallback(
(e: React.MouseEvent<HTMLElement, MouseEvent>): void => {
e.stopPropagation();
if (metadata) {
setMetricMetadataState({
type: metadata.type,
description: metadata.description,
unit: metadata.unit,
temporality: metadata.temporality,
isMonotonic: metadata.isMonotonic,
});
}
setIsEditing(false);
},
[metadata],
);
const actionButton = useMemo(() => { const actionButton = useMemo(() => {
if (isEditing) { if (isEditing) {
return ( return (
@@ -301,7 +254,10 @@ function Metadata({
<Button <Button
className="action-button" className="action-button"
type="text" type="text"
onClick={cancelEdit} onClick={(e): void => {
e.stopPropagation();
setIsEditing(false);
}}
disabled={isUpdatingMetricsMetadata} disabled={isUpdatingMetricsMetadata}
> >
<X size={14} /> <X size={14} />
@@ -322,9 +278,6 @@ function Metadata({
</div> </div>
); );
} }
if (isErrorMetricMetadata) {
return null;
}
return ( return (
<div className="action-menu"> <div className="action-menu">
<Button <Button
@@ -341,13 +294,7 @@ function Metadata({
</Button> </Button>
</div> </div>
); );
}, [ }, [handleSave, isEditing, isUpdatingMetricsMetadata]);
isEditing,
isErrorMetricMetadata,
isUpdatingMetricsMetadata,
cancelEdit,
handleSave,
]);
const items = useMemo( const items = useMemo(
() => [ () => [
@@ -359,14 +306,7 @@ function Metadata({
</div> </div>
), ),
key: 'metric-metadata', key: 'metric-metadata',
children: isErrorMetricMetadata ? ( children: (
<div className="metric-metadata-error-state">
<MetricDetailsErrorState
refetch={refetchMetricMetadata}
errorMessage="Something went wrong while fetching metric metadata"
/>
</div>
) : (
<ResizeTable <ResizeTable
columns={columns} columns={columns}
tableLayout="fixed" tableLayout="fixed"
@@ -378,23 +318,9 @@ function Metadata({
), ),
}, },
], ],
[ [actionButton, columns, tableData],
actionButton,
columns,
isErrorMetricMetadata,
refetchMetricMetadata,
tableData,
],
); );
if (isLoadingMetricMetadata) {
return (
<div className="metrics-metadata-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return ( return (
<Collapse <Collapse
bordered bordered

View File

@@ -38,12 +38,7 @@
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
.metrics-metadata-error {
padding: 16px !important;
}
.metric-details-content-grid { .metric-details-content-grid {
height: 50px;
.labels-row, .labels-row,
.values-row { .values-row {
display: grid; display: grid;
@@ -52,18 +47,6 @@
align-items: center; align-items: center;
} }
.metric-highlights-error-state {
display: flex;
gap: 8px;
height: 100%;
width: 100%;
align-items: center;
.ant-btn {
padding: 2px 4px;
}
}
.labels-row { .labels-row {
margin-bottom: 8px; margin-bottom: 8px;
@@ -89,7 +72,6 @@
.dashboards-and-alerts-popover-container { .dashboards-and-alerts-popover-container {
display: flex; display: flex;
gap: 16px; gap: 16px;
height: 32px;
.dashboards-and-alerts-popover { .dashboards-and-alerts-popover {
border-radius: 20px; border-radius: 20px;
@@ -120,19 +102,7 @@
} }
} }
.metrics-metadata-skeleton-container {
height: 330px;
}
.all-attributes-skeleton-container {
height: 600px;
}
.metrics-accordion { .metrics-accordion {
.all-attributes-error-state {
height: 300px;
}
.ant-table-body { .ant-table-body {
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 2px; width: 2px;
@@ -178,6 +148,7 @@
.all-attributes-search-input { .all-attributes-search-input {
width: 300px; width: 300px;
border: 1px solid var(--bg-slate-300);
} }
} }
@@ -190,7 +161,6 @@
.ant-typography:first-child { .ant-typography:first-child {
font-family: 'Geist Mono'; font-family: 'Geist Mono';
color: var(--bg-robin-400); color: var(--bg-robin-400);
background-color: transparent;
} }
} }
.all-attributes-contribution { .all-attributes-contribution {
@@ -247,10 +217,6 @@
.ant-collapse-content-box { .ant-collapse-content-box {
padding: 0; padding: 0;
.metric-metadata-error-state {
height: 267px;
}
} }
.ant-collapse-header { .ant-collapse-header {
@@ -271,7 +237,6 @@
} }
.metric-metadata-value { .metric-metadata-value {
height: 67px;
background: rgba(22, 25, 34, 0.4); background: rgba(22, 25, 34, 0.4);
overflow-x: scroll; overflow-x: scroll;
.field-renderer-container { .field-renderer-container {
@@ -365,26 +330,18 @@
.metric-details-content { .metric-details-content {
.metrics-accordion { .metrics-accordion {
.metrics-accordion-header { .metrics-accordion-header {
.action-menu { .action-button {
.action-button { .ant-typography {
.ant-typography { color: var(--bg-slate-400);
color: var(--bg-slate-400);
}
} }
} }
} }
.metrics-accordion-content { .metrics-accordion-content {
.metric-metadata-key { .metric-metadata-key {
.field-renderer-container {
.label {
color: var(--bg-slate-300);
}
}
.all-attributes-key { .all-attributes-key {
.ant-typography:last-child { .ant-typography:last-child {
color: var(--bg-vanilla-200); color: var(--bg-slate-400);
background-color: var(--bg-robin-300); background-color: var(--bg-robin-300);
} }
} }
@@ -438,13 +395,3 @@
} }
} }
} }
.metric-details-error-state {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}

View File

@@ -1,8 +1,16 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Typography } from 'antd'; import {
Button,
Divider,
Drawer,
Empty,
Skeleton,
Tooltip,
Typography,
} from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { useGetMetricMetadata } from 'api/generated/services/metrics'; import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, Crosshair, X } from 'lucide-react'; import { Compass, Crosshair, X } from 'lucide-react';
@@ -11,12 +19,16 @@ import ROUTES from '../../../constants/routes';
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange'; import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events'; import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
import { isInspectEnabled } from '../Inspect/utils'; import { isInspectEnabled } from '../Inspect/utils';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import AllAttributes from './AllAttributes'; import AllAttributes from './AllAttributes';
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover'; import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
import Highlights from './Highlights';
import Metadata from './Metadata'; import Metadata from './Metadata';
import { MetricDetailsProps } from './types'; import { MetricDetailsProps } from './types';
import { getMetricDetailsQuery } from './utils'; import {
formatNumberToCompactFormat,
formatTimestampToReadableDate,
getMetricDetailsQuery,
} from './utils';
import './MetricDetails.styles.scss'; import './MetricDetails.styles.scss';
import '../Summary/Summary.styles.scss'; import '../Summary/Summary.styles.scss';
@@ -31,49 +43,55 @@ function MetricDetails({
const { handleExplorerTabChange } = useHandleExplorerTabChange(); const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { const {
data: metricMetadataResponse, data,
isLoading: isLoadingMetricMetadata, isLoading,
isError: isErrorMetricMetadata, isFetching,
refetch: refetchMetricMetadata, error: metricDetailsError,
} = useGetMetricMetadata( refetch: refetchMetricDetails,
{ } = useGetMetricDetails(metricName ?? '', {
metricName, enabled: !!metricName,
}, });
{
query: {
enabled: !!metricName,
},
},
);
const metadata = useMemo(() => { const metric = data?.payload?.data;
if (!metricMetadataResponse) {
const lastReceived = useMemo(() => {
if (!metric) {
return null; return null;
} }
const { return formatTimestampToReadableDate(metric.lastReceived);
type, }, [metric]);
description,
unit,
temporality,
isMonotonic,
} = metricMetadataResponse.data;
return { const showInspectFeature = useMemo(
type, () => isInspectEnabled(metric?.metadata?.metric_type),
description, [metric],
unit, );
temporality,
isMonotonic,
};
}, [metricMetadataResponse]);
const showInspectFeature = useMemo(() => isInspectEnabled(metadata?.type), [ const isMetricDetailsLoading = isLoading || isFetching;
metadata?.type,
]); const timeSeries = useMemo(() => {
if (!metric) {
return null;
}
const timeSeriesActive = formatNumberToCompactFormat(metric.timeSeriesActive);
const timeSeriesTotal = formatNumberToCompactFormat(metric.timeSeriesTotal);
return (
<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>
);
}, [metric]);
const goToMetricsExplorerwithSelectedMetric = useCallback(() => { const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
if (metricName) { if (metricName) {
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type); const compositeQuery = getMetricDetailsQuery(
metricName,
metric?.metadata?.metric_type,
);
handleExplorerTabChange( handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TIME_SERIES,
{ {
@@ -89,7 +107,9 @@ function MetricDetails({
[MetricsExplorerEventKeys.Modal]: 'metric-details', [MetricsExplorerEventKeys.Modal]: 'metric-details',
}); });
} }
}, [metricName, handleExplorerTabChange, metadata?.type]); }, [metricName, handleExplorerTabChange, metric?.metadata?.metric_type]);
const isMetricDetailsError = metricDetailsError || !metric;
useEffect(() => { useEffect(() => {
logEvent(MetricsExplorerEvents.ModalOpened, { logEvent(MetricsExplorerEvents.ModalOpened, {
@@ -97,9 +117,6 @@ function MetricDetails({
}); });
}, []); }, []);
const isActionButtonDisabled =
!metricName || isLoadingMetricMetadata || isErrorMetricMetadata;
return ( return (
<Drawer <Drawer
width="60%" width="60%"
@@ -107,13 +124,13 @@ function MetricDetails({
<div className="metric-details-header"> <div className="metric-details-header">
<div className="metric-details-title"> <div className="metric-details-title">
<Divider type="vertical" /> <Divider type="vertical" />
<Typography.Text>{metricName}</Typography.Text> <Typography.Text>{metric?.name}</Typography.Text>
</div> </div>
<div className="metric-details-header-buttons"> <div className="metric-details-header-buttons">
<Button <Button
onClick={goToMetricsExplorerwithSelectedMetric} onClick={goToMetricsExplorerwithSelectedMetric}
icon={<Compass size={16} />} icon={<Compass size={16} />}
disabled={isActionButtonDisabled} disabled={!metricName}
data-testid="open-in-explorer-button" data-testid="open-in-explorer-button"
> >
Open in Explorer Open in Explorer
@@ -123,11 +140,10 @@ function MetricDetails({
<Button <Button
className="inspect-metrics-button" className="inspect-metrics-button"
aria-label="Inspect Metric" aria-label="Inspect Metric"
disabled={isActionButtonDisabled}
icon={<Crosshair size={18} />} icon={<Crosshair size={18} />}
onClick={(): void => { onClick={(): void => {
if (metricName) { if (metric?.name) {
openInspectModal(metricName); openInspectModal(metric.name);
} }
}} }}
data-testid="inspect-metric-button" data-testid="inspect-metric-button"
@@ -147,18 +163,60 @@ function MetricDetails({
destroyOnClose destroyOnClose
closeIcon={<X size={16} />} closeIcon={<X size={16} />}
> >
<div className="metric-details-content"> {isMetricDetailsLoading && (
<Highlights metricName={metricName} /> <div data-testid="metric-details-skeleton">
<DashboardsAndAlertsPopover metricName={metricName} /> <Skeleton active />
<Metadata </div>
metricName={metricName} )}
metadata={metadata} {isMetricDetailsError && !isMetricDetailsLoading && (
isErrorMetricMetadata={isErrorMetricMetadata} <Empty description="Error fetching metric details" />
isLoadingMetricMetadata={isLoadingMetricMetadata} )}
refetchMetricMetadata={refetchMetricMetadata} {!isMetricDetailsLoading && !isMetricDetailsError && (
/> <div className="metric-details-content">
<AllAttributes metricName={metricName} metricType={metadata?.type} /> <div className="metric-details-content-grid">
</div> <div className="labels-row">
<Typography.Text type="secondary" className="metric-details-grid-label">
SAMPLES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
TIME SERIES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
LAST RECEIVED
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="metric-details-grid-value">
<Tooltip title={metric?.samples.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metric?.samples)}
</Tooltip>
</Typography.Text>
<Typography.Text className="metric-details-grid-value">
<Tooltip title={timeSeries}>{timeSeries}</Tooltip>
</Typography.Text>
<Typography.Text className="metric-details-grid-value">
<Tooltip title={lastReceived}>{lastReceived}</Tooltip>
</Typography.Text>
</div>
</div>
<DashboardsAndAlertsPopover
dashboards={metric.dashboards}
alerts={metric.alerts}
/>
<Metadata
metricName={metric?.name}
metadata={metric.metadata}
refetchMetricDetails={refetchMetricDetails}
/>
{metric.attributes && (
<AllAttributes
metricName={metric?.name}
attributes={metric.attributes}
metricType={metric?.metadata?.metric_type}
/>
)}
</div>
)}
</Drawer> </Drawer>
); );
} }

View File

@@ -1,20 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Typography } from 'antd';
import { InfoIcon } from 'lucide-react';
import { MetricDetailsErrorStateProps } from './types';
function MetricDetailsErrorState({
refetch,
errorMessage,
}: MetricDetailsErrorStateProps): JSX.Element {
return (
<div className="metric-details-error-state">
<InfoIcon size={20} color={Color.BG_CHERRY_500} />
<Typography.Text>{errorMessage}</Typography.Text>
{refetch && <Button onClick={refetch}>Retry</Button>}
</div>
);
}
export default MetricDetailsErrorState;

View File

@@ -1,13 +1,11 @@
import * as reactUseHooks from 'react-use'; import * as reactUseHooks from 'react-use';
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics'; import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange'; import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import { userEvent } from 'tests/test-utils';
import { MetricDetailsAttribute } from '../../../../api/metricsExplorer/getMetricDetails';
import ROUTES from '../../../../constants/routes'; import ROUTES from '../../../../constants/routes';
import AllAttributes, { AllAttributesValue } from '../AllAttributes'; import AllAttributes, { AllAttributesValue } from '../AllAttributes';
import { getMockMetricAttributesData, MOCK_METRIC_NAME } from './testUtlls';
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@@ -22,28 +20,33 @@ jest
handleExplorerTabChange: mockHandleExplorerTabChange, handleExplorerTabChange: mockHandleExplorerTabChange,
}); });
const mockMetricName = 'test-metric';
const mockMetricType = MetricType.GAUGE;
const mockAttributes: MetricDetailsAttribute[] = [
{
key: 'attribute1',
value: ['value1', 'value2'],
valueCount: 2,
},
{
key: 'attribute2',
value: ['value3'],
valueCount: 1,
},
];
const mockUseCopyToClipboard = jest.fn(); const mockUseCopyToClipboard = jest.fn();
jest jest
.spyOn(reactUseHooks, 'useCopyToClipboard') .spyOn(reactUseHooks, 'useCopyToClipboard')
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any); .mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
const useGetMetricAttributesMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricAttributes',
);
describe('AllAttributes', () => { describe('AllAttributes', () => {
beforeEach(() => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData(),
});
});
it('renders attributes section with title', () => { it('renders attributes section with title', () => {
render( render(
<AllAttributes <AllAttributes
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metricType={MetrictypesTypeDTO.gauge} attributes={mockAttributes}
metricType={mockMetricType}
/>, />,
); );
@@ -53,8 +56,9 @@ describe('AllAttributes', () => {
it('renders all attribute keys and values', () => { it('renders all attribute keys and values', () => {
render( render(
<AllAttributes <AllAttributes
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metricType={MetrictypesTypeDTO.gauge} attributes={mockAttributes}
metricType={mockMetricType}
/>, />,
); );
@@ -71,8 +75,9 @@ describe('AllAttributes', () => {
it('renders value counts correctly', () => { it('renders value counts correctly', () => {
render( render(
<AllAttributes <AllAttributes
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metricType={MetrictypesTypeDTO.gauge} attributes={mockAttributes}
metricType={mockMetricType}
/>, />,
); );
@@ -81,44 +86,41 @@ describe('AllAttributes', () => {
}); });
it('handles empty attributes array', () => { it('handles empty attributes array', () => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData({
data: {
attributes: [],
totalKeys: 0,
},
}),
});
render( render(
<AllAttributes <AllAttributes
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metricType={MetrictypesTypeDTO.gauge} attributes={[]}
metricType={mockMetricType}
/>, />,
); );
expect(screen.getByText('All Attributes')).toBeInTheDocument(); expect(screen.getByText('All Attributes')).toBeInTheDocument();
expect(screen.getByText('No attributes found')).toBeInTheDocument(); expect(screen.queryByText('No data')).toBeInTheDocument();
}); });
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => { it('clicking on an attribute key opens the explorer with the attribute filter applied', () => {
render( render(
<AllAttributes <AllAttributes
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metricType={MetrictypesTypeDTO.gauge} attributes={mockAttributes}
metricType={mockMetricType}
/>, />,
); );
await userEvent.click(screen.getByText('attribute1')); fireEvent.click(screen.getByText('attribute1'));
expect(mockHandleExplorerTabChange).toHaveBeenCalled(); expect(mockHandleExplorerTabChange).toHaveBeenCalled();
}); });
it('filters attributes based on search input', async () => { it('filters attributes based on search input', () => {
render( render(
<AllAttributes <AllAttributes
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metricType={MetrictypesTypeDTO.gauge} attributes={mockAttributes}
metricType={mockMetricType}
/>, />,
); );
await userEvent.type(screen.getByPlaceholderText('Search'), 'value1'); fireEvent.change(screen.getByPlaceholderText('Search'), {
target: { value: 'value1' },
});
expect(screen.getByText('attribute1')).toBeInTheDocument(); expect(screen.getByText('attribute1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument(); expect(screen.getByText('value1')).toBeInTheDocument();
@@ -142,7 +144,7 @@ describe('AllAttributesValue', () => {
expect(screen.getByText('value2')).toBeInTheDocument(); expect(screen.getByText('value2')).toBeInTheDocument();
}); });
it('loads more attributes when show more button is clicked', async () => { it('loads more attributes when show more button is clicked', () => {
render( render(
<AllAttributesValue <AllAttributesValue
filterKey="attribute1" filterKey="attribute1"
@@ -153,7 +155,7 @@ describe('AllAttributesValue', () => {
/>, />,
); );
expect(screen.queryByText('value6')).not.toBeInTheDocument(); expect(screen.queryByText('value6')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Show More')); fireEvent.click(screen.getByText('Show More'));
expect(screen.getByText('value6')).toBeInTheDocument(); expect(screen.getByText('value6')).toBeInTheDocument();
}); });
@@ -170,7 +172,7 @@ describe('AllAttributesValue', () => {
expect(screen.queryByText('Show More')).not.toBeInTheDocument(); expect(screen.queryByText('Show More')).not.toBeInTheDocument();
}); });
it('copy button should copy the attribute value to the clipboard', async () => { it('copy button should copy the attribute value to the clipboard', () => {
render( render(
<AllAttributesValue <AllAttributesValue
filterKey="attribute1" filterKey="attribute1"
@@ -181,13 +183,13 @@ describe('AllAttributesValue', () => {
/>, />,
); );
expect(screen.getByText('value1')).toBeInTheDocument(); expect(screen.getByText('value1')).toBeInTheDocument();
await userEvent.click(screen.getByText('value1')); fireEvent.click(screen.getByText('value1'));
expect(screen.getByText('Copy Attribute')).toBeInTheDocument(); expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
await userEvent.click(screen.getByText('Copy Attribute')); fireEvent.click(screen.getByText('Copy Attribute'));
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1'); expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
}); });
it('explorer button should go to metrics explore with the attribute filter applied', async () => { it('explorer button should go to metrics explore with the attribute filter applied', () => {
render( render(
<AllAttributesValue <AllAttributesValue
filterKey="attribute1" filterKey="attribute1"
@@ -198,10 +200,10 @@ describe('AllAttributesValue', () => {
/>, />,
); );
expect(screen.getByText('value1')).toBeInTheDocument(); expect(screen.getByText('value1')).toBeInTheDocument();
await userEvent.click(screen.getByText('value1')); fireEvent.click(screen.getByText('value1'));
expect(screen.getByText('Open in Explorer')).toBeInTheDocument(); expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
await userEvent.click(screen.getByText('Open in Explorer')); fireEvent.click(screen.getByText('Open in Explorer'));
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith( expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
'attribute1', 'attribute1',
'value1', 'value1',

View File

@@ -1,18 +1,26 @@
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import { userEvent } from 'tests/test-utils';
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover'; import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
import {
getMockAlertsData, const mockAlert1 = {
getMockDashboardsData, alert_id: '1',
MOCK_ALERT_1, alert_name: 'Alert 1',
MOCK_ALERT_2, };
MOCK_DASHBOARD_1, const mockAlert2 = {
MOCK_DASHBOARD_2, alert_id: '2',
MOCK_METRIC_NAME, alert_name: 'Alert 2',
} from './testUtlls'; };
const mockDashboard1 = {
dashboard_id: '1',
dashboard_name: 'Dashboard 1',
};
const mockDashboard2 = {
dashboard_id: '2',
dashboard_name: 'Dashboard 2',
};
const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn(); const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({ jest.mock('hooks/useSafeNavigate', () => ({
@@ -20,6 +28,7 @@ jest.mock('hooks/useSafeNavigate', () => ({
safeNavigate: mockSafeNavigate, safeNavigate: mockSafeNavigate,
}), }),
})); }));
const mockSetQuery = jest.fn(); const mockSetQuery = jest.fn();
const mockUrlQuery = { const mockUrlQuery = {
set: mockSetQuery, set: mockSetQuery,
@@ -30,156 +39,125 @@ jest.mock('hooks/useUrlQuery', () => ({
default: jest.fn(() => mockUrlQuery), default: jest.fn(() => mockUrlQuery),
})); }));
const useGetMetricAlertsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricAlerts',
);
const useGetMetricDashboardsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricDashboards',
);
describe('DashboardsAndAlertsPopover', () => { describe('DashboardsAndAlertsPopover', () => {
beforeEach(() => {
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
});
it('renders the popover correctly with multiple dashboards and alerts', () => { it('renders the popover correctly with multiple dashboards and alerts', () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />); render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
expect(screen.getByText(`2 dashboards`)).toBeInTheDocument(); expect(
expect(screen.getByText(`2 alert rules`)).toBeInTheDocument(); screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
expect(
screen.getByText(`${mockAlerts.length} alert rules`),
).toBeInTheDocument();
}); });
it('renders null with no dashboards and alerts', () => { it('renders null with no dashboards and alerts', () => {
useGetMetricAlertsMock.mockReturnValue(
getMockAlertsData({
data: {
alerts: [],
},
}),
);
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [],
},
}),
);
const { container } = render( const { container } = render(
<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />, <DashboardsAndAlertsPopover alerts={[]} dashboards={[]} />,
); );
expect( expect(container).toBeEmptyDOMElement();
container.querySelector('dashboards-and-alerts-popover-container'),
).toBeNull();
}); });
it('renders popover with single dashboard and alert', () => { it('renders popover with single dashboard and alert', () => {
useGetMetricAlertsMock.mockReturnValue( render(
getMockAlertsData({ <DashboardsAndAlertsPopover
data: { alerts={[mockAlert1]}
alerts: [MOCK_ALERT_1], dashboards={[mockDashboard1]}
}, />,
}),
); );
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [MOCK_DASHBOARD_1],
},
}),
);
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText(`1 dashboard`)).toBeInTheDocument(); expect(screen.getByText(`1 dashboard`)).toBeInTheDocument();
expect(screen.getByText(`1 alert rule`)).toBeInTheDocument(); expect(screen.getByText(`1 alert rule`)).toBeInTheDocument();
}); });
it('renders popover with dashboard id if name is not available', async () => { it('renders popover with dashboard id if name is not available', () => {
useGetMetricDashboardsMock.mockReturnValue( render(
getMockDashboardsData({ <DashboardsAndAlertsPopover
data: { alerts={mockAlerts}
dashboards: [{ ...MOCK_DASHBOARD_1, dashboardName: '' }], dashboards={[{ ...mockDashboard1, dashboard_name: undefined } as any]}
}, />,
}),
); );
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />); fireEvent.click(screen.getByText(`1 dashboard`));
expect(screen.getByText(mockDashboard1.dashboard_id)).toBeInTheDocument();
await userEvent.click(screen.getByText(`1 dashboard`));
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardId)).toBeInTheDocument();
}); });
it('renders popover with alert id if name is not available', async () => { it('renders popover with alert id if name is not available', () => {
useGetMetricAlertsMock.mockReturnValue( render(
getMockAlertsData({ <DashboardsAndAlertsPopover
data: { alerts={[{ ...mockAlert1, alert_name: undefined } as any]}
alerts: [{ ...MOCK_ALERT_1, alertName: '' }], dashboards={mockDashboards}
}, />,
}),
); );
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />); fireEvent.click(screen.getByText(`1 alert rule`));
expect(screen.getByText(mockAlert1.alert_id)).toBeInTheDocument();
await userEvent.click(screen.getByText(`1 alert rule`));
expect(screen.getByText(MOCK_ALERT_1.alertId)).toBeInTheDocument();
}); });
it('navigates to the dashboard when the dashboard is clicked', async () => { it('navigates to the dashboard when the dashboard is clicked', () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />); render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
// Click on 2 dashboards button // Click on 2 dashboards button
await userEvent.click(screen.getByText(`2 dashboards`)); fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
// Popover showing list of 2 dashboards should be visible // Popover showing list of 2 dashboards should be visible
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument(); expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument(); expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
// Click on the first dashboard // Click on the first dashboard
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName)); fireEvent.click(screen.getByText(mockDashboard1.dashboard_name));
// Should navigate to the dashboard // Should navigate to the dashboard
expect(mockSafeNavigate).toHaveBeenCalledWith( expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`, `/dashboard/${mockDashboard1.dashboard_id}`,
); );
}); });
it('navigates to the alert when the alert is clicked', async () => { it('navigates to the alert when the alert is clicked', () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />); render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
// Click on 2 alert rules button // Click on 2 alert rules button
await userEvent.click(screen.getByText(`2 alert rules`)); fireEvent.click(screen.getByText(`${mockAlerts.length} alert rules`));
// Popover showing list of 2 alert rules should be visible // Popover showing list of 2 alert rules should be visible
expect(screen.getByText(MOCK_ALERT_1.alertName)).toBeInTheDocument(); expect(screen.getByText(mockAlert1.alert_name)).toBeInTheDocument();
expect(screen.getByText(MOCK_ALERT_2.alertName)).toBeInTheDocument(); expect(screen.getByText(mockAlert2.alert_name)).toBeInTheDocument();
// Click on the first alert rule // Click on the first alert rule
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName)); fireEvent.click(screen.getByText(mockAlert1.alert_name));
// Should navigate to the alert rule // Should navigate to the alert rule
expect(mockSetQuery).toHaveBeenCalledWith( expect(mockSetQuery).toHaveBeenCalledWith(
QueryParams.ruleId, QueryParams.ruleId,
MOCK_ALERT_1.alertId, mockAlert1.alert_id,
); );
}); });
it('renders unique dashboards even when there are duplicates', async () => { it('renders unique dashboards even when there are duplicates', () => {
useGetMetricDashboardsMock.mockReturnValue( render(
getMockDashboardsData({ <DashboardsAndAlertsPopover
data: { alerts={mockAlerts}
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2, MOCK_DASHBOARD_1], dashboards={[...mockDashboards, mockDashboard1]}
}, />,
}),
); );
expect(
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />); fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText('2 dashboards')).toBeInTheDocument(); expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
await userEvent.click(screen.getByText('2 dashboards'));
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument();
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument();
}); });
}); });

View File

@@ -1,67 +0,0 @@
import { render, screen } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import Highlights from '../Highlights';
import { formatTimestampToReadableDate } from '../utils';
import { getMockMetricHighlightsData, MOCK_METRIC_NAME } from './testUtlls';
const useGetMetricHighlightsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricHighlights',
);
describe('Highlights', () => {
beforeEach(() => {
useGetMetricHighlightsMock.mockReturnValue(getMockMetricHighlightsData());
});
it('should render all highlights data correctly', () => {
render(<Highlights metricName={MOCK_METRIC_NAME} />);
const dataPoints = screen.getByTestId('metric-highlights-data-points');
const timeSeriesTotal = screen.getByTestId(
'metric-highlights-time-series-total',
);
const lastReceived = screen.getByTestId('metric-highlights-last-received');
expect(dataPoints.textContent).toBe('1M+');
expect(timeSeriesTotal.textContent).toBe('1M total ⎯ 1M active');
expect(lastReceived.textContent).toBe(
formatTimestampToReadableDate('2026-01-24T00:00:00Z'),
);
});
it('should render error state correctly', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData(
{},
{
isError: true,
},
),
);
render(<Highlights metricName={MOCK_METRIC_NAME} />);
expect(
screen.getByTestId('metric-highlights-error-state'),
).toBeInTheDocument();
});
it('should render loading state when data is loading', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData(
{},
{
isLoading: true,
},
),
);
render(<Highlights metricName={MOCK_METRIC_NAME} />);
expect(
screen.getByTestId('metric-highlights-loading-state'),
).toBeInTheDocument();
});
});

View File

@@ -1,24 +1,16 @@
/* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable sonarjs/no-duplicate-string */
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import {
GetMetricMetadata200,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Temporality } from 'api/metricsExplorer/getMetricDetails'; import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { import {
UniversalYAxisUnit, UniversalYAxisUnit,
YAxisUnitSelectorProps, YAxisUnitSelectorProps,
} from 'components/YAxisUnitSelector/types'; } from 'components/YAxisUnitSelector/types';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useNotificationsHooks from 'hooks/useNotifications'; import * as useNotificationsHooks from 'hooks/useNotifications';
import { userEvent } from 'tests/test-utils';
import { SelectOption } from 'types/common/select'; import { SelectOption } from 'types/common/select';
import Metadata from '../Metadata'; import Metadata from '../Metadata';
import { MetricMetadata } from '../types';
import { transformMetricMetadata } from '../utils';
import { getMockMetricMetadataData, MOCK_METRIC_NAME } from './testUtlls';
// Mock antd select for testing // Mock antd select for testing
jest.mock('antd', () => ({ jest.mock('antd', () => ({
@@ -80,18 +72,13 @@ jest.mock('react-query', () => ({
}), }),
})); }));
const mockUseUpdateMetricMetadataHook = jest.spyOn(
metricsExplorerHooks,
'useUpdateMetricMetadata',
);
type UseUpdateMetricMetadataResult = ReturnType<
typeof metricsExplorerHooks.useUpdateMetricMetadata
>;
const mockUseUpdateMetricMetadata = jest.fn(); const mockUseUpdateMetricMetadata = jest.fn();
jest
const mockMetricMetadata = transformMetricMetadata( .spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
getMockMetricMetadataData().data as GetMetricMetadata200, .mockReturnValue({
) as MetricMetadata; mutate: mockUseUpdateMetricMetadata,
isLoading: false,
} as any);
const mockErrorNotification = jest.fn(); const mockErrorNotification = jest.fn();
const mockSuccessNotification = jest.fn(); const mockSuccessNotification = jest.fn();
@@ -102,50 +89,47 @@ jest.spyOn(useNotificationsHooks, 'useNotifications').mockReturnValue({
}, },
} as any); } as any);
const mockRefetchMetricMetadata = jest.fn(); const mockMetricName = 'test_metric';
const mockMetricMetadata = {
metric_type: MetricType.GAUGE,
description: 'test_description',
unit: 'test_unit',
temporality: Temporality.DELTA,
};
const mockRefetchMetricDetails = jest.fn();
describe('Metadata', () => { describe('Metadata', () => {
beforeEach(() => {
mockUseUpdateMetricMetadataHook.mockReturnValue(({
mutate: mockUseUpdateMetricMetadata,
} as Partial<UseUpdateMetricMetadataResult>) as UseUpdateMetricMetadataResult);
});
it('should render the metadata properly', () => { it('should render the metadata properly', () => {
render( render(
<Metadata <Metadata
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
isErrorMetricMetadata={false} refetchMetricDetails={mockRefetchMetricDetails}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
expect(screen.getByText('Metric Type')).toBeInTheDocument(); expect(screen.getByText('Metric Type')).toBeInTheDocument();
expect(screen.getByText('Gauge')).toBeInTheDocument(); expect(screen.getByText(mockMetricMetadata.metric_type)).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument(); expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.description)).toBeInTheDocument(); expect(screen.getByText(mockMetricMetadata.description)).toBeInTheDocument();
expect(screen.getByText('Unit')).toBeInTheDocument(); expect(screen.getByText('Unit')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.unit)).toBeInTheDocument(); expect(screen.getByText(mockMetricMetadata.unit)).toBeInTheDocument();
expect(screen.getByText('Temporality')).toBeInTheDocument(); expect(screen.getByText('Temporality')).toBeInTheDocument();
expect(screen.getByText('Delta')).toBeInTheDocument(); expect(screen.getByText(mockMetricMetadata.temporality)).toBeInTheDocument();
}); });
it('editing the metadata should show the form inputs', async () => { it('editing the metadata should show the form inputs', () => {
render( render(
<Metadata <Metadata
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
isErrorMetricMetadata={false} refetchMetricDetails={mockRefetchMetricDetails}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
await userEvent.click(editButton); fireEvent.click(editButton);
expect(screen.getByTestId('metric-type-select')).toBeInTheDocument(); expect(screen.getByTestId('metric-type-select')).toBeInTheDocument();
expect(screen.getByTestId('temporality-select')).toBeInTheDocument(); expect(screen.getByTestId('temporality-select')).toBeInTheDocument();
@@ -155,53 +139,57 @@ describe('Metadata', () => {
it('should update the metadata when the form is submitted', async () => { it('should update the metadata when the form is submitted', async () => {
render( render(
<Metadata <Metadata
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metadata={{ metadata={{
...mockMetricMetadata, ...mockMetricMetadata,
unit: '', unit: '',
}} }}
isErrorMetricMetadata={false} refetchMetricDetails={mockRefetchMetricDetails}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
await userEvent.click(editButton); fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input'); const metricDescriptionInput = screen.getByTestId('description-input');
expect(metricDescriptionInput).toBeInTheDocument(); expect(metricDescriptionInput).toBeInTheDocument();
await userEvent.clear(metricDescriptionInput); fireEvent.change(metricDescriptionInput, {
await userEvent.type(metricDescriptionInput, 'Updated description'); target: { value: 'Updated description' },
});
const metricTypeSelect = screen.getByTestId('metric-type-select'); const metricTypeSelect = screen.getByTestId('metric-type-select');
expect(metricTypeSelect).toBeInTheDocument(); expect(metricTypeSelect).toBeInTheDocument();
await userEvent.selectOptions(metricTypeSelect, MetrictypesTypeDTO.sum); fireEvent.change(metricTypeSelect, {
target: { value: MetricType.SUM },
});
const temporalitySelect = screen.getByTestId('temporality-select'); const temporalitySelect = screen.getByTestId('temporality-select');
expect(temporalitySelect).toBeInTheDocument(); expect(temporalitySelect).toBeInTheDocument();
await userEvent.selectOptions(temporalitySelect, Temporality.CUMULATIVE); fireEvent.change(temporalitySelect, {
target: { value: Temporality.CUMULATIVE },
});
const unitSelect = screen.getByTestId('unit-select'); const unitSelect = screen.getByTestId('unit-select');
expect(unitSelect).toBeInTheDocument(); expect(unitSelect).toBeInTheDocument();
await userEvent.selectOptions(unitSelect, 'By'); fireEvent.change(unitSelect, {
target: { value: 'By' },
});
const saveButton = screen.getByText('Save'); const saveButton = screen.getByText('Save');
expect(saveButton).toBeInTheDocument(); expect(saveButton).toBeInTheDocument();
await userEvent.click(saveButton); fireEvent.click(saveButton);
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith( expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ metricName: mockMetricName,
type: MetrictypesTypeDTO.sum, payload: expect.objectContaining({
temporality: MetrictypesTemporalityDTO.cumulative, description: 'Updated description',
metricType: MetricType.SUM,
temporality: Temporality.CUMULATIVE,
unit: 'By', unit: 'By',
isMonotonic: true, isMonotonic: true,
}), }),
pathParams: {
metricName: MOCK_METRIC_NAME,
},
}), }),
expect.objectContaining({ expect.objectContaining({
onSuccess: expect.any(Function), onSuccess: expect.any(Function),
@@ -213,56 +201,56 @@ describe('Metadata', () => {
it('should show success notification when metadata is updated successfully', async () => { it('should show success notification when metadata is updated successfully', async () => {
render( render(
<Metadata <Metadata
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
isErrorMetricMetadata={false} refetchMetricDetails={mockRefetchMetricDetails}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
await userEvent.click(editButton); fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input'); const metricDescriptionInput = screen.getByTestId('description-input');
await userEvent.clear(metricDescriptionInput); fireEvent.change(metricDescriptionInput, {
await userEvent.type(metricDescriptionInput, 'Updated description'); target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save'); const saveButton = screen.getByText('Save');
await userEvent.click(saveButton); fireEvent.click(saveButton);
const onSuccessCallback = const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess; mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ status: 200 }); onSuccessCallback({ statusCode: 200 });
expect(mockSuccessNotification).toHaveBeenCalledWith({ expect(mockSuccessNotification).toHaveBeenCalledWith({
message: 'Metadata updated successfully', message: 'Metadata updated successfully',
}); });
expect(mockRefetchMetricDetails).toHaveBeenCalled();
}); });
it('should show error notification when metadata update fails with non-200 response', async () => { it('should show error notification when metadata update fails with non-200 response', async () => {
render( render(
<Metadata <Metadata
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
isErrorMetricMetadata={false} refetchMetricDetails={mockRefetchMetricDetails}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
await userEvent.click(editButton); fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input'); const metricDescriptionInput = screen.getByTestId('description-input');
await userEvent.clear(metricDescriptionInput); fireEvent.change(metricDescriptionInput, {
await userEvent.type(metricDescriptionInput, 'Updated description'); target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save'); const saveButton = screen.getByText('Save');
await userEvent.click(saveButton); fireEvent.click(saveButton);
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError; const onSuccessCallback =
onErrorCallback({ status: 500 }); mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ statusCode: 500 });
expect(mockErrorNotification).toHaveBeenCalledWith({ expect(mockErrorNotification).toHaveBeenCalledWith({
message: message:
@@ -273,23 +261,22 @@ describe('Metadata', () => {
it('should show error notification when metadata update fails', async () => { it('should show error notification when metadata update fails', async () => {
render( render(
<Metadata <Metadata
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
isErrorMetricMetadata={false} refetchMetricDetails={mockRefetchMetricDetails}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
await userEvent.click(editButton); fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input'); const metricDescriptionInput = screen.getByTestId('description-input');
await userEvent.clear(metricDescriptionInput); fireEvent.change(metricDescriptionInput, {
await userEvent.type(metricDescriptionInput, 'Updated description'); target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save'); const saveButton = screen.getByText('Save');
await userEvent.click(saveButton); fireEvent.click(saveButton);
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError; const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
@@ -302,43 +289,39 @@ describe('Metadata', () => {
}); });
}); });
it('cancel button should cancel the edit mode', async () => { it('cancel button should cancel the edit mode', () => {
render( render(
<Metadata <Metadata
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
isErrorMetricMetadata={false} refetchMetricDetails={mockRefetchMetricDetails}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
await userEvent.click(editButton); fireEvent.click(editButton);
const cancelButton = screen.getByText('Cancel'); const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toBeInTheDocument(); expect(cancelButton).toBeInTheDocument();
await userEvent.click(cancelButton); fireEvent.click(cancelButton);
const editButton2 = screen.getByText('Edit'); const editButton2 = screen.getByText('Edit');
expect(editButton2).toBeInTheDocument(); expect(editButton2).toBeInTheDocument();
}); });
it('should not allow editing of unit if it is already set', async () => { it('should not allow editing of unit if it is already set', () => {
render( render(
<Metadata <Metadata
metricName={MOCK_METRIC_NAME} metricName={mockMetricName}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
isErrorMetricMetadata={false} refetchMetricDetails={mockRefetchMetricDetails}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
await userEvent.click(editButton); fireEvent.click(editButton);
const unitSelect = screen.queryByTestId('unit-select'); const unitSelect = screen.queryByTestId('unit-select');
expect(unitSelect).not.toBeInTheDocument(); expect(unitSelect).not.toBeInTheDocument();

View File

@@ -1,16 +1,68 @@
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics'; import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange'; import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import { userEvent } from 'tests/test-utils';
import MetricDetails from '../MetricDetails'; import MetricDetails from '../MetricDetails';
import { getMockMetricMetadataData } from './testUtlls';
const mockMetricName = 'test-metric'; const mockMetricName = 'test-metric';
const mockMetricDescription = 'description for a test metric';
const mockMetricData: MetricDetailsType = {
name: mockMetricName,
description: mockMetricDescription,
unit: 'count',
attributes: [
{
key: 'test-attribute',
value: ['test-value'],
valueCount: 1,
},
],
alerts: [],
dashboards: [],
metadata: {
metric_type: MetricType.SUM,
description: mockMetricDescription,
unit: 'count',
},
type: '',
timeseries: 0,
samples: 0,
timeSeriesTotal: 0,
timeSeriesActive: 0,
lastReceived: '',
};
const mockOpenInspectModal = jest.fn(); const mockOpenInspectModal = jest.fn();
const mockOnClose = jest.fn(); const mockOnClose = jest.fn();
const mockUseGetMetricDetailsData = {
data: {
payload: {
data: mockMetricData,
},
},
isLoading: false,
isFetching: false,
isError: false,
error: null,
refetch: jest.fn(),
};
jest
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
.mockReturnValue(mockUseGetMetricDetailsData as any);
jest.spyOn(useUpdateMetricMetadata, 'useUpdateMetricMetadata').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
isError: false,
error: null,
} as any);
const mockHandleExplorerTabChange = jest.fn(); const mockHandleExplorerTabChange = jest.fn();
jest jest
.spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange') .spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange')
@@ -36,50 +88,7 @@ jest.mock('react-query', () => ({
}), }),
})); }));
jest.mock(
'container/MetricsExplorer/MetricDetails/AllAttributes',
() =>
function MockAllAttributes(): JSX.Element {
return <div data-testid="all-attributes">All Attributes</div>;
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover',
() =>
function MockDashboardsAndAlertsPopover(): JSX.Element {
return (
<div data-testid="dashboards-and-alerts-popover">
Dashboards and Alerts Popover
</div>
);
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/Highlights',
() =>
function MockHighlights(): JSX.Element {
return <div data-testid="highlights">Highlights</div>;
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/Metadata',
() =>
function MockMetadata(): JSX.Element {
return <div data-testid="metadata">Metadata</div>;
},
);
const useGetMetricMetadataMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricMetadata',
);
describe('MetricDetails', () => { describe('MetricDetails', () => {
beforeEach(() => {
useGetMetricMetadataMock.mockReturnValue(getMockMetricMetadataData());
});
it('renders metric details correctly', () => { it('renders metric details correctly', () => {
render( render(
<MetricDetails <MetricDetails
@@ -92,15 +101,27 @@ describe('MetricDetails', () => {
); );
expect(screen.getByText(mockMetricName)).toBeInTheDocument(); expect(screen.getByText(mockMetricName)).toBeInTheDocument();
expect(screen.getByTestId('all-attributes')).toBeInTheDocument(); expect(screen.getByText(mockMetricDescription)).toBeInTheDocument();
expect( expect(
screen.getByTestId('dashboards-and-alerts-popover'), screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByTestId('highlights')).toBeInTheDocument();
expect(screen.getByTestId('metadata')).toBeInTheDocument();
}); });
it('renders the "open in explorer" and "inspect" buttons', async () => { it('renders the "open in explorer" and "inspect" buttons', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValueOnce({
...mockUseGetMetricDetailsData,
data: {
payload: {
data: {
...mockMetricData,
metadata: {
...mockMetricData.metadata,
metric_type: MetricType.GAUGE,
},
},
},
},
} as any);
render( render(
<MetricDetails <MetricDetails
onClose={mockOnClose} onClose={mockOnClose}
@@ -114,10 +135,93 @@ describe('MetricDetails', () => {
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument(); expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
expect(screen.getByTestId('inspect-metric-button')).toBeInTheDocument(); expect(screen.getByTestId('inspect-metric-button')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('open-in-explorer-button')); fireEvent.click(screen.getByTestId('open-in-explorer-button'));
expect(mockHandleExplorerTabChange).toHaveBeenCalled(); expect(mockHandleExplorerTabChange).toHaveBeenCalled();
await userEvent.click(screen.getByTestId('inspect-metric-button')); fireEvent.click(screen.getByTestId('inspect-metric-button'));
expect(mockOpenInspectModal).toHaveBeenCalled(); expect(mockOpenInspectModal).toHaveBeenCalled();
}); });
it('should render error state when metric details are not found', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
isError: true,
error: {
message: 'Error fetching metric details',
},
} as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByText('Error fetching metric details')).toBeInTheDocument();
});
it('should render loading state when metric details are loading', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
isLoading: true,
} as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByTestId('metric-details-skeleton')).toBeInTheDocument();
});
it('should render all attributes section', () => {
jest
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
.mockReturnValue(mockUseGetMetricDetailsData as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByText('All Attributes')).toBeInTheDocument();
});
it('should not render all attributes section when relevant data is not present', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
data: {
payload: {
data: {
...mockMetricData,
attributes: null,
},
},
},
} as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.queryByText('All Attributes')).not.toBeInTheDocument();
});
}); });

View File

@@ -1,168 +0,0 @@
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import {
GetMetricAlerts200,
GetMetricAttributes200,
GetMetricDashboards200,
GetMetricHighlights200,
GetMetricMetadata200,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
export const MOCK_METRIC_NAME = 'test-metric';
export function getMockMetricHighlightsData(
overrides?: Partial<GetMetricHighlights200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights> {
return {
data: {
data: {
dataPoints: 1000000,
lastReceived: '2026-01-24T00:00:00Z',
totalTimeSeries: 1000000,
activeTimeSeries: 1000000,
},
status: 'success',
...overrides,
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights>;
}
export const MOCK_DASHBOARD_1 = {
dashboardName: 'Dashboard 1',
dashboardId: '1',
widgetId: '1',
widgetName: 'Widget 1',
};
export const MOCK_DASHBOARD_2 = {
dashboardName: 'Dashboard 2',
dashboardId: '2',
widgetId: '2',
widgetName: 'Widget 2',
};
export const MOCK_ALERT_1 = {
alertName: 'Alert 1',
alertId: '1',
};
export const MOCK_ALERT_2 = {
alertName: 'Alert 2',
alertId: '2',
};
export function getMockDashboardsData(
overrides?: Partial<GetMetricDashboards200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards> {
return {
data: {
data: {
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2],
},
status: 'success',
...overrides,
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards>;
}
export function getMockAlertsData(
overrides?: Partial<GetMetricAlerts200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts> {
return {
data: {
data: {
alerts: [MOCK_ALERT_1, MOCK_ALERT_2],
},
status: 'success',
...overrides,
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts>;
}
export function getMockMetricAttributesData(
overrides?: Partial<GetMetricAttributes200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes> {
return {
data: {
data: {
attributes: [
{
key: 'attribute1',
values: ['value1', 'value2'],
valueCount: 2,
},
{
key: 'attribute2',
values: ['value3'],
valueCount: 1,
},
],
totalKeys: 2,
},
status: 'success',
...overrides,
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes>;
}
export function getMockMetricMetadataData(
overrides?: Partial<GetMetricMetadata200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata> {
return {
data: {
data: {
description: 'test_description',
type: MetrictypesTypeDTO.gauge,
unit: 'test_unit',
temporality: MetrictypesTemporalityDTO.delta,
isMonotonic: false,
},
status: 'success',
...overrides,
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata>;
}

View File

@@ -1,7 +1,5 @@
import { import { Temporality } from 'api/metricsExplorer/getMetricDetails';
MetrictypesTemporalityDTO, import { MetricType } from 'api/metricsExplorer/getMetricsList';
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { import {
determineIsMonotonic, determineIsMonotonic,
@@ -12,48 +10,35 @@ import {
describe('MetricDetails utils', () => { describe('MetricDetails utils', () => {
describe('determineIsMonotonic', () => { describe('determineIsMonotonic', () => {
it('should return true for histogram metrics', () => { it('should return true for histogram metrics', () => {
expect(determineIsMonotonic(MetrictypesTypeDTO.histogram)).toBe(true); expect(determineIsMonotonic(MetricType.HISTOGRAM)).toBe(true);
}); });
it('should return true for exponential histogram metrics', () => { it('should return true for exponential histogram metrics', () => {
expect(determineIsMonotonic(MetrictypesTypeDTO.exponentialhistogram)).toBe( expect(determineIsMonotonic(MetricType.EXPONENTIAL_HISTOGRAM)).toBe(true);
});
it('should return false for gauge metrics', () => {
expect(determineIsMonotonic(MetricType.GAUGE)).toBe(false);
});
it('should return false for summary metrics', () => {
expect(determineIsMonotonic(MetricType.SUMMARY)).toBe(false);
});
it('should return true for sum metrics with cumulative temporality', () => {
expect(determineIsMonotonic(MetricType.SUM, Temporality.CUMULATIVE)).toBe(
true, true,
); );
}); });
it('should return false for gauge metrics', () => {
expect(determineIsMonotonic(MetrictypesTypeDTO.gauge)).toBe(false);
});
it('should return false for summary metrics', () => {
expect(determineIsMonotonic(MetrictypesTypeDTO.summary)).toBe(false);
});
it('should return true for sum metrics with cumulative temporality', () => {
expect(
determineIsMonotonic(
MetrictypesTypeDTO.sum,
MetrictypesTemporalityDTO.cumulative,
),
).toBe(true);
});
it('should return false for sum metrics with delta temporality', () => { it('should return false for sum metrics with delta temporality', () => {
expect( expect(determineIsMonotonic(MetricType.SUM, Temporality.DELTA)).toBe(false);
determineIsMonotonic(
MetrictypesTypeDTO.sum,
MetrictypesTemporalityDTO.delta,
),
).toBe(false);
}); });
it('should return false by default', () => { it('should return false by default', () => {
expect( expect(determineIsMonotonic('' as MetricType, '' as Temporality)).toBe(
determineIsMonotonic( false,
'' as MetrictypesTypeDTO, );
'' as MetrictypesTemporalityDTO,
),
).toBe(false);
}); });
}); });
@@ -130,16 +115,13 @@ describe('MetricDetails utils', () => {
const API_GATEWAY = 'api-gateway'; const API_GATEWAY = 'api-gateway';
it('should create correct query for SUM metric type', () => { it('should create correct query for SUM metric type', () => {
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
TEST_METRIC_NAME, TEST_METRIC_NAME,
); );
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.sum, MetricType.SUM,
); );
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate'); expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate'); expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
@@ -147,16 +129,13 @@ describe('MetricDetails utils', () => {
}); });
it('should create correct query for GAUGE metric type', () => { it('should create correct query for GAUGE metric type', () => {
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.GAUGE);
TEST_METRIC_NAME,
MetrictypesTypeDTO.gauge,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
TEST_METRIC_NAME, TEST_METRIC_NAME,
); );
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.gauge, MetricType.GAUGE,
); );
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg'); expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg'); expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
@@ -164,16 +143,13 @@ describe('MetricDetails utils', () => {
}); });
it('should create correct query for SUMMARY metric type', () => { it('should create correct query for SUMMARY metric type', () => {
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUMMARY);
TEST_METRIC_NAME,
MetrictypesTypeDTO.summary,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
TEST_METRIC_NAME, TEST_METRIC_NAME,
); );
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.summary, MetricType.SUMMARY,
); );
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop'); expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop'); expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
@@ -181,16 +157,13 @@ describe('MetricDetails utils', () => {
}); });
it('should create correct query for HISTOGRAM metric type', () => { it('should create correct query for HISTOGRAM metric type', () => {
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.HISTOGRAM);
TEST_METRIC_NAME,
MetrictypesTypeDTO.histogram,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
TEST_METRIC_NAME, TEST_METRIC_NAME,
); );
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.histogram, MetricType.HISTOGRAM,
); );
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop'); expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop'); expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
@@ -200,14 +173,14 @@ describe('MetricDetails utils', () => {
it('should create correct query for EXPONENTIAL_HISTOGRAM metric type', () => { it('should create correct query for EXPONENTIAL_HISTOGRAM metric type', () => {
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(
TEST_METRIC_NAME, TEST_METRIC_NAME,
MetrictypesTypeDTO.exponentialhistogram, MetricType.EXPONENTIAL_HISTOGRAM,
); );
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
TEST_METRIC_NAME, TEST_METRIC_NAME,
); );
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe( expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.exponentialhistogram, MetricType.EXPONENTIAL_HISTOGRAM,
); );
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop'); expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop'); expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
@@ -230,7 +203,7 @@ describe('MetricDetails utils', () => {
const filter = { key: 'service', value: API_GATEWAY }; const filter = { key: 'service', value: API_GATEWAY };
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(
TEST_METRIC_NAME, TEST_METRIC_NAME,
MetrictypesTypeDTO.sum, MetricType.SUM,
filter, filter,
); );
@@ -248,7 +221,7 @@ describe('MetricDetails utils', () => {
const groupBy = 'service'; const groupBy = 'service';
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(
TEST_METRIC_NAME, TEST_METRIC_NAME,
MetrictypesTypeDTO.sum, MetricType.SUM,
undefined, undefined,
groupBy, groupBy,
); );
@@ -263,7 +236,7 @@ describe('MetricDetails utils', () => {
const groupBy = 'endpoint'; const groupBy = 'endpoint';
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(
TEST_METRIC_NAME, TEST_METRIC_NAME,
MetrictypesTypeDTO.sum, MetricType.SUM,
filter, filter,
groupBy, groupBy,
); );
@@ -277,10 +250,7 @@ describe('MetricDetails utils', () => {
}); });
it('should not include filters or groupBy when not provided', () => { it('should not include filters or groupBy when not provided', () => {
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
);
expect(query.builder.queryData[0]?.filters?.items).toHaveLength(0); expect(query.builder.queryData[0]?.filters?.items).toHaveLength(0);
expect(query.builder.queryData[0]?.groupBy).toHaveLength(0); expect(query.builder.queryData[0]?.groupBy).toHaveLength(0);

View File

@@ -1,55 +1,6 @@
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
export const METRIC_METADATA_KEYS = { export const METRIC_METADATA_KEYS = {
description: 'Description', description: 'Description',
unit: 'Unit', unit: 'Unit',
type: 'Metric Type', metric_type: 'Metric Type',
temporality: 'Temporality', temporality: 'Temporality',
isMonotonic: 'Monotonic',
}; };
export const METRIC_METADATA_TEMPORALITY_OPTIONS: Array<{
value: MetrictypesTemporalityDTO;
label: string;
}> = [
{
value: MetrictypesTemporalityDTO.delta,
label: 'Delta',
},
{
value: MetrictypesTemporalityDTO.cumulative,
label: 'Cumulative',
},
];
export const METRIC_METADATA_TYPE_OPTIONS: Array<{
value: MetrictypesTypeDTO;
label: string;
}> = [
{
value: MetrictypesTypeDTO.sum,
label: 'Sum',
},
{
value: MetrictypesTypeDTO.gauge,
label: 'Gauge',
},
{
value: MetrictypesTypeDTO.histogram,
label: 'Histogram',
},
{
value: MetrictypesTypeDTO.summary,
label: 'Summary',
},
{
value: MetrictypesTypeDTO.exponentialhistogram,
label: 'Exponential Histogram',
},
];
export const METRIC_METADATA_UPDATE_ERROR_MESSAGE =
'Failed to update metadata, please try again. If the issue persists, please contact support.';

View File

@@ -1,39 +1,34 @@
import { import {
MetricsexplorertypesMetricAlertDTO, MetricDetails,
MetricsexplorertypesMetricAttributeDTO, MetricDetailsAlert,
MetricsexplorertypesMetricDashboardDTO, MetricDetailsAttribute,
MetricsexplorertypesMetricHighlightsResponseDTO, MetricDetailsDashboard,
MetricsexplorertypesMetricMetadataDTO, } from 'api/metricsExplorer/getMetricDetails';
MetrictypesTemporalityDTO, import { MetricType } from 'api/metricsExplorer/getMetricsList';
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
export interface MetricDetailsProps { export interface MetricDetailsProps {
onClose: () => void; onClose: () => void;
isOpen: boolean; isOpen: boolean;
metricName: string; metricName: string | null;
isModalTimeSelection: boolean; isModalTimeSelection: boolean;
openInspectModal?: (metricName: string) => void; openInspectModal?: (metricName: string) => void;
} }
export interface HighlightsProps {
metricName: string;
}
export interface DashboardsAndAlertsPopoverProps { export interface DashboardsAndAlertsPopoverProps {
metricName: string; dashboards: MetricDetailsDashboard[] | null;
alerts: MetricDetailsAlert[] | null;
} }
export interface MetadataProps { export interface MetadataProps {
metricName: string; metricName: string;
metadata: MetricMetadata | null; metadata: MetricDetails['metadata'] | undefined;
isErrorMetricMetadata: boolean; refetchMetricDetails: () => void;
isLoadingMetricMetadata: boolean;
refetchMetricMetadata: () => void;
} }
export interface AllAttributesProps { export interface AllAttributesProps {
attributes: MetricDetailsAttribute[];
metricName: string; metricName: string;
metricType: MetrictypesTypeDTO | undefined; metricType: MetricType | undefined;
} }
export interface AllAttributesValueProps { export interface AllAttributesValueProps {
@@ -41,38 +36,3 @@ export interface AllAttributesValueProps {
filterValue: string[]; filterValue: string[];
goToMetricsExploreWithAppliedAttribute: (key: string, value: string) => void; goToMetricsExploreWithAppliedAttribute: (key: string, value: string) => void;
} }
export interface AllAttributesEmptyTextProps {
isErrorAttributes: boolean;
refetchAttributes: () => void;
}
export type MetricHighlight = MetricsexplorertypesMetricHighlightsResponseDTO;
export type MetricAlert = MetricsexplorertypesMetricAlertDTO;
export type MetricDashboard = MetricsexplorertypesMetricDashboardDTO;
export type MetricMetadata = MetricsexplorertypesMetricMetadataDTO;
export interface MetricMetadataFormState {
type: MetrictypesTypeDTO;
description: string;
temporality?: MetrictypesTemporalityDTO;
unit: string;
isMonotonic: boolean;
}
export type MetricAttribute = MetricsexplorertypesMetricAttributeDTO;
export enum TableFields {
DESCRIPTION = 'description',
UNIT = 'unit',
TYPE = 'type',
Temporality = 'temporality',
IS_MONOTONIC = 'isMonotonic',
}
export interface MetricDetailsErrorStateProps {
refetch?: () => void;
errorMessage: string;
}

View File

@@ -1,23 +1,12 @@
import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics'; import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { import { MetricType } from 'api/metricsExplorer/getMetricsList';
GetMetricMetadata200,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5'; import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
import { initialQueriesMap } from 'constants/queryBuilder'; import { initialQueriesMap } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder'; import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { MetricMetadata, MetricMetadataFormState } from './types'; export function formatTimestampToReadableDate(timestamp: string): string {
export function formatTimestampToReadableDate(
timestamp: number | string | undefined,
): string {
if (!timestamp) {
return '-';
}
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(); const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
@@ -50,10 +39,7 @@ export function formatTimestampToReadableDate(
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
export function formatNumberToCompactFormat(num: number | undefined): string { export function formatNumberToCompactFormat(num: number): string {
if (!num) {
return '-';
}
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
notation: 'compact', notation: 'compact',
maximumFractionDigits: 1, maximumFractionDigits: 1,
@@ -61,30 +47,27 @@ export function formatNumberToCompactFormat(num: number | undefined): string {
} }
export function determineIsMonotonic( export function determineIsMonotonic(
metricType: MetrictypesTypeDTO, metricType: MetricType,
temporality?: MetrictypesTemporalityDTO, temporality?: Temporality,
): boolean { ): boolean {
if ( if (
metricType === MetrictypesTypeDTO.histogram || metricType === MetricType.HISTOGRAM ||
metricType === MetrictypesTypeDTO.exponentialhistogram metricType === MetricType.EXPONENTIAL_HISTOGRAM
) { ) {
return true; return true;
} }
if ( if (metricType === MetricType.GAUGE || metricType === MetricType.SUMMARY) {
metricType === MetrictypesTypeDTO.gauge ||
metricType === MetrictypesTypeDTO.summary
) {
return false; return false;
} }
if (metricType === MetrictypesTypeDTO.sum) { if (metricType === MetricType.SUM) {
return temporality === MetrictypesTemporalityDTO.cumulative; return temporality === Temporality.CUMULATIVE;
} }
return false; return false;
} }
export function getMetricDetailsQuery( export function getMetricDetailsQuery(
metricName: string, metricName: string,
metricType: MetrictypesTypeDTO | undefined, metricType: MetricType | undefined,
filter?: { key: string; value: string }, filter?: { key: string; value: string },
groupBy?: string, groupBy?: string,
): Query { ): Query {
@@ -92,23 +75,23 @@ export function getMetricDetailsQuery(
let spaceAggregation; let spaceAggregation;
let aggregateOperator; let aggregateOperator;
switch (metricType) { switch (metricType) {
case MetrictypesTypeDTO.sum: case MetricType.SUM:
timeAggregation = 'rate'; timeAggregation = 'rate';
spaceAggregation = 'sum'; spaceAggregation = 'sum';
aggregateOperator = 'rate'; aggregateOperator = 'rate';
break; break;
case MetrictypesTypeDTO.gauge: case MetricType.GAUGE:
timeAggregation = 'avg'; timeAggregation = 'avg';
spaceAggregation = 'avg'; spaceAggregation = 'avg';
aggregateOperator = 'avg'; aggregateOperator = 'avg';
break; break;
case MetrictypesTypeDTO.summary: case MetricType.SUMMARY:
timeAggregation = 'noop'; timeAggregation = 'noop';
spaceAggregation = 'sum'; spaceAggregation = 'sum';
aggregateOperator = 'noop'; aggregateOperator = 'noop';
break; break;
case MetrictypesTypeDTO.histogram: case MetricType.HISTOGRAM:
case MetrictypesTypeDTO.exponentialhistogram: case MetricType.EXPONENTIAL_HISTOGRAM:
timeAggregation = 'noop'; timeAggregation = 'noop';
spaceAggregation = 'p90'; spaceAggregation = 'p90';
aggregateOperator = 'noop'; aggregateOperator = 'noop';
@@ -177,38 +160,3 @@ export function getMetricDetailsQuery(
}, },
}; };
} }
export function transformMetricMetadata(
apiData: GetMetricMetadata200 | undefined,
): MetricMetadata | null {
if (!apiData || !apiData.data) {
return null;
}
const { type, description, unit, temporality, isMonotonic } = apiData.data;
return {
type,
description,
unit,
temporality,
isMonotonic,
};
}
export function transformUpdateMetricMetadataRequest(
metricName: string,
metricMetadata: MetricMetadataFormState,
): UpdateMetricMetadataMutationBody {
return {
metricName: metricName,
type: metricMetadata.type,
description: metricMetadata.description,
unit: metricMetadata.unit,
temporality:
metricMetadata.temporality ?? MetrictypesTemporalityDTO.unspecified,
isMonotonic: determineIsMonotonic(
metricMetadata.type,
metricMetadata.temporality,
),
};
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { import {
Button, Button,
Empty, Empty,
@@ -9,24 +10,22 @@ import {
Popover, Popover,
Spin, Spin,
} from 'antd'; } from 'antd';
import { Filter } from 'api/v5/v5';
import {
convertExpressionToFilters,
convertFiltersToExpression,
} from 'components/QueryBuilderV2/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues'; import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import useDebouncedFn from 'hooks/useDebouncedFunction'; import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { SUMMARY_FILTERS_KEY } from './constants';
function MetricNameSearch({ function MetricNameSearch({
queryFilterExpression, queryFilters,
onFilterChange,
}: { }: {
queryFilterExpression: Filter; queryFilters: TagFilter;
onFilterChange: (value: string) => void;
}): JSX.Element { }): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false); const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [searchString, setSearchString] = useState<string>(''); const [searchString, setSearchString] = useState<string>('');
const [debouncedSearchString, setDebouncedSearchString] = useState<string>(''); const [debouncedSearchString, setDebouncedSearchString] = useState<string>('');
@@ -68,12 +67,9 @@ function MetricNameSearch({
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedMetricName: string): void => { (selectedMetricName: string): void => {
const queryFilters = convertExpressionToFilters(
queryFilterExpression.expression,
);
const newFilters = { const newFilters = {
items: [ items: [
...queryFilters, ...queryFilters.items,
{ {
id: 'metric_name', id: 'metric_name',
op: 'CONTAINS', op: 'CONTAINS',
@@ -87,11 +83,13 @@ function MetricNameSearch({
], ],
op: 'and', op: 'and',
}; };
const newFilterExpression = convertFiltersToExpression(newFilters); setSearchParams({
onFilterChange(newFilterExpression.expression); ...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
setIsPopoverOpen(false); setIsPopoverOpen(false);
}, },
[queryFilterExpression, onFilterChange], [queryFilters.items, setSearchParams, searchParams],
); );
const metricNameFilterValues = useMemo( const metricNameFilterValues = useMemo(

View File

@@ -1,76 +0,0 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Typography } from 'antd';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
} from 'lucide-react';
import { METRIC_TYPE_LABEL_MAP } from './constants';
// TODO: @amlannandy Delete this component after API migration is complete
function MetricTypeRenderer({ type }: { type: MetricType }): JSX.Element {
const [icon, color] = useMemo(() => {
switch (type) {
case MetricType.SUM:
return [
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
Color.BG_ROBIN_500,
];
case MetricType.GAUGE:
return [
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
Color.BG_SAKURA_500,
];
case MetricType.HISTOGRAM:
return [
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
Color.BG_SIENNA_500,
];
case MetricType.SUMMARY:
return [
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
Color.BG_FOREST_500,
];
case MetricType.EXPONENTIAL_HISTOGRAM:
return [
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
Color.BG_AQUA_500,
];
default:
return [null, ''];
}
}, [type]);
const metricTypeRendererStyle = useMemo(
() => ({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
}),
[color],
);
const metricTypeRendererTextStyle = useMemo(
() => ({
color,
fontSize: 12,
}),
[color],
);
return (
<div className="metric-type-renderer" style={metricTypeRendererStyle}>
{icon}
<Typography.Text style={metricTypeRendererTextStyle}>
{METRIC_TYPE_LABEL_MAP[type]}
</Typography.Text>
</div>
);
}
export default MetricTypeRenderer;

View File

@@ -1,19 +1,23 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { Button, Menu, Popover, Tooltip } from 'antd'; import { Button, Menu, Popover, Tooltip } from 'antd';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas'; import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { METRIC_TYPE_VIEW_VALUES_MAP } from './constants'; import {
METRIC_TYPE_LABEL_MAP,
METRIC_TYPE_VALUES_MAP,
SUMMARY_FILTERS_KEY,
} from './constants';
function MetricTypeSearch({ function MetricTypeSearch({
queryFilters, queryFilters,
onFilterChange,
}: { }: {
queryFilters: TagFilter; queryFilters: TagFilter;
onFilterChange: (expression: string) => void;
}): JSX.Element { }): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false); const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const menuItems = useMemo( const menuItems = useMemo(
@@ -22,9 +26,9 @@ function MetricTypeSearch({
key: 'all', key: 'all',
value: 'All', value: 'All',
}, },
...Object.keys(METRIC_TYPE_VIEW_VALUES_MAP).map((key) => ({ ...Object.keys(METRIC_TYPE_LABEL_MAP).map((key) => ({
key: METRIC_TYPE_VIEW_VALUES_MAP[key as MetrictypesTypeDTO], key: METRIC_TYPE_VALUES_MAP[key as MetricType],
value: METRIC_TYPE_VIEW_VALUES_MAP[key as MetrictypesTypeDTO], value: METRIC_TYPE_LABEL_MAP[key as MetricType],
})), })),
], ],
[], [],
@@ -32,17 +36,16 @@ function MetricTypeSearch({
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedMetricType: string): void => { (selectedMetricType: string): void => {
let newFilters;
if (selectedMetricType !== 'all') { if (selectedMetricType !== 'all') {
newFilters = { const newFilters = {
items: [ items: [
...queryFilters.items, ...queryFilters.items,
{ {
id: 'type', id: 'metric_type',
op: '=', op: '=',
key: { key: {
id: 'type', id: 'metric_type',
key: 'type', key: 'metric_type',
type: 'tag', type: 'tag',
}, },
value: selectedMetricType, value: selectedMetricType,
@@ -50,17 +53,23 @@ function MetricTypeSearch({
], ],
op: 'AND', op: 'AND',
}; };
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
} else { } else {
newFilters = { const newFilters = {
items: queryFilters.items.filter((item) => item.id !== 'type'), items: queryFilters.items.filter((item) => item.id !== 'metric_type'),
op: 'AND', op: 'AND',
}; };
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
} }
const newFilterExpression = convertFiltersToExpression(newFilters);
onFilterChange(newFilterExpression.expression);
setIsPopoverOpen(false); setIsPopoverOpen(false);
}, },
[queryFilters.items, onFilterChange], [queryFilters.items, setSearchParams, searchParams],
); );
const menu = ( const menu = (

View File

@@ -1,79 +0,0 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Typography } from 'antd';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
} from 'lucide-react';
import { METRIC_TYPE_VIEW_VALUES_MAP } from './constants';
export function MetricTypeViewRenderer({
type,
}: {
type: MetrictypesTypeDTO;
}): JSX.Element {
const [icon, color] = useMemo(() => {
switch (type) {
case MetrictypesTypeDTO.sum:
return [
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
Color.BG_ROBIN_500,
];
case MetrictypesTypeDTO.gauge:
return [
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
Color.BG_SAKURA_500,
];
case MetrictypesTypeDTO.histogram:
return [
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
Color.BG_SIENNA_500,
];
case MetrictypesTypeDTO.summary:
return [
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
Color.BG_FOREST_500,
];
case MetrictypesTypeDTO.exponentialhistogram:
return [
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
Color.BG_AQUA_500,
];
default:
return [null, ''];
}
}, [type]);
const {
metricTypeViewRendererStyle,
metricTypeViewRendererTextStyle,
} = useMemo(() => {
return {
metricTypeViewRendererStyle: {
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
},
metricTypeViewRendererTextStyle: {
color,
fontSize: 12,
},
};
}, [color]);
return (
<div className="metric-type-renderer" style={metricTypeViewRendererStyle}>
{icon}
<Typography.Text style={metricTypeViewRendererTextStyle}>
{METRIC_TYPE_VIEW_VALUES_MAP[type]}
</Typography.Text>
</div>
);
}
export default MetricTypeViewRenderer;

View File

@@ -1,58 +1,27 @@
import { useCallback } from 'react';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch'; import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { Info } from 'lucide-react'; import { HardHat, Info } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import { MetricsSearchProps } from './types'; import { MetricsSearchProps } from './types';
function MetricsSearch({ function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
query,
onChange,
currentQueryFilterExpression,
setCurrentQueryFilterExpression,
isLoading,
}: MetricsSearchProps): JSX.Element {
const handleOnChange = useCallback(
(expression: string): void => {
setCurrentQueryFilterExpression(expression);
},
[setCurrentQueryFilterExpression],
);
const handleStageAndRunQuery = useCallback(() => {
onChange(currentQueryFilterExpression);
}, [currentQueryFilterExpression, onChange]);
return ( return (
<div className="metrics-search-container"> <div className="metrics-search-container">
<div data-testid="qb-search-container" className="qb-search-container"> <div className="qb-search-container">
<Tooltip <Tooltip
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service" title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
placement="right" placement="right"
> >
<Info size={16} /> <Info size={16} />
</Tooltip> </Tooltip>
<QuerySearch <QueryBuilderSearch
onChange={handleOnChange} query={query}
dataSource={DataSource.METRICS} onChange={onChange}
queryData={{ suffixIcon={<HardHat size={16} />}
...query, isMetricsExplorer
filter: {
...query?.filter,
expression: currentQueryFilterExpression,
},
}}
onRun={handleOnChange}
showFilterSuggestionsWithoutMetric
/> />
</div> </div>
<RunQueryBtn
onStageRunQuery={handleStageAndRunQuery}
isLoadingQueries={isLoading}
/>
<div className="metrics-search-options"> <div className="metrics-search-options">
<DateTimeSelectionV2 <DateTimeSelectionV2
showAutoRefresh={false} showAutoRefresh={false}

View File

@@ -9,7 +9,6 @@ import {
Typography, Typography,
} from 'antd'; } from 'antd';
import { SorterResult } from 'antd/es/table/interface'; import { SorterResult } from 'antd/es/table/interface';
import { Querybuildertypesv5OrderDirectionDTO } from 'api/generated/services/sigNoz.schemas';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { MetricsListItemRowData, MetricsTableProps } from './types'; import { MetricsListItemRowData, MetricsTableProps } from './types';
@@ -25,8 +24,7 @@ function MetricsTable({
setOrderBy, setOrderBy,
totalCount, totalCount,
openMetricDetails, openMetricDetails,
queryFilterExpression, queryFilters,
onFilterChange,
}: MetricsTableProps): JSX.Element { }: MetricsTableProps): JSX.Element {
const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback( const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback(
( (
@@ -38,20 +36,13 @@ function MetricsTable({
): void => { ): void => {
if ('field' in sorter && sorter.order) { if ('field' in sorter && sorter.order) {
setOrderBy({ setOrderBy({
key: { columnName: sorter.field as string,
name: sorter.field as string, order: sorter.order === 'ascend' ? 'asc' : 'desc',
},
direction:
sorter.order === 'ascend'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}); });
} else { } else {
setOrderBy({ setOrderBy({
key: { columnName: 'samples',
name: 'samples', order: 'desc',
},
direction: Querybuildertypesv5OrderDirectionDTO.desc,
}); });
} }
}, },
@@ -60,17 +51,19 @@ function MetricsTable({
return ( return (
<div className="metrics-table-container"> <div className="metrics-table-container">
<div className="metrics-table-title" data-testid="metrics-table-title"> {!isError && !isLoading && (
<Typography.Title level={4} className="metrics-table-title"> <div className="metrics-table-title" data-testid="metrics-table-title">
List View <Typography.Title level={4} className="metrics-table-title">
</Typography.Title> List View
<Tooltip </Typography.Title>
title="The table displays all metrics in the selected time range. Each row represents a unique metric, and its metric name, and metadata like description, type, unit, and samples/timeseries cardinality observed in the selected time range." <Tooltip
placement="right" title="The table displays all metrics in the selected time range. Each row represents a unique metric, and its metric name, and metadata like description, type, unit, and samples/timeseries cardinality observed in the selected time range."
> placement="right"
<Info size={16} /> >
</Tooltip> <Info size={16} />
</div> </Tooltip>
</div>
)}
<Table <Table
loading={{ loading={{
spinning: isLoading, spinning: isLoading,
@@ -82,7 +75,7 @@ function MetricsTable({
), ),
}} }}
dataSource={data} dataSource={data}
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)} columns={getMetricsTableColumns(queryFilters)}
locale={{ locale={{
emptyText: isLoading ? null : ( emptyText: isLoading ? null : (
<div <div

View File

@@ -1,10 +1,9 @@
import { useCallback, useMemo } from 'react'; import { useMemo } from 'react';
import { useWindowSize } from 'react-use'; import { useWindowSize } from 'react-use';
import { Group } from '@visx/group'; import { Group } from '@visx/group';
import { Treemap } from '@visx/hierarchy'; import { Treemap } from '@visx/hierarchy';
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd'; import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas'; import { stratify, treemapBinary } from 'd3-hierarchy';
import { HierarchyNode, stratify, treemapBinary } from 'd3-hierarchy';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { import {
@@ -13,24 +12,21 @@ import {
TREEMAP_SQUARE_PADDING, TREEMAP_SQUARE_PADDING,
TREEMAP_VIEW_OPTIONS, TREEMAP_VIEW_OPTIONS,
} from './constants'; } from './constants';
import { import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
MetricsTreemapInternalProps,
MetricsTreemapProps,
TreemapTile,
} from './types';
import { import {
getTreemapTileStyle, getTreemapTileStyle,
getTreemapTileTextStyle, getTreemapTileTextStyle,
transformTreemapData, transformTreemapData,
} from './utils'; } from './utils';
function MetricsTreemapInternal({ function MetricsTreemap({
viewType,
data,
isLoading, isLoading,
isError, isError,
data,
viewType,
openMetricDetails, openMetricDetails,
}: MetricsTreemapInternalProps): JSX.Element { setHeatmapView,
}: MetricsTreemapProps): JSX.Element {
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
const treemapWidth = useMemo( const treemapWidth = useMemo(
@@ -44,9 +40,9 @@ function MetricsTreemapInternal({
const treemapData = useMemo(() => { const treemapData = useMemo(() => {
const extracedTreemapData = const extracedTreemapData =
(viewType === MetricsexplorertypesTreemapModeDTO.timeseries (viewType === TreemapViewType.TIMESERIES
? data?.timeseries ? data?.data?.[TreemapViewType.TIMESERIES]
: data?.samples) || []; : data?.data?.[TreemapViewType.SAMPLES]) || [];
return transformTreemapData(extracedTreemapData, viewType); return transformTreemapData(extracedTreemapData, viewType);
}, [data, viewType]); }, [data, viewType]);
@@ -58,126 +54,41 @@ function MetricsTreemapInternal({
const xMax = treemapWidth - TREEMAP_MARGINS.LEFT - TREEMAP_MARGINS.RIGHT; const xMax = treemapWidth - TREEMAP_MARGINS.LEFT - TREEMAP_MARGINS.RIGHT;
const yMax = TREEMAP_HEIGHT - TREEMAP_MARGINS.TOP - TREEMAP_MARGINS.BOTTOM; const yMax = TREEMAP_HEIGHT - TREEMAP_MARGINS.TOP - TREEMAP_MARGINS.BOTTOM;
const treemapStylesWithoutPadding = useMemo(
() => ({
width: treemapWidth,
height: TREEMAP_HEIGHT,
}),
[treemapWidth],
);
const treemapStylesWithPadding = useMemo(
() => ({
width: treemapWidth,
height: TREEMAP_HEIGHT,
paddingTop: 30,
}),
[treemapWidth],
);
const treemapTileStyle = useCallback(
(node: HierarchyNode<TreemapTile>) => ({
...getTreemapTileStyle(node.data),
...getTreemapTileTextStyle(),
}),
[],
);
if (isLoading) { if (isLoading) {
return ( return (
<div data-testid="metrics-treemap-loading-state"> <div data-testid="metrics-treemap-loading-state">
<Skeleton style={treemapStylesWithoutPadding} active /> <Skeleton
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
active
/>
</div> </div>
); );
} }
if (isError) { if (
return ( !data ||
<Empty !data.data ||
description="Error fetching metrics. If the problem persists, please contact support." (data?.status === 'success' && !data?.data?.[viewType])
data-testid="metrics-treemap-error-state" ) {
style={treemapStylesWithPadding}
/>
);
}
if (!data || !data?.[viewType]?.length) {
return ( return (
<Empty <Empty
description="No metrics found" description="No metrics found"
data-testid="metrics-treemap-empty-state" data-testid="metrics-treemap-empty-state"
style={treemapStylesWithPadding} style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
/> />
); );
} }
return ( if (data?.status === 'error' || isError) {
<svg width={treemapWidth} height={TREEMAP_HEIGHT} className="metrics-treemap"> return (
<rect <Empty
width={treemapWidth} description="Error fetching metrics. If the problem persists, please contact support."
height={TREEMAP_HEIGHT} data-testid="metrics-treemap-error-state"
rx={14} style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
fill="transparent"
/> />
<Treemap<TreemapTile> );
top={TREEMAP_MARGINS.TOP} }
root={transformedTreemapData}
size={[xMax, yMax]}
tile={treemapBinary}
round
>
{(treemap): JSX.Element => (
<Group>
{treemap
.descendants()
.reverse()
.map((node, i) => {
const nodeWidth = node.x1 - node.x0 - TREEMAP_SQUARE_PADDING;
const nodeHeight = node.y1 - node.y0 - TREEMAP_SQUARE_PADDING;
if (nodeWidth < 0 || nodeHeight < 0) {
return null;
}
return (
<Group
// eslint-disable-next-line react/no-array-index-key
key={node.data.id || `node-${i}`}
top={node.y0 + TREEMAP_MARGINS.TOP}
left={node.x0 + TREEMAP_MARGINS.LEFT}
>
{node.depth > 0 && (
<Tooltip
title={`${node.data.id}: ${node.data.displayValue}%`}
placement="top"
>
<foreignObject
width={nodeWidth}
height={nodeHeight}
onClick={(): void => openMetricDetails(node.data.id, 'treemap')}
>
<div style={treemapTileStyle(node)}>
{`${node.data.displayValue}%`}
</div>
</foreignObject>
</Tooltip>
)}
</Group>
);
})}
</Group>
)}
</Treemap>
</svg>
);
}
function MetricsTreemap({
viewType,
data,
isLoading,
isError,
openMetricDetails,
setHeatmapView,
}: MetricsTreemapProps): JSX.Element {
return ( return (
<div <div
className="metrics-treemap-container" className="metrics-treemap-container"
@@ -197,16 +108,72 @@ function MetricsTreemap({
options={TREEMAP_VIEW_OPTIONS} options={TREEMAP_VIEW_OPTIONS}
value={viewType} value={viewType}
onChange={setHeatmapView} onChange={setHeatmapView}
disabled={isLoading}
/> />
</div> </div>
<MetricsTreemapInternal <svg
isLoading={isLoading} width={treemapWidth}
isError={isError} height={TREEMAP_HEIGHT}
data={data} className="metrics-treemap"
viewType={viewType} >
openMetricDetails={openMetricDetails} <rect
/> width={treemapWidth}
height={TREEMAP_HEIGHT}
rx={14}
fill="transparent"
/>
<Treemap<TreemapTile>
top={TREEMAP_MARGINS.TOP}
root={transformedTreemapData}
size={[xMax, yMax]}
tile={treemapBinary}
round
>
{(treemap): JSX.Element => (
<Group>
{treemap
.descendants()
.reverse()
.map((node, i) => {
const nodeWidth = node.x1 - node.x0 - TREEMAP_SQUARE_PADDING;
const nodeHeight = node.y1 - node.y0 - TREEMAP_SQUARE_PADDING;
if (nodeWidth < 0 || nodeHeight < 0) {
return null;
}
return (
<Group
// eslint-disable-next-line react/no-array-index-key
key={node.data.id || `node-${i}`}
top={node.y0 + TREEMAP_MARGINS.TOP}
left={node.x0 + TREEMAP_MARGINS.LEFT}
>
{node.depth > 0 && (
<Tooltip
title={`${node.data.id}: ${node.data.displayValue}%`}
placement="top"
>
<foreignObject
width={nodeWidth}
height={nodeHeight}
onClick={(): void => openMetricDetails(node.data.id, 'treemap')}
>
<div
style={{
...getTreemapTileStyle(node.data),
...getTreemapTileTextStyle(),
}}
>
{`${node.data.displayValue}%`}
</div>
</foreignObject>
</Tooltip>
)}
</Group>
);
})}
</Group>
)}
</Treemap>
</svg>
</div> </div>
); );
} }

View File

@@ -38,7 +38,6 @@
.metrics-search-container { .metrics-search-container {
display: flex; display: flex;
gap: 16px; gap: 16px;
align-items: center;
.metrics-search-options { .metrics-search-options {
display: flex; display: flex;

View File

@@ -4,24 +4,11 @@ import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat'; import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { import { initialQueriesMap } from 'constants/queryBuilder';
useGetMetricsStats,
useGetMetricsTreemap,
} from 'api/generated/services/metrics';
import {
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapModeDTO,
MetricsexplorertypesTreemapRequestDTO,
Querybuildertypesv5OrderByDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
convertExpressionToFilters,
convertFiltersToExpression,
} from 'components/QueryBuilderV2/utils';
import { usePageSize } from 'container/InfraMonitoringK8s/utils'; import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import NoLogs from 'container/NoLogs/NoLogs'; import NoLogs from 'container/NoLogs/NoLogs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
import { useGetMetricsTreeMap } from 'hooks/metricsExplorer/useGetMetricsTreeMap';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -36,38 +23,32 @@ import {
IS_INSPECT_MODAL_OPEN_KEY, IS_INSPECT_MODAL_OPEN_KEY,
IS_METRIC_DETAILS_OPEN_KEY, IS_METRIC_DETAILS_OPEN_KEY,
SELECTED_METRIC_NAME_KEY, SELECTED_METRIC_NAME_KEY,
SUMMARY_FILTERS_KEY,
} from './constants'; } from './constants';
import MetricsSearch from './MetricsSearch'; import MetricsSearch from './MetricsSearch';
import MetricsTable from './MetricsTable'; import MetricsTable from './MetricsTable';
import MetricsTreemap from './MetricsTreemap'; import MetricsTreemap from './MetricsTreemap';
import { convertNanoToMilliseconds, formatDataForMetricsTable } from './utils'; import { OrderByPayload, TreemapViewType } from './types';
import {
convertNanoToMilliseconds,
formatDataForMetricsTable,
getMetricsListQuery,
} from './utils';
import './Summary.styles.scss'; import './Summary.styles.scss';
const DEFAULT_ORDER_BY: Querybuildertypesv5OrderByDTO = { const DEFAULT_ORDER_BY: OrderByPayload = {
key: { columnName: 'samples',
name: 'samples', order: 'desc',
},
direction: Querybuildertypesv5OrderDirectionDTO.desc,
}; };
function Summary(): JSX.Element { function Summary(): JSX.Element {
const { pageSize, setPageSize } = usePageSize('metricsExplorer'); const { pageSize, setPageSize } = usePageSize('metricsExplorer');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [orderBy, setOrderBy] = useState<Querybuildertypesv5OrderByDTO>( const [orderBy, setOrderBy] = useState<OrderByPayload>(DEFAULT_ORDER_BY);
DEFAULT_ORDER_BY, const [heatmapView, setHeatmapView] = useState<TreemapViewType>(
TreemapViewType.TIMESERIES,
); );
const [
heatmapView,
setHeatmapView,
] = useState<MetricsexplorertypesTreemapModeDTO>(
MetricsexplorertypesTreemapModeDTO.timeseries,
);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
currentQuery,
]);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState( const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
@@ -84,10 +65,16 @@ function Summary(): JSX.Element {
(state) => state.globalTime, (state) => state.globalTime,
); );
const [ const queryFilters: TagFilter = useMemo(() => {
currentQueryFilterExpression, const encodedFilters = searchParams.get(SUMMARY_FILTERS_KEY);
setCurrentQueryFilterExpression, if (encodedFilters) {
] = useState<string>(query?.filter?.expression || ''); return JSON.parse(encodedFilters);
}
return {
items: [],
op: 'AND',
};
}, [searchParams]);
useEffect(() => { useEffect(() => {
logEvent(MetricsExplorerEvents.TabChanged, { logEvent(MetricsExplorerEvents.TabChanged, {
@@ -100,101 +87,105 @@ function Summary(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const queryFilterExpression = useMemo(() => { // This is used to avoid the filters from being serialized with the id
const filters = query.filters || { items: [], op: 'AND' }; const queryFiltersWithoutId = useMemo(() => {
return convertFiltersToExpression(filters); const filtersWithoutId = {
}, [query.filters]); ...queryFilters,
items: queryFilters.items.map(({ id: _id, ...rest }) => rest),
};
return JSON.stringify(filtersWithoutId);
}, [queryFilters]);
const metricsListQuery: MetricsexplorertypesStatsRequestDTO = useMemo(() => { const metricsListQuery = useMemo(() => {
const baseQuery = getMetricsListQuery();
return { return {
start: convertNanoToMilliseconds(minTime), ...baseQuery,
end: convertNanoToMilliseconds(maxTime),
limit: pageSize, limit: pageSize,
offset: (currentPage - 1) * pageSize, offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime),
orderBy, orderBy,
filter: {
expression: queryFilterExpression.expression,
},
}; };
}, [ }, [queryFilters, minTime, maxTime, orderBy, pageSize, currentPage]);
minTime,
maxTime,
orderBy,
pageSize,
currentPage,
queryFilterExpression.expression,
]);
const metricsTreemapQuery: MetricsexplorertypesTreemapRequestDTO = useMemo( const metricsTreemapQuery = useMemo(
() => ({ () => ({
limit: 100, limit: 100,
filters: queryFilters,
treemap: heatmapView, treemap: heatmapView,
start: convertNanoToMilliseconds(minTime), start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime), end: convertNanoToMilliseconds(maxTime),
mode: heatmapView,
filter: {
expression: queryFilterExpression.expression,
},
}), }),
[heatmapView, minTime, maxTime, queryFilterExpression.expression], [queryFilters, heatmapView, minTime, maxTime],
); );
const { const {
data: metricsData, data: metricsData,
mutate: getMetricsStats, isLoading: isMetricsLoading,
isLoading: isGetMetricsStatsLoading, isFetching: isMetricsFetching,
isError: isGetMetricsStatsError, isError: isMetricsError,
} = useGetMetricsStats(); } = useGetMetricsList(metricsListQuery, {
enabled: !!metricsListQuery && !isInspectModalOpen,
queryKey: [
'metricsList',
queryFiltersWithoutId,
orderBy,
pageSize,
currentPage,
minTime,
maxTime,
],
});
const isListViewError = useMemo(
() => isMetricsError || !!(metricsData && metricsData.statusCode !== 200),
[isMetricsError, metricsData],
);
const { const {
data: treeMapData, data: treeMapData,
mutate: getMetricsTreemap, isLoading: isTreeMapLoading,
isLoading: isGetMetricsTreemapLoading, isFetching: isTreeMapFetching,
isError: isGetMetricsTreemapError, isError: isTreeMapError,
} = useGetMetricsTreemap(); } = useGetMetricsTreeMap(metricsTreemapQuery, {
enabled: !!metricsTreemapQuery && !isInspectModalOpen,
queryKey: [
'metricsTreemap',
queryFiltersWithoutId,
heatmapView,
minTime,
maxTime,
],
});
useEffect(() => { const isProportionViewError = useMemo(
getMetricsStats({ () => isTreeMapError || treeMapData?.statusCode !== 200,
data: metricsListQuery, [isTreeMapError, treeMapData],
}); );
}, [metricsListQuery, getMetricsStats]);
useEffect(() => {
getMetricsTreemap({
data: metricsTreemapQuery,
});
}, [metricsTreemapQuery, getMetricsTreemap]);
const handleFilterChange = useCallback( const handleFilterChange = useCallback(
(expression: string) => { (value: TagFilter) => {
const newFilters: TagFilter = { setSearchParams({
items: convertExpressionToFilters(expression), ...Object.fromEntries(searchParams.entries()),
op: 'AND', [SUMMARY_FILTERS_KEY]: JSON.stringify(value),
};
redirectWithQueryBuilderData({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilters,
filter: {
expression,
},
},
],
},
}); });
setCurrentQueryFilterExpression(expression);
setCurrentPage(1); setCurrentPage(1);
if (expression) { if (value.items.length > 0) {
logEvent(MetricsExplorerEvents.FilterApplied, { logEvent(MetricsExplorerEvents.FilterApplied, {
[MetricsExplorerEventKeys.Tab]: 'summary', [MetricsExplorerEventKeys.Tab]: 'summary',
}); });
} }
}, },
[currentQuery, redirectWithQueryBuilderData], [setSearchParams, searchParams],
);
const searchQuery = useMemo(
() => ({
...initialQueriesMap.metrics.builder.queryData[0],
filters: queryFilters,
}),
[queryFilters],
); );
const onPaginationChange = (page: number, pageSize: number): void => { const onPaginationChange = (page: number, pageSize: number): void => {
@@ -211,7 +202,7 @@ function Summary(): JSX.Element {
}; };
const formattedMetricsData = useMemo( const formattedMetricsData = useMemo(
() => formatDataForMetricsTable(metricsData?.data.metrics || []), () => formatDataForMetricsTable(metricsData?.payload?.data?.metrics || []),
[metricsData], [metricsData],
); );
@@ -263,9 +254,7 @@ function Summary(): JSX.Element {
}); });
}; };
const handleSetHeatmapView = ( const handleSetHeatmapView = (view: TreemapViewType): void => {
view: MetricsexplorertypesTreemapModeDTO,
): void => {
setHeatmapView(view); setHeatmapView(view);
logEvent(MetricsExplorerEvents.TreemapViewChanged, { logEvent(MetricsExplorerEvents.TreemapViewChanged, {
[MetricsExplorerEventKeys.Tab]: 'summary', [MetricsExplorerEventKeys.Tab]: 'summary',
@@ -273,67 +262,68 @@ function Summary(): JSX.Element {
}); });
}; };
const handleSetOrderBy = (orderBy: Querybuildertypesv5OrderByDTO): void => { const handleSetOrderBy = (orderBy: OrderByPayload): void => {
setOrderBy(orderBy); setOrderBy(orderBy);
logEvent(MetricsExplorerEvents.OrderByApplied, { logEvent(MetricsExplorerEvents.OrderByApplied, {
[MetricsExplorerEventKeys.Tab]: 'summary', [MetricsExplorerEventKeys.Tab]: 'summary',
[MetricsExplorerEventKeys.ColumnName]: orderBy.key?.name, [MetricsExplorerEventKeys.ColumnName]: orderBy.columnName,
[MetricsExplorerEventKeys.Order]: orderBy.direction, [MetricsExplorerEventKeys.Order]: orderBy.order,
}); });
}; };
const isMetricsListDataEmpty = const isMetricsListDataEmpty = useMemo(
formattedMetricsData.length === 0 && !isGetMetricsStatsLoading; () =>
formattedMetricsData.length === 0 && !isMetricsLoading && !isMetricsFetching,
[formattedMetricsData, isMetricsLoading, isMetricsFetching],
);
const isMetricsTreeMapDataEmpty = const isMetricsTreeMapDataEmpty = useMemo(
!treeMapData?.data[heatmapView]?.length && !isGetMetricsTreemapLoading; () =>
!treeMapData?.payload?.data[heatmapView]?.length &&
const showFullScreenLoading = !isTreeMapLoading &&
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) && !isTreeMapFetching,
formattedMetricsData.length === 0 && [
!treeMapData?.data[heatmapView]?.length; treeMapData?.payload?.data,
heatmapView,
isTreeMapLoading,
isTreeMapFetching,
],
);
return ( return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}> <Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-summary-tab"> <div className="metrics-explorer-summary-tab">
<MetricsSearch <MetricsSearch query={searchQuery} onChange={handleFilterChange} />
query={query} {isMetricsLoading || isTreeMapLoading ? (
onChange={handleFilterChange}
currentQueryFilterExpression={currentQueryFilterExpression}
setCurrentQueryFilterExpression={setCurrentQueryFilterExpression}
isLoading={isGetMetricsStatsLoading || isGetMetricsTreemapLoading}
/>
{showFullScreenLoading ? (
<MetricsLoading /> <MetricsLoading />
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? ( ) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
<NoLogs dataSource={DataSource.METRICS} /> <NoLogs dataSource={DataSource.METRICS} />
) : ( ) : (
<> <>
<MetricsTreemap <MetricsTreemap
data={treeMapData?.data} data={treeMapData?.payload}
isLoading={isGetMetricsTreemapLoading} isLoading={isTreeMapLoading || isTreeMapFetching}
isError={isGetMetricsTreemapError} isError={isProportionViewError}
viewType={heatmapView} viewType={heatmapView}
openMetricDetails={openMetricDetails} openMetricDetails={openMetricDetails}
setHeatmapView={handleSetHeatmapView} setHeatmapView={handleSetHeatmapView}
/> />
<MetricsTable <MetricsTable
isLoading={isGetMetricsStatsLoading} isLoading={isMetricsLoading || isMetricsFetching}
isError={isGetMetricsStatsError} isError={isListViewError}
data={formattedMetricsData} data={formattedMetricsData}
pageSize={pageSize} pageSize={pageSize}
currentPage={currentPage} currentPage={currentPage}
onPaginationChange={onPaginationChange} onPaginationChange={onPaginationChange}
setOrderBy={handleSetOrderBy} setOrderBy={handleSetOrderBy}
totalCount={metricsData?.data.total || 0} totalCount={metricsData?.payload?.data?.total || 0}
openMetricDetails={openMetricDetails} openMetricDetails={openMetricDetails}
queryFilterExpression={queryFilterExpression} queryFilters={queryFilters}
onFilterChange={handleFilterChange}
/> />
</> </>
)} )}
</div> </div>
{isMetricDetailsOpen && selectedMetricName && ( {isMetricDetailsOpen && (
<MetricDetails <MetricDetails
isOpen={isMetricDetailsOpen} isOpen={isMetricDetailsOpen}
onClose={closeMetricDetails} onClose={closeMetricDetails}

View File

@@ -1,63 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import MetricTypeRenderer from '../MetricTypeRenderer';
jest.mock('lucide-react', () => {
return {
__esModule: true,
Diff: (): JSX.Element => <svg data-testid="diff-icon" />,
Gauge: (): JSX.Element => <svg data-testid="gauge-icon" />,
BarChart2: (): JSX.Element => <svg data-testid="bar-chart-2-icon" />,
BarChartHorizontal: (): JSX.Element => (
<svg data-testid="bar-chart-horizontal-icon" />
),
BarChart: (): JSX.Element => <svg data-testid="bar-chart-icon" />,
};
});
describe('MetricTypeRenderer', () => {
it('should render correct icon and color for each metric type', () => {
const types = [
{
type: MetricType.SUM,
color: Color.BG_ROBIN_500,
iconTestId: 'diff-icon',
},
{
type: MetricType.GAUGE,
color: Color.BG_SAKURA_500,
iconTestId: 'gauge-icon',
},
{
type: MetricType.HISTOGRAM,
color: Color.BG_SIENNA_500,
iconTestId: 'bar-chart-2-icon',
},
{
type: MetricType.SUMMARY,
color: Color.BG_FOREST_500,
iconTestId: 'bar-chart-horizontal-icon',
},
{
type: MetricType.EXPONENTIAL_HISTOGRAM,
color: Color.BG_AQUA_500,
iconTestId: 'bar-chart-icon',
},
];
types.forEach(({ type, color, iconTestId }) => {
const { container } = render(<MetricTypeRenderer type={type} />);
const rendererDiv = container.firstChild as HTMLElement;
expect(rendererDiv).toHaveStyle({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
});
expect(screen.getByTestId(iconTestId)).toBeInTheDocument();
});
});
});

View File

@@ -1,10 +1,10 @@
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { Filter } from 'api/v5/v5';
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues'; import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations'; import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
import store from 'store'; import store from 'store';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import MetricsTable from '../MetricsTable'; import MetricsTable from '../MetricsTable';
import { MetricsListItemRowData } from '../types'; import { MetricsListItemRowData } from '../types';
@@ -30,8 +30,9 @@ const mockData: MetricsListItemRowData[] = [
}, },
]; ];
const mockQueryFilterExpression: Filter = { const mockQueryFilters: TagFilter = {
expression: '', items: [],
op: 'AND',
}; };
jest.mock('react-router-dom-v5-compat', () => { jest.mock('react-router-dom-v5-compat', () => {
@@ -81,8 +82,7 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression} queryFilters={mockQueryFilters}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -106,9 +106,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression} queryFilters={mockQueryFilters}
isLoading isLoading
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -131,8 +130,7 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression} queryFilters={mockQueryFilters}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -160,8 +158,7 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression} queryFilters={mockQueryFilters}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -190,8 +187,7 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={mockOpenMetricDetails} openMetricDetails={mockOpenMetricDetails}
queryFilterExpression={mockQueryFilterExpression} queryFilters={mockQueryFilters}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -216,8 +212,7 @@ describe('MetricsTable', () => {
setOrderBy={mockSetOrderBy} setOrderBy={mockSetOrderBy}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression} queryFilters={mockQueryFilters}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -227,10 +222,8 @@ describe('MetricsTable', () => {
fireEvent.click(samplesHeader); fireEvent.click(samplesHeader);
expect(mockSetOrderBy).toHaveBeenCalledWith({ expect(mockSetOrderBy).toHaveBeenCalledWith({
key: { columnName: 'samples',
name: 'samples', order: 'asc',
},
direction: 'asc',
}); });
}); });
}); });

View File

@@ -1,10 +1,10 @@
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
import store from 'store'; import store from 'store';
import MetricsTreemap from '../MetricsTreemap'; import MetricsTreemap from '../MetricsTreemap';
import { TreemapViewType } from '../types';
jest.mock('d3-hierarchy', () => ({ jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({ stratify: jest.fn().mockReturnValue({
@@ -27,14 +27,14 @@ jest.mock('react-use', () => ({
const mockData = [ const mockData = [
{ {
metricName: 'Metric 1', metric_name: 'Metric 1',
percentage: 0.5, percentage: 0.5,
totalValue: 15, total_value: 15,
}, },
{ {
metricName: 'Metric 2', metric_name: 'Metric 2',
percentage: 0.6, percentage: 0.6,
totalValue: 10, total_value: 10,
}, },
]; ];
@@ -47,11 +47,14 @@ describe('MetricsTreemap', () => {
isLoading={false} isLoading={false}
isError={false} isError={false}
data={{ data={{
timeseries: [mockData[0]], status: 'success',
samples: [mockData[1]], data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}} }}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
viewType={MetricsexplorertypesTreemapModeDTO.samples} viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()} setHeatmapView={jest.fn()}
/> />
</Provider> </Provider>
@@ -69,11 +72,14 @@ describe('MetricsTreemap', () => {
isLoading isLoading
isError={false} isError={false}
data={{ data={{
timeseries: [mockData[0]], status: 'success',
samples: [mockData[1]], data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}} }}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
viewType={MetricsexplorertypesTreemapModeDTO.samples} viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()} setHeatmapView={jest.fn()}
/> />
</Provider> </Provider>
@@ -93,11 +99,14 @@ describe('MetricsTreemap', () => {
isLoading={false} isLoading={false}
isError isError
data={{ data={{
timeseries: [mockData[0]], status: 'success',
samples: [mockData[1]], data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}} }}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
viewType={MetricsexplorertypesTreemapModeDTO.samples} viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()} setHeatmapView={jest.fn()}
/> />
</Provider> </Provider>
@@ -119,9 +128,9 @@ describe('MetricsTreemap', () => {
<MetricsTreemap <MetricsTreemap
isLoading={false} isLoading={false}
isError={false} isError={false}
data={undefined} data={null}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
viewType={MetricsexplorertypesTreemapModeDTO.samples} viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()} setHeatmapView={jest.fn()}
/> />
</Provider> </Provider>

View File

@@ -1,81 +1,109 @@
import { Color } from '@signozhq/design-tokens';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas'; import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { Filter } from 'api/v5/v5';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils'; import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TreemapViewType } from '../types'; import { TreemapViewType } from '../types';
import { formatDataForMetricsTable, getMetricsTableColumns } from '../utils'; import {
formatDataForMetricsTable,
const mockQueryExpression: Filter = { getMetricsTableColumns,
expression: '', MetricTypeRenderer,
}; } from '../utils';
const mockOnChange = jest.fn();
describe('metricsTableColumns', () => { describe('metricsTableColumns', () => {
const mockQueryFilters: TagFilter = {
items: [],
op: 'AND',
};
it('should have correct column definitions', () => { it('should have correct column definitions', () => {
expect( expect(getMetricsTableColumns(mockQueryFilters)).toHaveLength(6);
getMetricsTableColumns(mockQueryExpression, mockOnChange),
).toHaveLength(6);
// Metric Name column // Metric Name column
expect( expect(getMetricsTableColumns(mockQueryFilters)[0].dataIndex).toBe(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].dataIndex, 'metric_name',
).toBe('metric_name'); );
expect( expect(getMetricsTableColumns(mockQueryFilters)[0].width).toBe(400);
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].width, expect(getMetricsTableColumns(mockQueryFilters)[0].sorter).toBe(false);
).toBe(400);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].sorter,
).toBe(false);
// Description column // Description column
expect( expect(getMetricsTableColumns(mockQueryFilters)[1].dataIndex).toBe(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].dataIndex, 'description',
).toBe('description'); );
expect( expect(getMetricsTableColumns(mockQueryFilters)[1].width).toBe(400);
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].width,
).toBe(400);
// Type column // Type column
expect( expect(getMetricsTableColumns(mockQueryFilters)[2].dataIndex).toBe(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].dataIndex, 'metric_type',
).toBe('metric_type'); );
expect( expect(getMetricsTableColumns(mockQueryFilters)[2].width).toBe(150);
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].width, expect(getMetricsTableColumns(mockQueryFilters)[2].sorter).toBe(false);
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].sorter,
).toBe(false);
// Unit column // Unit column
expect( expect(getMetricsTableColumns(mockQueryFilters)[3].dataIndex).toBe('unit');
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].dataIndex, expect(getMetricsTableColumns(mockQueryFilters)[3].width).toBe(150);
).toBe('unit');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].width,
).toBe(150);
// Samples column // Samples column
expect( expect(getMetricsTableColumns(mockQueryFilters)[4].dataIndex).toBe(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].dataIndex, TreemapViewType.SAMPLES,
).toBe(TreemapViewType.SAMPLES); );
expect( expect(getMetricsTableColumns(mockQueryFilters)[4].width).toBe(150);
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].width, expect(getMetricsTableColumns(mockQueryFilters)[4].sorter).toBe(true);
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].sorter,
).toBe(true);
// Time Series column // Time Series column
expect( expect(getMetricsTableColumns(mockQueryFilters)[5].dataIndex).toBe(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].dataIndex, TreemapViewType.TIMESERIES,
).toBe(TreemapViewType.TIMESERIES); );
expect( expect(getMetricsTableColumns(mockQueryFilters)[5].width).toBe(150);
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].width, expect(getMetricsTableColumns(mockQueryFilters)[5].sorter).toBe(true);
).toBe(150); });
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].sorter, describe('MetricTypeRenderer', () => {
).toBe(true); it('should render correct icon and color for each metric type', () => {
const types = [
{
type: MetricType.SUM,
color: Color.BG_ROBIN_500,
},
{
type: MetricType.GAUGE,
color: Color.BG_SAKURA_500,
},
{
type: MetricType.HISTOGRAM,
color: Color.BG_SIENNA_500,
},
{
type: MetricType.SUMMARY,
color: Color.BG_FOREST_500,
},
{
type: MetricType.EXPONENTIAL_HISTOGRAM,
color: Color.BG_AQUA_500,
},
];
types.forEach(({ type, color }) => {
const { container } = render(<MetricTypeRenderer type={type} />);
const rendererDiv = container.firstChild as HTMLElement;
expect(rendererDiv).toHaveStyle({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
});
});
});
it('should return empty icon and color for unknown metric type', () => {
const { container } = render(
<MetricTypeRenderer type={'UNKNOWN' as MetricType} />,
);
const rendererDiv = container.firstChild as HTMLElement;
expect(rendererDiv.querySelector('svg')).toBeNull();
});
}); });
}); });
@@ -83,12 +111,12 @@ describe('formatDataForMetricsTable', () => {
it('should format metrics data correctly', () => { it('should format metrics data correctly', () => {
const mockData = [ const mockData = [
{ {
metricName: 'test_metric', metric_name: 'test_metric',
description: 'Test description', description: 'Test description',
type: MetrictypesTypeDTO.gauge, type: MetricType.GAUGE,
unit: 'bytes', unit: 'bytes',
samples: 1000, [TreemapViewType.SAMPLES]: 1000,
timeseries: 2000, [TreemapViewType.TIMESERIES]: 2000,
lastReceived: '2023-01-01T00:00:00Z', lastReceived: '2023-01-01T00:00:00Z',
}, },
]; ];
@@ -135,12 +163,12 @@ describe('formatDataForMetricsTable', () => {
it('should handle empty/null values', () => { it('should handle empty/null values', () => {
const mockData = [ const mockData = [
{ {
metricName: '', metric_name: '',
description: '', description: '',
type: MetrictypesTypeDTO.gauge, type: MetricType.GAUGE,
unit: '', unit: '',
samples: 0, [TreemapViewType.SAMPLES]: 0,
timeseries: 0, [TreemapViewType.TIMESERIES]: 0,
lastReceived: '2023-01-01T00:00:00Z', lastReceived: '2023-01-01T00:00:00Z',
}, },
]; ];

View File

@@ -1,17 +1,15 @@
import {
MetricsexplorertypesTreemapModeDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetricType } from 'api/metricsExplorer/getMetricsList'; import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { TreemapViewType } from './types';
export const METRICS_TABLE_PAGE_SIZE = 10; export const METRICS_TABLE_PAGE_SIZE = 10;
export const TREEMAP_VIEW_OPTIONS: { export const TREEMAP_VIEW_OPTIONS: {
value: MetricsexplorertypesTreemapModeDTO; value: TreemapViewType;
label: string; label: string;
}[] = [ }[] = [
{ value: MetricsexplorertypesTreemapModeDTO.timeseries, label: 'Time Series' }, { value: TreemapViewType.TIMESERIES, label: 'Time Series' },
{ value: MetricsexplorertypesTreemapModeDTO.samples, label: 'Samples' }, { value: TreemapViewType.SAMPLES, label: 'Samples' },
]; ];
export const TREEMAP_HEIGHT = 200; export const TREEMAP_HEIGHT = 200;
@@ -19,7 +17,6 @@ export const TREEMAP_SQUARE_PADDING = 5;
export const TREEMAP_MARGINS = { TOP: 10, LEFT: 10, RIGHT: 10, BOTTOM: 10 }; export const TREEMAP_MARGINS = { TOP: 10, LEFT: 10, RIGHT: 10, BOTTOM: 10 };
// TODO: Remove this once API migration is complete
export const METRIC_TYPE_LABEL_MAP = { export const METRIC_TYPE_LABEL_MAP = {
[MetricType.SUM]: 'Sum', [MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge', [MetricType.GAUGE]: 'Gauge',
@@ -28,16 +25,7 @@ export const METRIC_TYPE_LABEL_MAP = {
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram', [MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
}; };
export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = { export const METRIC_TYPE_VALUES_MAP = {
[MetrictypesTypeDTO.sum]: 'Sum',
[MetrictypesTypeDTO.gauge]: 'Gauge',
[MetrictypesTypeDTO.histogram]: 'Histogram',
[MetrictypesTypeDTO.summary]: 'Summary',
[MetrictypesTypeDTO.exponentialhistogram]: 'Exp. Histogram',
};
// TODO(@amlannandy): To remove this once API migration is complete
export const METRIC_TYPE_VALUES_MAP: Record<MetricType, string> = {
[MetricType.SUM]: 'Sum', [MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge', [MetricType.GAUGE]: 'Gauge',
[MetricType.HISTOGRAM]: 'Histogram', [MetricType.HISTOGRAM]: 'Histogram',
@@ -45,14 +33,7 @@ export const METRIC_TYPE_VALUES_MAP: Record<MetricType, string> = {
[MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram', [MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram',
}; };
export const METRIC_TYPE_VIEW_VALUES_MAP: Record<MetrictypesTypeDTO, string> = {
[MetrictypesTypeDTO.sum]: 'Sum',
[MetrictypesTypeDTO.gauge]: 'Gauge',
[MetrictypesTypeDTO.histogram]: 'Histogram',
[MetrictypesTypeDTO.summary]: 'Summary',
[MetrictypesTypeDTO.exponentialhistogram]: 'ExponentialHistogram',
};
export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen'; export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen';
export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen'; export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName'; export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
export const SUMMARY_FILTERS_KEY = 'summaryFilters';

View File

@@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
import { MetricsTreeMapResponse } from 'api/metricsExplorer/getMetricsTreeMap';
import { import {
MetricsexplorertypesTreemapModeDTO, IBuilderQuery,
MetricsexplorertypesTreemapResponseDTO, TagFilter,
Querybuildertypesv5OrderByDTO, } from 'types/api/queryBuilder/queryBuilderData';
} from 'api/generated/services/sigNoz.schemas';
import { Filter } from 'api/v5/v5';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export interface MetricsTableProps { export interface MetricsTableProps {
isLoading: boolean; isLoading: boolean;
@@ -14,36 +12,24 @@ export interface MetricsTableProps {
pageSize: number; pageSize: number;
currentPage: number; currentPage: number;
onPaginationChange: (page: number, pageSize: number) => void; onPaginationChange: (page: number, pageSize: number) => void;
setOrderBy: (orderBy: Querybuildertypesv5OrderByDTO) => void; setOrderBy: (orderBy: OrderByPayload) => void;
totalCount: number; totalCount: number;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void; openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
queryFilterExpression: Filter; queryFilters: TagFilter;
onFilterChange: (expression: string) => void;
} }
export interface MetricsSearchProps { export interface MetricsSearchProps {
query: IBuilderQuery; query: IBuilderQuery;
onChange: (expression: string) => void; onChange: (value: TagFilter) => void;
currentQueryFilterExpression: string;
setCurrentQueryFilterExpression: (expression: string) => void;
isLoading: boolean;
} }
export interface MetricsTreemapProps { export interface MetricsTreemapProps {
data: MetricsexplorertypesTreemapResponseDTO | undefined; data: MetricsTreeMapResponse | null | undefined;
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
viewType: MetricsexplorertypesTreemapModeDTO; viewType: TreemapViewType;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
setHeatmapView: (value: MetricsexplorertypesTreemapModeDTO) => void;
}
export interface MetricsTreemapInternalProps {
isLoading: boolean;
isError: boolean;
data: MetricsexplorertypesTreemapResponseDTO | undefined;
viewType: MetricsexplorertypesTreemapModeDTO;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void; openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
setHeatmapView: (value: TreemapViewType) => void;
} }
export interface OrderByPayload { export interface OrderByPayload {

View File

@@ -1,31 +1,39 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd'; import { Tooltip, Typography } from 'antd';
import { ColumnType } from 'antd/es/table'; import { ColumnType } from 'antd/es/table';
import { import {
MetricsexplorertypesStatDTO, MetricsListItemData,
MetricsexplorertypesTreemapEntryDTO, MetricsListPayload,
MetricsexplorertypesTreemapModeDTO, MetricType,
} from 'api/generated/services/sigNoz.schemas'; } from 'api/metricsExplorer/getMetricsList';
import { MetricsListPayload } from 'api/metricsExplorer/getMetricsList'; import {
import { Filter } from 'api/v5/v5'; SamplesData,
TimeseriesData,
} from 'api/metricsExplorer/getMetricsTreeMap';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils'; import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
} from 'lucide-react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { METRIC_TYPE_LABEL_MAP } from './constants';
import MetricNameSearch from './MetricNameSearch'; import MetricNameSearch from './MetricNameSearch';
import MetricTypeViewRenderer from './MetricTypeViewRenderer'; import MetricTypeSearch from './MetricTypeSearch';
import { MetricsListItemRowData, TreemapTile } from './types'; import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
export const getMetricsTableColumns = ( export const getMetricsTableColumns = (
queryFilterExpression: Filter, queryFilters: TagFilter,
onFilterChange: (expression: string) => void,
): ColumnType<MetricsListItemRowData>[] => [ ): ColumnType<MetricsListItemRowData>[] => [
{ {
title: ( title: (
<div className="metric-name-column-header"> <div className="metric-name-column-header">
<span className="metric-name-column-header-text">METRIC</span> <span className="metric-name-column-header-text">METRIC</span>
<MetricNameSearch <MetricNameSearch queryFilters={queryFilters} />
queryFilterExpression={queryFilterExpression}
onFilterChange={onFilterChange}
/>
</div> </div>
), ),
dataIndex: 'metric_name', dataIndex: 'metric_name',
@@ -47,10 +55,7 @@ export const getMetricsTableColumns = (
title: ( title: (
<div className="metric-type-column-header"> <div className="metric-type-column-header">
<span className="metric-type-column-header-text">TYPE</span> <span className="metric-type-column-header-text">TYPE</span>
{/* <MetricTypeSearch <MetricTypeSearch queryFilters={queryFilters} />
queryFilters={queryFilters}
onFilterChange={onFilterChange}
/> */}
</div> </div>
), ),
dataIndex: 'metric_type', dataIndex: 'metric_type',
@@ -64,13 +69,13 @@ export const getMetricsTableColumns = (
}, },
{ {
title: 'SAMPLES', title: 'SAMPLES',
dataIndex: MetricsexplorertypesTreemapModeDTO.samples, dataIndex: TreemapViewType.SAMPLES,
width: 150, width: 150,
sorter: true, sorter: true,
}, },
{ {
title: 'TIME SERIES', title: 'TIME SERIES',
dataIndex: MetricsexplorertypesTreemapModeDTO.timeseries, dataIndex: TreemapViewType.TIMESERIES,
width: 150, width: 150,
sorter: true, sorter: true,
}, },
@@ -84,6 +89,60 @@ export const getMetricsListQuery = (): MetricsListPayload => ({
orderBy: { columnName: 'metric_name', order: 'asc' }, orderBy: { columnName: 'metric_name', order: 'asc' },
}); });
export function MetricTypeRenderer({
type,
}: {
type: MetricType;
}): JSX.Element {
const [icon, color] = useMemo(() => {
switch (type) {
case MetricType.SUM:
return [
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
Color.BG_ROBIN_500,
];
case MetricType.GAUGE:
return [
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
Color.BG_SAKURA_500,
];
case MetricType.HISTOGRAM:
return [
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
Color.BG_SIENNA_500,
];
case MetricType.SUMMARY:
return [
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
Color.BG_FOREST_500,
];
case MetricType.EXPONENTIAL_HISTOGRAM:
return [
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
Color.BG_AQUA_500,
];
default:
return [null, ''];
}
}, [type]);
return (
<div
className="metric-type-renderer"
style={{
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
}}
>
{icon}
<Typography.Text style={{ color, fontSize: 12 }}>
{METRIC_TYPE_LABEL_MAP[type]}
</Typography.Text>
</div>
);
}
function ValidateRowValueWrapper({ function ValidateRowValueWrapper({
value, value,
children, children,
@@ -101,9 +160,6 @@ export const formatNumberIntoHumanReadableFormat = (
num: number, num: number,
addPlusSign = true, addPlusSign = true,
): string => { ): string => {
if (!num) {
return '-';
}
function format(num: number, divisor: number, suffix: string): string { function format(num: number, divisor: number, suffix: string): string {
const value = num / divisor; const value = num / divisor;
return value % 1 === 0 return value % 1 === 0
@@ -126,13 +182,13 @@ export const formatNumberIntoHumanReadableFormat = (
}; };
export const formatDataForMetricsTable = ( export const formatDataForMetricsTable = (
data: MetricsexplorertypesStatDTO[], data: MetricsListItemData[],
): MetricsListItemRowData[] => ): MetricsListItemRowData[] =>
data.map((metric) => ({ data.map((metric) => ({
key: metric.metricName, key: metric.metric_name,
metric_name: ( metric_name: (
<ValidateRowValueWrapper value={metric.metricName}> <ValidateRowValueWrapper value={metric.metric_name}>
<Tooltip title={metric.metricName}>{metric.metricName}</Tooltip> <Tooltip title={metric.metric_name}>{metric.metric_name}</Tooltip>
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
description: ( description: (
@@ -142,54 +198,39 @@ export const formatDataForMetricsTable = (
</Tooltip> </Tooltip>
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
metric_type: <MetricTypeViewRenderer type={metric.type} />, metric_type: <MetricTypeRenderer type={metric.type} />,
unit: ( unit: (
<ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}> <ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}>
{getUniversalNameFromMetricUnit(metric.unit)} {getUniversalNameFromMetricUnit(metric.unit)}
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
[MetricsexplorertypesTreemapModeDTO.samples]: ( [TreemapViewType.SAMPLES]: (
<ValidateRowValueWrapper <ValidateRowValueWrapper value={metric[TreemapViewType.SAMPLES]}>
value={metric[MetricsexplorertypesTreemapModeDTO.samples]} <Tooltip title={metric[TreemapViewType.SAMPLES].toLocaleString()}>
> {formatNumberIntoHumanReadableFormat(metric[TreemapViewType.SAMPLES])}
<Tooltip
title={metric[MetricsexplorertypesTreemapModeDTO.samples].toLocaleString()}
>
{formatNumberIntoHumanReadableFormat(
metric[MetricsexplorertypesTreemapModeDTO.samples],
)}
</Tooltip> </Tooltip>
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
[MetricsexplorertypesTreemapModeDTO.timeseries]: ( [TreemapViewType.TIMESERIES]: (
<ValidateRowValueWrapper <ValidateRowValueWrapper value={metric[TreemapViewType.TIMESERIES]}>
value={metric[MetricsexplorertypesTreemapModeDTO.timeseries]} <Tooltip title={metric[TreemapViewType.TIMESERIES].toLocaleString()}>
> {formatNumberIntoHumanReadableFormat(metric[TreemapViewType.TIMESERIES])}
<Tooltip
title={metric[
MetricsexplorertypesTreemapModeDTO.timeseries
].toLocaleString()}
>
{formatNumberIntoHumanReadableFormat(
metric[MetricsexplorertypesTreemapModeDTO.timeseries],
)}
</Tooltip> </Tooltip>
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
})); }));
export const transformTreemapData = ( export const transformTreemapData = (
data: MetricsexplorertypesTreemapEntryDTO[], data: TimeseriesData[] | SamplesData[],
viewType: MetricsexplorertypesTreemapModeDTO, viewType: TreemapViewType,
): TreemapTile[] => { ): TreemapTile[] => {
const totalSize = data.reduce( const totalSize = (data as (TimeseriesData | SamplesData)[]).reduce(
(acc: number, item: MetricsexplorertypesTreemapEntryDTO) => (acc: number, item: TimeseriesData | SamplesData) => acc + item.percentage,
acc + item.percentage,
0, 0,
); );
const children = data.map((item) => ({ const children = data.map((item) => ({
id: item.metricName, id: item.metric_name,
size: totalSize > 0 ? Number((item.percentage / totalSize).toFixed(2)) : 0, size: totalSize > 0 ? Number((item.percentage / totalSize).toFixed(2)) : 0,
displayValue: Number(item.percentage).toFixed(2), displayValue: Number(item.percentage).toFixed(2),
parent: viewType, parent: viewType,

View File

@@ -1,131 +0,0 @@
import { ColorPickerProps } from 'antd';
import { Color } from 'antd/es/color-picker';
import { render, screen, userEvent } from 'tests/test-utils';
import LegendColors from './LegendColors';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
__esModule: true,
useQueryBuilder: (): { currentQuery: unknown } => ({
currentQuery: {
builder: {
queryData: [
{
queryName: 'A',
legend: '{service.name}',
},
],
},
},
}),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
ColorPicker: ({ onChange, children }: ColorPickerProps): JSX.Element => (
<button
type="button"
data-testid="legend-color-picker"
onClick={(): void =>
onChange!({ toHexString: (): string => '#ffffff' } as Color, '#ffffff')
}
>
{children}
</button>
),
};
});
describe('LegendColors', () => {
it('renders legend colors panel and items', async () => {
const user = userEvent.setup();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={jest.fn()}
queryResponse={undefined}
/>,
);
expect(screen.getByText('Legend Colors')).toBeInTheDocument();
// Expand the collapse to reveal legend items
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
expect(screen.getByText('{service.name}')).toBeInTheDocument();
});
it('calls setCustomLegendColors when color is changed', async () => {
const user = userEvent.setup();
const setCustomLegendColors = jest.fn();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={setCustomLegendColors}
queryResponse={undefined}
/>,
);
// Expand to render the mocked ColorPicker button
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
const colorTrigger = screen.getByTestId('legend-color-picker');
await user.click(colorTrigger);
expect(setCustomLegendColors).toHaveBeenCalled();
});
it('throttles rapid color changes', async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const setCustomLegendColors = jest.fn();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={setCustomLegendColors}
queryResponse={undefined}
/>,
);
// Expand panel to render the mocked ColorPicker button
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
const colorTrigger = screen.getByTestId('legend-color-picker');
// Fire multiple rapid changes
await user.click(colorTrigger);
await user.click(colorTrigger);
await user.click(colorTrigger);
await user.click(colorTrigger);
// Flush pending throttled calls
jest.advanceTimersByTime(500);
// Throttling should ensure we don't invoke the setter once per click
expect(setCustomLegendColors).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
});

View File

@@ -14,7 +14,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { getLegend } from 'lib/dashboard/getQueryResults'; import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName'; import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor'; import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import throttle from 'lodash-es/throttle';
import { Palette } from 'lucide-react'; import { Palette } from 'lucide-react';
import { SuccessResponse } from 'types/api'; import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -96,24 +95,13 @@ function LegendColors({
); );
}; };
// Handle color change (throttled to avoid excessive updates) // Handle color change
const handleColorChange = useMemo( const handleColorChange = (label: string, color: string): void => {
() => setCustomLegendColors((prev) => ({
throttle((label: string, color: string): void => { ...prev,
setCustomLegendColors((prev) => ({ [label]: color,
...prev, }));
[label]: color, };
}));
}, 200), // 200ms is a good compromise between responsiveness and performance
[setCustomLegendColors],
);
// Clean up throttled handler on unmount
useEffect(() => {
return (): void => {
handleColorChange.cancel();
};
}, [handleColorChange]);
// Reset to default color // Reset to default color
const resetToDefault = (label: string): void => { const resetToDefault = (label: string): void => {

View File

@@ -169,10 +169,6 @@
font-weight: 400; font-weight: 400;
line-height: 16px; /* 133.333% */ line-height: 16px; /* 133.333% */
.ant-select {
width: 100%;
}
.ant-select-selector { .ant-select-selector {
border: none; border: none;
height: unset; height: unset;

View File

@@ -2,9 +2,6 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useDrag, useDrop, XYCoord } from 'react-dnd'; import { useDrag, useDrop, XYCoord } from 'react-dnd';
import { Button, Input, InputNumber, Select, Space, Typography } from 'antd'; import { Button, Input, InputNumber, Select, Space, Typography } from 'antd';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { Y_AXIS_UNIT_NAMES } from 'components/YAxisUnitSelector/constants';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { unitOptions } from 'container/NewWidget/utils'; import { unitOptions } from 'container/NewWidget/utils';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -207,18 +204,6 @@ function Threshold({
return unit !== 'none' && convertUnit(value, unit, toUnitId) === null; return unit !== 'none' && convertUnit(value, unit, toUnitId) === null;
}, [selectedGraph, yAxisUnit, tableSelectedOption, columnUnits, unit, value]); }, [selectedGraph, yAxisUnit, tableSelectedOption, columnUnits, unit, value]);
const unitSelectCategories = useMemo(() => {
return unitOptions(
selectedGraph === PANEL_TYPES.TABLE
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
: yAxisUnit || '',
);
}, [selectedGraph, yAxisUnit, tableSelectedOption, columnUnits]);
const unitLabel = useMemo(() => {
return Y_AXIS_UNIT_NAMES[unit as keyof typeof Y_AXIS_UNIT_NAMES];
}, [unit]);
return ( return (
<div <div
ref={allowDragAndDrop ? ref : null} ref={allowDragAndDrop ? ref : null}
@@ -328,17 +313,19 @@ function Threshold({
<ShowCaseValue value={value} className="unit-input" /> <ShowCaseValue value={value} className="unit-input" />
)} )}
{isEditMode ? ( {isEditMode ? (
<YAxisUnitSelector <Select
value={unit} defaultValue={unit}
options={unitOptions(
selectedGraph === PANEL_TYPES.TABLE
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
: yAxisUnit || '',
)}
onChange={handleUnitChange} onChange={handleUnitChange}
placeholder="Select unit" showSearch
source={YAxisSource.DASHBOARDS} className="unit-selection"
initialValue={unit}
categoriesOverride={unitSelectCategories}
containerClassName="unit-selection"
/> />
) : ( ) : (
<ShowCaseValue value={unitLabel} className="unit-selection-prev" /> <ShowCaseValue value={unit} className="unit-selection-prev" />
)} )}
</div> </div>
<div className="thresholds-color-selector"> <div className="thresholds-color-selector">
@@ -369,10 +356,7 @@ function Threshold({
)} )}
</div> </div>
{isInvalidUnitComparison && ( {isInvalidUnitComparison && (
<Typography.Text <Typography.Text className="invalid-unit">
className="invalid-unit"
data-testid="invalid-unit-comparison"
>
Threshold unit ({unit}) is not valid in comparison with the{' '} Threshold unit ({unit}) is not valid in comparison with the{' '}
{selectedGraph === PANEL_TYPES.TABLE ? 'column' : 'y-axis'} unit ( {selectedGraph === PANEL_TYPES.TABLE ? 'column' : 'y-axis'} unit (
{selectedGraph === PANEL_TYPES.TABLE {selectedGraph === PANEL_TYPES.TABLE

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