Compare commits

..

26 Commits

Author SHA1 Message Date
nityanandagohain
83cf760a01 chore: enchance clickhouse log_comment 2026-02-27 18:33:16 +05:30
Amlan Kumar Nandy
6e28f4dd84 chore: metrics explorer v2 api migration in summary page (#10337)
* chore: metrics explorer summary page api migration

* chore: search bar replacement

* chore: additional fixes

* chore: fix CI

* chore: additional performance fix

* chore: address comments

* chore: additional fixes

* chore: address comments

* chore: corresponding fix for generated api changes

* chore: additional changes

* chore: additional fixes

* chore: address comments

* chore: address comments
2026-02-27 09:27:56 +05:30
Srikanth Chekuri
43933f3a33 chore: move converter/formatter to pkg/units/... (#10408)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-26 23:52:58 +05:30
Naman Verma
d2e1a24b20 chore: choose latest seen unit for metrics instead of any unit (#10431) 2026-02-26 16:48:22 +00:00
Nikhil Mantri
887b3c7b3a chore: improve error messaging and UI edge cases in infra hosts monitoring (#10304) 2026-02-26 13:38:57 +00:00
Vinicius Lourenço
476fe7a29a perf(service-map): use react-force-graph-2d dep to reduce bundle size (#10191)
Co-authored-by: Yunus M <myounis.ar@live.com>
2026-02-26 10:10:19 -03:00
Ashwin Bhatkal
c1d38d86f1 chore: add nuqs and zustand to the repo (#10434) 2026-02-26 12:21:53 +00:00
Vinicius Lourenço
4f2594c31d perf(tooltip-value): cache intl number object (#9965) 2026-02-26 11:45:19 +00:00
Karan Balani
c9985b56bc feat: add org id support in root user config (#10418)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: add org id support in root user config

* chore: address review comments

* fix: use zero value uuid for org id in example.conf
2026-02-26 13:44:14 +05:30
Abhi kumar
f9868e2221 fix: thresholds working correctly with number panel (#10394)
* fix: fixed unit converstion support across thresholds and yaxisunit

* fix: thresholds working correctly with number panel

* fix: fixed tsc

* chore: fixed unit tests

* chore: reverted the extractnumberfromstring change

* chore: replaced select component with yaxisunitselector in threshold

* fix: fixed test

* chore: pr review fix

* chore: added test for yaxisunitselector
2026-02-26 07:42:29 +00:00
Amlan Kumar Nandy
72b0398eaf chore: metrics explorer v2 api migration in explorer section (#10111) 2026-02-26 06:57:41 +00:00
Abhi kumar
5b75a39777 chore: removed sentry instrumentation for querysearch (#10426) 2026-02-26 12:04:58 +05:30
Abhi kumar
6948b69012 fix: throttled legend color picker in dashboard + memory leak fix due to tooltip persistance (#10421)
* fix: creating tooltip plugin container only once

* chore: throttled legendcolor change

* fix: fixed tooltip plugin test
2026-02-26 11:28:20 +05:30
Amlan Kumar Nandy
bc9701397e chore: migrate metric details side drawer in metrics explorer to v2 APIs (#9995) 2026-02-26 04:18:33 +00:00
Naman Verma
396cf3194e feat: add support for count based aggregation in histogram metrics (#10355)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-25 17:03:06 +00:00
Abhi kumar
8be96a0ded fix: fetch the current version changelog instead of latest version (#10422) 2026-02-25 21:03:25 +05:30
primus-bot[bot]
82c54b1d36 chore(release): bump to v0.113.0 (#10420)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-02-25 18:37:05 +05:30
Ishan
39f5fb7290 feat: outside click bug fix (#10412)
* feat: outside click bug fix

* feat: added popover so removed data attr

* feat: close drawer on filter apply

* feat: old bug stop propogating to parent on settings click

* feat: removed extra logic for autofocus
2026-02-25 16:30:06 +05:30
Piyush Singariya
6ec2989e5c fix: replace promoted paths table (#10153)
* fix: replace promoted paths table

* fix: query args fix
2026-02-25 09:48:12 +00:00
Aditya Singh
016da679b9 Migrate QueryBuilderSearch in Logs Pipelines to v2 (#10387)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: function idon func added

* feat: test update

* fix: minor refactor

* feat: migrate to querybuilder search v2

* feat: fix failing test

* feat: minor change
2026-02-25 09:31:56 +00:00
Ashwin Bhatkal
ff028e366b fix: first query variable without 'ALL' selection (#10417)
* fix: first query variable without all

* chore: resolve self comments

* chore: added tests for getOptions logic
2026-02-25 08:36:39 +00:00
Ishan
c579614d56 feat: color fallback and red checks (#10389)
* feat: color fallback and red checks

* feat: testcase added
2026-02-25 11:54:22 +05:30
Ishan
78ba2ba356 feat: text selection block (#10373)
* feat: text selection block

* chore: added test file
2026-02-25 11:38:15 +05:30
Ishan
7fd4762e2a feat: ui bugs body width and table css (#10377)
* feat: ui bugs body width and table css

* feat: defualt open search overview

* feat: added timerRef to cleanup
2026-02-25 11:25:54 +05:30
Nageshbansal
4e4c9ce5af chore: enable metadataexporter in docker (#10409)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-25 03:13:27 +05:30
Srikanth Chekuri
7605775a38 chore: remove support for non v5 version in rules (#10406) 2026-02-24 23:16:21 +05:30
185 changed files with 7645 additions and 2853 deletions

View File

@@ -320,3 +320,4 @@ 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.112.1 image: signoz/signoz:v0.113.0
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.142.1 image: signoz/signoz-otel-collector:v0.144.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:${OTELCOL_TAG:-v0.142.0} image: signoz/signoz-otel-collector:v0.144.1
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.112.1 image: signoz/signoz:v0.113.0
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.142.1 image: signoz/signoz-otel-collector:v0.144.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:${OTELCOL_TAG:-v0.142.0} image: signoz/signoz-otel-collector:v0.144.1
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,6 +82,12 @@ 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:
@@ -93,19 +99,19 @@ service:
traces: traces:
receivers: [otlp] receivers: [otlp]
processors: [signozspanmetrics/delta, batch] processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces, signozmeter] exporters: [clickhousetraces, metadataexporter, signozmeter]
metrics: metrics:
receivers: [otlp] receivers: [otlp]
processors: [batch] processors: [batch]
exporters: [signozclickhousemetrics, signozmeter] exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
metrics/prometheus: metrics/prometheus:
receivers: [prometheus] receivers: [prometheus]
processors: [batch] processors: [batch]
exporters: [signozclickhousemetrics, signozmeter] exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
logs: logs:
receivers: [otlp] receivers: [otlp]
processors: [batch] processors: [batch]
exporters: [clickhouselogsexporter, signozmeter] exporters: [clickhouselogsexporter, metadataexporter, 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.112.1} image: signoz/signoz:${VERSION:-v0.113.0}
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.142.1} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.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.142.0} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
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.112.1} image: signoz/signoz:${VERSION:-v0.113.0}
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.142.1} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.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.142.0} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
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,6 +82,12 @@ 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:
@@ -93,19 +99,19 @@ service:
traces: traces:
receivers: [otlp] receivers: [otlp]
processors: [signozspanmetrics/delta, batch] processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces, signozmeter] exporters: [clickhousetraces, metadataexporter, signozmeter]
metrics: metrics:
receivers: [otlp] receivers: [otlp]
processors: [batch] processors: [batch]
exporters: [signozclickhousemetrics, signozmeter] exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
metrics/prometheus: metrics/prometheus:
receivers: [prometheus] receivers: [prometheus]
processors: [batch] processors: [batch]
exporters: [signozclickhousemetrics, signozmeter] exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
logs: logs:
receivers: [otlp] receivers: [otlp]
processors: [batch] processors: [batch]
exporters: [clickhouselogsexporter, signozmeter] exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
metrics/meter: metrics/meter:
receivers: [signozmeter] receivers: [signozmeter]
processors: [batch/meter] processors: [batch/meter]

View File

@@ -842,6 +842,17 @@ 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
@@ -1138,6 +1149,8 @@ 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/query-service/formatter" "github.com/SigNoz/signoz/pkg/units"
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 := formatter.FromUnit(r.Unit()) valueFormatter := units.FormatterFromUnit(r.Unit())
var res ruletypes.Vector var res ruletypes.Vector
var err error var err error

View File

@@ -117,6 +117,7 @@
"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",
@@ -130,7 +131,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": "^1.43.0", "react-force-graph-2d": "^1.29.1",
"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",
@@ -162,7 +163,8 @@
"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": [
@@ -287,4 +289,4 @@
"on-headers": "^1.1.0", "on-headers": "^1.1.0",
"tmp": "0.2.4" "tmp": "0.2.4"
} }
} }

View File

@@ -1006,6 +1006,18 @@ 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',
@@ -1367,6 +1379,7 @@ export interface Querybuildertypesv5LogAggregationDTO {
} }
export interface Querybuildertypesv5MetricAggregationDTO { export interface Querybuildertypesv5MetricAggregationDTO {
comparisonSpaceAggregationParam?: MetrictypesComparisonSpaceAggregationParamDTO;
/** /**
* @type string * @type string
*/ */

View File

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

View File

@@ -1,26 +0,0 @@
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,6 +248,11 @@ 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.
@@ -270,10 +275,7 @@ 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 = value.toLocaleString('en-US', { const numStr = intlNumberFormatter.format(value);
useGrouping: false,
maximumFractionDigits: 20,
});
const [integerPart, decimalPart = ''] = numStr.split('.'); const [integerPart, decimalPart = ''] = numStr.split('.');

View File

@@ -86,8 +86,13 @@ 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 explicitly ignored regions // Don't close if clicking on drawer content, overlays, or portal elements
if (target.closest('[data-log-detail-ignore="true"]')) { if (
target.closest('[data-log-detail-ignore="true"]') ||
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover')
) {
return; return;
} }
@@ -400,7 +405,11 @@ 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 title={removeEscapeCharacters(log?.body)} placement="left"> <Tooltip
title={removeEscapeCharacters(log?.body)}
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} /> <div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip> </Tooltip>
@@ -466,6 +475,7 @@ 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"
@@ -481,6 +491,7 @@ 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,7 +27,11 @@ 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 placement="top" content={popOverContent}> <Popover
overlayClassName="drawer-popover"
placement="top"
content={popOverContent}
>
{children} {children}
</Popover> </Popover>
</div> </div>

View File

@@ -32,6 +32,7 @@ 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: '60rem', maxWidth: '90rem',
}; };
export const defaultListViewPanelStyle: CSSProperties = { export const defaultListViewPanelStyle: CSSProperties = {

View File

@@ -11,7 +11,6 @@ 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';
@@ -86,6 +85,7 @@ 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,6 +96,7 @@ 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[]>([]);
@@ -252,7 +253,8 @@ 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;
@@ -301,6 +303,7 @@ function QuerySearch({
queryData.aggregateAttribute?.key, queryData.aggregateAttribute?.key,
signalSource, signalSource,
hardcodedAttributeKeys, hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
], ],
); );
@@ -564,15 +567,7 @@ 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((lastPos) => { setCursorPos(newPos);
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) {
@@ -1328,7 +1323,10 @@ function QuerySearch({
)} )}
<div className="query-where-clause-editor-container"> <div className="query-where-clause-editor-container">
<Tooltip title={getTooltipContent()} placement="left"> <Tooltip
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"
@@ -1562,6 +1560,7 @@ 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,6 +40,7 @@ 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);
@@ -87,7 +88,7 @@ function ValueGraph({
const { const {
threshold, threshold,
isConflictingThresholds, isConflictingThresholds,
} = getBackgroundColorAndThresholdCheck(thresholds, rawValue); } = getBackgroundColorAndThresholdCheck(thresholds, rawValue, yAxisUnit);
return ( return (
<div <div
@@ -155,6 +156,7 @@ 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,9 +1,10 @@
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; import { evaluateThresholdWithConvertedValue } from 'container/GridTableComponent/utils';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
function compareThreshold( function doesValueSatisfyThreshold(
rawValue: number, rawValue: number,
threshold: ThresholdProps, threshold: ThresholdProps,
yAxisUnit?: string,
): boolean { ): boolean {
if ( if (
threshold.thresholdOperator === undefined || threshold.thresholdOperator === undefined ||
@@ -11,31 +12,14 @@ function compareThreshold(
) { ) {
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;
}
}
function extractNumbersFromString(inputString: string): number[] { return evaluateThresholdWithConvertedValue(
const regex = /[+-]?\d+(\.\d+)?/g; rawValue,
const matches = inputString.match(regex); threshold.thresholdValue,
threshold.thresholdOperator,
if (matches) { threshold.thresholdUnit,
return matches.map(Number); yAxisUnit,
} );
return [];
} }
function getHighestPrecedenceThreshold( function getHighestPrecedenceThreshold(
@@ -60,21 +44,32 @@ 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) => {
compareThreshold( const numbers = extractNumbersFromString(rawValue.toString());
extractNumbersFromString( if (numbers.length === 0) {
getYAxisFormattedValue(rawValue.toString(), threshold.thresholdUnit || ''), return false;
)[0], }
threshold, return doesValueSatisfyThreshold(numbers[0], threshold, yAxisUnit);
), });
);
if (matchingThresholds.length === 0) { if (matchingThresholds.length === 0) {
return { return {

View File

@@ -22,6 +22,8 @@ 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);
@@ -66,10 +68,14 @@ function YAxisUnitSelector({
return aliases.some((alias) => alias.toLowerCase().includes(search)); return aliases.some((alias) => alias.toLowerCase().includes(search));
}; };
const categories = getYAxisCategories(source); const categoriesToRender = useMemo(() => {
return categoriesOverride || getYAxisCategories(source);
}, [categoriesOverride, source]);
return ( return (
<div className="y-axis-unit-selector-component"> <div
className={classNames('y-axis-unit-selector-component', containerClassName)}
>
<Select <Select
showSearch showSearch
value={universalUnit} value={universalUnit}
@@ -90,7 +96,7 @@ function YAxisUnitSelector({
data-testid={dataTestId} data-testid={dataTestId}
allowClear allowClear
> >
{categories.map((category) => ( {categoriesToRender.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,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { YAxisSource } from '../types'; import { YAxisCategoryNames } from '../constants';
import { UniversalYAxisUnit, YAxisSource } from '../types';
import YAxisUnitSelector from '../YAxisUnitSelector'; import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => { describe('YAxisUnitSelector', () => {
@@ -123,4 +124,34 @@ 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,6 +9,8 @@ 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 { latestVersion } = useSelector<AppState, AppReducer>( const { currentVersion } = 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(latestVersion, changelogForTenant), getChangelogByVersion(currentVersion, changelogForTenant),
queryKey: ['getChangelogByVersion', latestVersion, changelogForTenant], queryKey: ['getChangelogByVersion', currentVersion, changelogForTenant],
enabled: isLoggedIn && Boolean(latestVersion), enabled: isLoggedIn && Boolean(currentVersion),
}, },
]); ]);
@@ -226,7 +226,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
!changelog && !changelog &&
!getChangelogByVersionResponse.isLoading && !getChangelogByVersionResponse.isLoading &&
isLoggedIn && isLoggedIn &&
Boolean(latestVersion) Boolean(currentVersion)
) { ) {
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(latestVersion) && Boolean(currentVersion) &&
seenChangelogVersion != null && seenChangelogVersion != null &&
latestVersion !== seenChangelogVersion && currentVersion !== 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,
latestVersion, currentVersion,
seenChangelogVersion, seenChangelogVersion,
toggleChangelogModal, toggleChangelogModal,
isWorkspaceAccessRestricted, isWorkspaceAccessRestricted,

View File

@@ -9,6 +9,7 @@ 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,
@@ -31,6 +32,9 @@ 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,
); );
@@ -96,6 +100,28 @@ 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 };
@@ -130,10 +156,12 @@ 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, isString } from 'lodash-es'; import { isArray, isEmpty } 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,
applyDefaultIfNeeded, getDefaultValue,
} = useDashboardVariableSelectHelper({ } = useDashboardVariableSelectHelper({
variableData, variableData,
optionsData, optionsData,
@@ -68,81 +68,93 @@ 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)
) { ) {
const newOptionsData = sortValues( return;
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;
}
const oldOptionsData = sortValues(optionsData, variableData.sort) as never; // If multi-select with ALL option enabled, and ALL is currently selected, we want to maintain that state and select all new options
// 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);
if (!areArraysEqual(newOptionsData, oldOptionsData)) { // Update tempSelection to maintain ALL state when dropdown is open
let valueNotInList = false; if (tempSelection !== undefined) {
setTempSelection(sortedNewOptions.map((option) => option.toString()));
}
return;
}
if (isArray(variableData.selectedValue)) { const value = variableData.selectedValue;
variableData.selectedValue.forEach((val) => { let allSelected = false;
if (!newOptionsData.includes(val)) {
valueNotInList = true;
}
});
} else if (
isString(variableData.selectedValue) &&
!newOptionsData.includes(variableData.selectedValue)
) {
valueNotInList = true;
}
if (variableData.name && (valueNotInList || variableData.allSelected)) { if (variableData.multiSelect) {
if ( const { selectedValue } = variableData;
variableData.allSelected && allSelected =
variableData.multiSelect && sortedNewOptions.length > 0 &&
variableData.showALLOption Array.isArray(selectedValue) &&
) { sortedNewOptions.every((option) => selectedValue.includes(option));
if ( }
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(
variableData.name,
variableData.id,
newOptionsData,
true,
);
}
// Update tempSelection to maintain ALL state when dropdown is open if (
if (tempSelection !== undefined) { variableData.name &&
setTempSelection(newOptionsData.map((option) => option.toString())); variableData.id &&
} !isEmpty(variableData.selectedValue)
} else { ) {
const value = variableData.selectedValue; onValueUpdate(variableData.name, variableData.id, value, allSelected);
let allSelected = false; } else {
const defaultValue = getDefaultValue(sortedNewOptions);
if (variableData.multiSelect) { if (defaultValue !== undefined) {
const { selectedValue } = variableData; onValueUpdate(
allSelected = variableData.name,
newOptionsData.length > 0 && variableData.id,
Array.isArray(selectedValue) && defaultValue,
newOptionsData.every((option) => selectedValue.includes(option)); allSelected,
} );
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) {
@@ -155,7 +167,7 @@ function QueryVariableInput({
onValueUpdate, onValueUpdate,
tempSelection, tempSelection,
setTempSelection, setTempSelection,
applyDefaultIfNeeded, getDefaultValue,
], ],
); );

View File

@@ -1,5 +1,6 @@
/* 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,
@@ -10,6 +11,7 @@ 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';
@@ -17,6 +19,17 @@ 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();
@@ -56,10 +69,14 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }), useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
})); }));
// Mock VariableItem to avoid rendering complexity // VariableItem mock captures the onValueUpdate prop for use in onValueUpdate tests
jest.mock('../VariableItem', () => ({ jest.mock('../VariableItem', () => ({
__esModule: true, __esModule: true,
default: (): JSX.Element => <div data-testid="variable-item" />, // eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any): JSX.Element => {
mockVariableItemCallbacks.onValueUpdate = props.onValueUpdate;
return <div data-testid="variable-item" />;
},
})); }));
function createVariable( function createVariable(
@@ -200,4 +217,162 @@ 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

@@ -0,0 +1,275 @@
/* 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,6 +46,9 @@ 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
@@ -248,5 +251,6 @@ 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.
*/ */
function evaluateThresholdWithConvertedValue( export function evaluateThresholdWithConvertedValue(
value: number, value: number,
thresholdValue: number, thresholdValue: number,
thresholdOperator?: string, thresholdOperator?: string,

View File

@@ -99,6 +99,7 @@ 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 { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { LoadingOutlined } from '@ant-design/icons'; import { LoadingOutlined } from '@ant-design/icons';
import { import {
Skeleton, Skeleton,
@@ -14,12 +14,93 @@ 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,
@@ -46,6 +127,11 @@ 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],
@@ -84,12 +170,6 @@ export default function HostsListTable({
}); });
}; };
const showNoFilteredHostsMessage =
!isFetching &&
!isLoading &&
formattedHostMetricsData.length === 0 &&
filters.items.length > 0;
const showHostsEmptyState = const showHostsEmptyState =
!isFetching && !isFetching &&
!isLoading && !isLoading &&
@@ -97,63 +177,36 @@ 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;
if (isError) { const emptyOrLoadingView = EmptyOrLoadingView({
return <Typography>{data?.error || 'Something went wrong'}</Typography>; isError,
} errorMessage: data?.error ?? '',
showHostsEmptyState,
sentAnyHostMetricsData,
isSendingIncorrectK8SAgentMetrics,
showEndTimeBeforeRetentionMessage,
showNoRecordsInSelectedTimeRangeMessage,
showTableLoadingState,
});
if (showHostsEmptyState) { if (emptyOrLoadingView) {
return ( return <>{emptyOrLoadingView}</>;
<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,12 +1,16 @@
/* 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';
describe('HostsListTable', () => { const createMockHost = (): HostData =>
const mockHost = { ({
hostName: 'test-host-1', hostName: 'test-host-1',
active: true, active: true,
cpu: 0.75, cpu: 0.75,
@@ -14,20 +18,46 @@ describe('HostsListTable', () => {
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 mockTableData = { const createMockTableData = (
overrides: Partial<HostListResponse['data']> = {},
): SuccessResponse<HostListResponse> => {
const mockHost = createMockHost();
return {
statusCode: 200,
message: 'Success',
error: null,
payload: { payload: {
status: 'success',
data: { data: {
hosts: [mockHost], type: 'list',
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,
@@ -43,7 +73,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(
@@ -51,7 +81,7 @@ describe('HostsListTable', () => {
{...mockProps} {...mockProps}
isLoading isLoading
hostMetricsData={[]} hostMetricsData={[]}
tableData={{ payload: { data: { hosts: [] } } }} tableData={createMockTableData({ records: [] })}
/>, />,
); );
expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy(); expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy();
@@ -63,7 +93,7 @@ describe('HostsListTable', () => {
{...mockProps} {...mockProps}
isFetching isFetching
hostMetricsData={[]} hostMetricsData={[]}
tableData={{ payload: { data: { hosts: [] } } }} tableData={createMockTableData({ records: [] })}
/>, />,
); );
expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy(); expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy();
@@ -74,19 +104,56 @@ 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={{ tableData={createMockTableData({
payload: { records: [],
data: { hosts: [] }, })}
},
}}
/>, />,
); );
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy(); expect(
container.querySelector('.no-filtered-hosts-message-container'),
).toBeTruthy();
}); });
it('renders empty state if sentAnyHostMetricsData is false', () => { it('renders empty state if sentAnyHostMetricsData is false', () => {
@@ -94,58 +161,114 @@ describe('HostsListTable', () => {
<HostsListTable <HostsListTable
{...mockProps} {...mockProps}
hostMetricsData={[]} hostMetricsData={[]}
tableData={{ tableData={createMockTableData({
...mockTableData, sentAnyHostMetricsData: false,
payload: { records: [],
...mockTableData.payload, })}
data: {
...mockTableData.payload.data,
sentAnyHostMetricsData: false,
hosts: [],
},
},
}}
/>, />,
); );
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy(); expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
}); });
it('renders empty state if isSendingIncorrectK8SAgentMetrics is true', () => { it('renders empty state if isSendingK8SAgentMetrics is true', () => {
const { container } = render( const { container } = render(
<HostsListTable <HostsListTable
{...mockProps} {...mockProps}
hostMetricsData={[]} hostMetricsData={[]}
tableData={{ tableData={createMockTableData({
...mockTableData, isSendingK8SAgentMetrics: true,
payload: { records: [],
...mockTableData.payload, })}
data: {
...mockTableData.payload.data,
isSendingIncorrectK8SAgentMetrics: true,
hosts: [],
},
},
}}
/>, />,
); );
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy(); 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(
screen.getByText(
/No host metrics in the selected time range and filters\. Please adjust your time range or filters\./,
),
).toBeInTheDocument();
});
it('renders table data', () => { it('renders table data', () => {
const { container } = render( const { container } = render(
<HostsListTable <HostsListTable
{...mockProps} {...mockProps}
tableData={{ tableData={createMockTableData({
...mockTableData, isSendingK8SAgentMetrics: false,
payload: { sentAnyHostMetricsData: true,
...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,6 +107,17 @@ 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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -103,10 +103,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,10 +105,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -106,10 +106,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -101,10 +101,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
return []; return [];
}); });

View File

@@ -10,7 +10,6 @@ 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';
@@ -59,10 +58,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['filters']>(urlFilters); const decoded = decodeURIComponent(urlFilters);
if (parsed) { const parsed = JSON.parse(decoded);
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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -104,10 +104,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -99,10 +99,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -92,10 +92,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,10 +105,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
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, safeParseJSON } from '../commonUtils'; import { getOrderByFromParams } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,10 +105,9 @@ 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 parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy); const decoded = decodeURIComponent(groupBy);
if (parsed) { const parsed = JSON.parse(decoded);
return parsed; return parsed as IBuilderQuery['groupBy'];
}
} }
return []; return [];
}); });

View File

@@ -5,7 +5,6 @@
/* 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';
@@ -261,19 +260,6 @@ 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,
@@ -285,12 +271,9 @@ export const getOrderByFromParams = (
INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY, INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
); );
if (orderByFromParams) { if (orderByFromParams) {
const parsed = safeParseJSON<{ columnName: string; order: 'asc' | 'desc' }>( const decoded = decodeURIComponent(orderByFromParams);
orderByFromParams, const parsed = JSON.parse(decoded);
); return parsed as { columnName: string; order: 'asc' | 'desc' };
if (parsed) {
return parsed;
}
} }
if (returnNullAsDefault) { if (returnNullAsDefault) {
return null; return null;
@@ -304,7 +287,13 @@ export const getFiltersFromParams = (
): IBuilderQuery['filters'] | null => { ): IBuilderQuery['filters'] | null => {
const filtersFromParams = searchParams.get(queryKey); const filtersFromParams = searchParams.get(queryKey);
if (filtersFromParams) { if (filtersFromParams) {
return safeParseJSON<IBuilderQuery['filters']>(filtersFromParams); try {
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,9 +121,23 @@ function BodyTitleRenderer({
return ( return (
<TitleWrapper onClick={handleNodeClick}> <TitleWrapper onClick={handleNodeClick}>
{typeof value !== 'object' && ( {typeof value !== 'object' && (
<Dropdown menu={menu} trigger={['click']}> <span
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" /> onClick={(e): void => {
</Dropdown> e.stopPropagation();
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}> <Tooltip placement="left" title={newField} mouseLeaveDelay={0}>
<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>(false); const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
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"> <Tooltip title="Inspect in Trace" mouseLeaveDelay={0}>
<Button <Button
className="periscope-btn" className="periscope-btn"
onClick={( onClick={(

View File

@@ -0,0 +1,34 @@
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,7 +1,16 @@
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,
@@ -67,8 +76,13 @@ export function getColorsForSeverityLabels(
label: string, label: string,
index: number, index: number,
): string { ): string {
// Check if we have a direct mapping for this severity variant const trimmed = label.trim();
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;
} }
@@ -103,5 +117,8 @@ export function getColorsForSeverityLabels(
return Color.BG_SAKURA_500; return Color.BG_SAKURA_500;
} }
return colors[index % colors.length] || themeColors.red; return (
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
Color.BG_SLATE_400
);
} }

View File

@@ -111,23 +111,19 @@ 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 => (
return ( <TableRow
<div key={log.id as string}> tableColumns={tableColumns}
<TableRow index={index}
tableColumns={tableColumns} log={log}
index={index} logs={tableViewProps.logs}
log={log} hasActions
logs={tableViewProps.logs} fontSize={tableViewProps.fontSize}
hasActions onShowLogDetails={onSetActiveLog}
fontSize={tableViewProps.fontSize} isActiveLog={activeLog?.id === log.id}
onShowLogDetails={onSetActiveLog} onClearActiveLog={onCloseActiveLog}
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 && ( {isMetricDetailsOpen && selectedMetricName && (
<MetricDetails <MetricDetails
metricName={selectedMetricName} metricName={selectedMetricName}
isOpen={isMetricDetailsOpen} isOpen={isMetricDetailsOpen}

View File

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

View File

@@ -1,8 +1,13 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQueries } from 'react-query'; import { useQueries, useQueryClient } 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 { Tooltip, Typography } from 'antd'; import { toast } from '@signozhq/sonner';
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';
@@ -23,7 +28,10 @@ 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 { splitQueryIntoOneChartPerQuery } from './utils'; import {
buildUpdateMetricYAxisUnitPayload,
splitQueryIntoOneChartPerQuery,
} from './utils';
function TimeSeries({ function TimeSeries({
showOneChartPerQuery, showOneChartPerQuery,
@@ -35,6 +43,7 @@ function TimeSeries({
yAxisUnit, yAxisUnit,
setYAxisUnit, setYAxisUnit,
showYAxisUnitSelector, showYAxisUnitSelector,
metrics,
}: TimeSeriesProps): JSX.Element { }: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder(); const { stagedQuery, currentQuery } = useQueryBuilder();
@@ -42,6 +51,7 @@ 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[] = [];
@@ -138,54 +148,51 @@ 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 => {
// updateMetricMetadata( if (metrics[0] && yAxisUnit) {
// { updateMetricMetadata(
// metricName: metricNames[0], {
// payload: { pathParams: {
// unit: yAxisUnit, metricName: metricNames[0],
// description: metrics[0]?.description ?? '', },
// metricType: metrics[0]?.type as MetricType, data: buildUpdateMetricYAxisUnitPayload(
// temporality: metrics[0]?.temporality, metricNames[0],
// }, metrics[0],
// }, yAxisUnit,
// { ),
// onSuccess: () => { },
// notifications.success({ {
// message: 'Unit saved successfully', onSuccess: () => {
// }); toast.success('Unit saved successfully');
// queryClient.invalidateQueries([ invalidateGetMetricMetadata(queryClient, {
// REACT_QUERY_KEY.GET_METRIC_DETAILS, metricName: metricNames[0],
// metricNames[0], });
// ]); },
// }, onError: () => {
// onError: () => { toast.error('Failed to save unit');
// notifications.error({ },
// message: 'Failed to save unit', },
// }); );
// }, }
// }, };
// );
// };
return ( return (
<> <>
@@ -198,8 +205,7 @@ function TimeSeries({
source={YAxisSource.EXPLORER} source={YAxisSource.EXPLORER}
data-testid="y-axis-unit-selector" data-testid="y-axis-unit-selector"
/> />
{/* TODO: Enable once we have resolved all related metrics v2 api issues */} {showSaveUnitButton && (
{/* {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?
@@ -213,7 +219,7 @@ function TimeSeries({
<Typography.Paragraph>Yes</Typography.Paragraph> <Typography.Paragraph>Yes</Typography.Paragraph>
</Button> </Button>
</div> </div>
)} */} )}
</> </>
)} )}
</div> </div>

View File

@@ -3,8 +3,10 @@ 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 { Temporality } from 'api/metricsExplorer/getMetricDetails'; import {
import { MetricType } from 'api/metricsExplorer/getMetricsList'; MetrictypesTemporalityDTO,
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';
@@ -14,12 +16,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();
@@ -135,14 +137,6 @@ 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}>
@@ -190,7 +184,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [mockMetric, mockMetric], metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
}); });
renderExplorer(); renderExplorer();
@@ -207,7 +201,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [mockMetric, mockMetric], metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
}); });
renderExplorer(); renderExplorer();
@@ -220,7 +214,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [mockMetric], metrics: [MOCK_METRIC_METADATA],
}); });
renderExplorer(); renderExplorer();
@@ -237,7 +231,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [mockMetric, mockMetric], metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
}); });
renderExplorer(); renderExplorer();
@@ -250,7 +244,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [mockMetric, mockMetric], metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
}); });
renderExplorer(); renderExplorer();
@@ -269,10 +263,10 @@ describe('Explorer', () => {
isError: false, isError: false,
metrics: [ metrics: [
{ {
type: MetricType.SUM, type: MetrictypesTypeDTO.sum,
description: 'metric1 description', description: 'metric1 description',
unit: '', unit: '',
temporality: Temporality.CUMULATIVE, temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true, isMonotonic: true,
}, },
], ],
@@ -289,7 +283,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [mockMetric], metrics: [MOCK_METRIC_METADATA],
}); });
renderExplorer(); renderExplorer();
@@ -324,7 +318,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({ jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
metrics: [mockMetric, mockMetric], metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
}); });
renderExplorer(); renderExplorer();

View File

@@ -1,29 +1,19 @@
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 { Temporality } from 'api/metricsExplorer/getMetricDetails'; import * as metricsExplorerHooks from 'api/generated/services/metrics';
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';
type MockUpdateMetricMetadata = UseMutationResult<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
>;
const mockUpdateMetricMetadata = jest.fn(); const mockUpdateMetricMetadata = jest.fn();
jest const updateMetricMetadataSpy = jest.spyOn(
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata') metricsExplorerHooks,
.mockReturnValue(({ 'useUpdateMetricMetadata',
mutate: mockUpdateMetricMetadata, );
isLoading: false, type UseUpdateMetricMetadataReturnType = ReturnType<
} as Partial<MockUpdateMetricMetadata>) as MockUpdateMetricMetadata); typeof metricsExplorerHooks.useUpdateMetricMetadata
>;
jest.mock('container/TimeSeriesView/TimeSeriesView', () => ({ jest.mock('container/TimeSeriesView/TimeSeriesView', () => ({
__esModule: true, __esModule: true,
@@ -60,14 +50,6 @@ 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();
@@ -96,6 +78,13 @@ 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({
@@ -118,7 +107,7 @@ describe('TimeSeries', () => {
const { container } = renderTimeSeries({ const { container } = renderTimeSeries({
metricUnits: ['', 'count'], metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'], metricNames: ['metric1', 'metric2'],
metrics: [mockMetric, mockMetric], metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
yAxisUnit: 'seconds', yAxisUnit: 'seconds',
}); });
@@ -133,18 +122,17 @@ describe('TimeSeries', () => {
); );
}); });
// TODO: Unskip this test once the save unit button is implemented it('shows Save unit button when metric had no unit but one is selected', async () => {
// 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: [mockMetric], metrics: [MOCK_METRIC_METADATA],
yAxisUnit: 'seconds', yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
}); });
expect( expect(
findByText('Save the selected unit for this metric?'), await findByText('Save the selected unit for this metric?'),
).toBeInTheDocument(); ).toBeInTheDocument();
const yesButton = getByRole('button', { name: 'Yes' }); const yesButton = getByRole('button', { name: 'Yes' });
@@ -152,24 +140,25 @@ describe('TimeSeries', () => {
expect(yesButton).toBeEnabled(); expect(yesButton).toBeEnabled();
}); });
// TODO: Unskip this test once the save unit button is implemented it('clicking on save unit button shoould upated metric metadata', async () => {
// 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: [mockMetric], metrics: [MOCK_METRIC_METADATA],
yAxisUnit: 'seconds', yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
}); });
const yesButton = getByRole('button', { name: /Yes/i }); const yesButton = getByRole('button', { name: /Yes/i });
user.click(yesButton); await user.click(yesButton);
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith( expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
{ {
metricName: 'metric1', pathParams: {
payload: expect.objectContaining({ unit: 'seconds' }), metricName: 'metric1',
},
data: expect.objectContaining({ unit: 'seconds' }),
}, },
expect.objectContaining({ expect.objectContaining({
onSuccess: expect.any(Function), onSuccess: expect.any(Function),

View File

@@ -0,0 +1,13 @@
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,14 +1,8 @@
import { UseQueryResult } from 'react-query'; import { UseQueryResult } from 'react-query';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails'; import { GetMetricMetadata200 } from 'api/generated/services/sigNoz.schemas';
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,
@@ -22,6 +16,7 @@ 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],
@@ -91,32 +86,19 @@ 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: {
httpStatusCode: 200, data: MOCK_METRIC_METADATA,
data: { status: 'success',
status: 'success',
data: MOCK_METRIC_METADATA,
},
}, },
} as Partial< } as UseQueryResult<GetMetricMetadata200, Error>,
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
]); ]);
}); });
@@ -133,12 +115,11 @@ describe('useGetMetrics', () => {
jest jest
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics') .spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
.mockReturnValue([ .mockReturnValue([
({ {
isLoading: true, isLoading: true,
isError: false, isError: false,
} as Partial< data: undefined,
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error> } as UseQueryResult<GetMetricMetadata200, 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: (MetricMetadata | undefined)[]; metrics: (MetricsexplorertypesMetricMetadataDTO | 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,9 +1,12 @@
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
@@ -68,16 +71,14 @@ export function useGetMetrics(
): { ): {
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
metrics: (MetricMetadata | undefined)[]; metrics: (MetricsexplorertypesMetricMetadataDTO | 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 metrics: metricsData.map((metric) => metric.data?.data),
.map((metric) => metric.data?.data)
.map((data) => data?.data),
isError: metricsData.some((metric) => metric.isError), isError: metricsData.some((metric) => metric.isError),
}; };
} }
@@ -89,9 +90,24 @@ 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: (MetricMetadata | undefined)[], metrics: (MetricsexplorertypesMetricMetadataDTO | 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,8 +40,10 @@ 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(metricType: MetricType | undefined): boolean { export function isInspectEnabled(
return metricType === MetricType.GAUGE; metricType: MetrictypesTypeDTO | undefined,
): boolean {
return metricType === MetrictypesTypeDTO.gauge;
} }
export function getAllTimestampsOfMetrics( export function getAllTimestampsOfMetrics(

View File

@@ -1,8 +1,17 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use'; import { useCopyToClipboard } from 'react-use';
import { Button, Collapse, Input, Menu, Popover, Typography } from 'antd'; import {
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';
@@ -12,9 +21,33 @@ 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 { AllAttributesProps, AllAttributesValueProps } from './types'; import MetricDetailsErrorState from './MetricDetailsErrorState';
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,
@@ -110,13 +143,23 @@ 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 | string[]>( const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
'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();
@@ -178,7 +221,7 @@ function AllAttributes({
attributes.filter( attributes.filter(
(attribute) => (attribute) =>
attribute.key.toLowerCase().includes(searchString.toLowerCase()) || attribute.key.toLowerCase().includes(searchString.toLowerCase()) ||
attribute.value.some((value) => attribute.values?.some((value) =>
value.toLowerCase().includes(searchString.toLowerCase()), value.toLowerCase().includes(searchString.toLowerCase()),
), ),
), ),
@@ -195,7 +238,7 @@ function AllAttributes({
}, },
value: { value: {
key: attribute.key, key: attribute.key,
value: attribute.value, value: attribute.values,
}, },
})) }))
: [], : [],
@@ -270,6 +313,7 @@ function AllAttributes({
onClick={(e): void => { onClick={(e): void => {
e.stopPropagation(); e.stopPropagation();
}} }}
disabled={isLoadingAttributes || isErrorAttributes}
/> />
</div> </div>
), ),
@@ -277,25 +321,49 @@ 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 metrics-metadata-accordion" className="metrics-accordion"
activeKey={activeKey} activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys)} onChange={(keys): void => setActiveKey(keys as string[])}
items={items} items={items}
/> />
); );

View File

@@ -2,36 +2,84 @@ 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({
alerts, metricName,
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.alert_id, key: alert.alertId,
label: ( label: (
<Typography.Link <Typography.Link
key={alert.alert_id} key={alert.alertId}
onClick={(): void => { onClick={(): void => {
params.set(QueryParams.ruleId, alert.alert_id); params.set(QueryParams.ruleId, alert.alertId);
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.alert_name || alert.alert_id} {alert.alertName || alert.alertId}
</Typography.Link> </Typography.Link>
), ),
})); }));
@@ -39,41 +87,44 @@ 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 (uniqueDashboards && uniqueDashboards.length > 0) { if (dashboards && dashboards.length > 0) {
return uniqueDashboards.map((dashboard) => ({ return dashboards.map((dashboard) => ({
key: dashboard.dashboard_id, key: dashboard.dashboardId,
label: ( label: (
<Typography.Link <Typography.Link
key={dashboard.dashboard_id} key={dashboard.dashboardId}
onClick={(): void => { onClick={(): void => {
safeNavigate( safeNavigate(
generatePath(ROUTES.DASHBOARD, { generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboard_id, dashboardId: dashboard.dashboardId,
}), }),
); );
}} }}
className="dashboards-popover-content-item" className="dashboards-popover-content-item"
> >
{dashboard.dashboard_name || dashboard.dashboard_id} {dashboard.dashboardName || dashboard.dashboardId}
</Typography.Link> </Typography.Link>
), ),
})); }));
} }
return null; return null;
}, [uniqueDashboards, safeNavigate]); }, [dashboards, safeNavigate]);
if (!dashboardsPopoverContent && !alertsPopoverContent) { if (isLoadingAlerts || isLoadingDashboards) {
return null; return (
<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 (
@@ -92,8 +143,7 @@ function DashboardsAndAlertsPopover({
> >
<Grid size={12} color={Color.BG_SIENNA_500} /> <Grid size={12} color={Color.BG_SIENNA_500} />
<Typography.Text> <Typography.Text>
{uniqueDashboards?.length} dashboard {pluralize(dashboards.length, 'dashboard')}
{uniqueDashboards?.length === 1 ? '' : 's'}
</Typography.Text> </Typography.Text>
</div> </div>
</Dropdown> </Dropdown>
@@ -112,7 +162,7 @@ function DashboardsAndAlertsPopover({
> >
<Bell size={12} color={Color.BG_SAKURA_500} /> <Bell size={12} color={Color.BG_SAKURA_500} />
<Typography.Text> <Typography.Text>
{alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'} {pluralize(alerts.length, 'alert rule')}
</Typography.Text> </Typography.Text>
</div> </div>
</Dropdown> </Dropdown>

View File

@@ -0,0 +1,123 @@
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,45 +1,58 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query'; import { useQueryClient } from 'react-query';
import { Button, Collapse, Input, Select, Typography } from 'antd'; import { Button, Collapse, Input, Select, 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 { Temporality } from 'api/metricsExplorer/getMetricDetails'; import {
import { MetricType } from 'api/metricsExplorer/getMetricsList'; invalidateGetMetricMetadata,
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata'; invalidateListMetrics,
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_TYPE_LABEL_MAP, METRIC_METADATA_KEYS,
METRIC_TYPE_VALUES_MAP, METRIC_METADATA_TEMPORALITY_OPTIONS,
} from '../Summary/constants'; METRIC_METADATA_TYPE_OPTIONS,
import { MetricTypeRenderer } from '../Summary/utils'; METRIC_METADATA_UPDATE_ERROR_MESSAGE,
import { METRIC_METADATA_KEYS } from './constants'; } from './constants';
import { MetadataProps } from './types'; import MetricDetailsErrorState from './MetricDetailsErrorState';
import { determineIsMonotonic } from './utils'; import { MetadataProps, MetricMetadataFormState, TableFields } from './types';
import { transformUpdateMetricMetadataRequest } from './utils';
function Metadata({ function Metadata({
metricName, metricName,
metadata, metadata,
refetchMetricDetails, isErrorMetricMetadata,
isLoadingMetricMetadata,
refetchMetricMetadata,
}: MetadataProps): JSX.Element { }: MetadataProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [ const [
metricMetadata, metricMetadataState,
setMetricMetadata, setMetricMetadataState,
] = useState<UpdateMetricMetadataProps>({ ] = useState<MetricMetadataFormState>({
metricType: metadata?.metric_type || MetricType.SUM, type: MetrictypesTypeDTO.sum,
description: metadata?.description || '', description: '',
temporality: metadata?.temporality, temporality: MetrictypesTemporalityDTO.unspecified,
unit: metadata?.unit, unit: '',
isMonotonic: false,
}); });
const { notifications } = useNotifications(); const { notifications } = useNotifications();
const { const {
@@ -51,110 +64,135 @@ 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({ ? Object.keys(metadata).map((key) => ({
...metadata, key,
temporality: metadata?.temporality, value: {
}) 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((key: string, value: string) => { const renderUneditableField = useCallback(
if (key === 'metric_type') { (key: keyof MetricMetadataFormState, value: string) => {
return <MetricTypeRenderer type={value as MetricType} />; if (isErrorMetricMetadata) {
} return <FieldRenderer field="-" />;
let fieldValue = value; }
if (key === 'unit') { if (key === TableFields.TYPE) {
fieldValue = getUniversalNameFromMetricUnit(value); return <MetricTypeRendererV2 type={value as MetrictypesTypeDTO} />;
} }
return <FieldRenderer field={fieldValue || '-'} />; if (key === TableFields.IS_MONOTONIC) {
}, []); 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: { value: string; key: string }): JSX.Element => { (field: {
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 = field.key === 'unit' && Boolean(metadata?.unit); const metricUnitAlreadySet =
field.key === TableFields.UNIT && Boolean(metadata?.unit);
if (metricUnitAlreadySet) { if (metricUnitAlreadySet) {
return renderUneditableField(field.key, field.value); return renderUneditableField(field.key, field.value);
} }
if (field.key === 'metric_type') { // Monotonic is not editable
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={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({ options={METRIC_METADATA_TYPE_OPTIONS}
value: key, value={metricMetadataState.type}
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
}))}
value={metricMetadata.metricType}
onChange={(value): void => { onChange={(value): void => {
setMetricMetadata((prev) => ({ setMetricMetadataState((prev) => ({
...prev, ...prev,
metricType: value as MetricType, type: value,
})); }));
}} }}
/> />
); );
} }
if (field.key === 'unit') { if (field.key === TableFields.UNIT) {
return ( return (
<YAxisUnitSelector <YAxisUnitSelector
value={metricMetadata.unit} value={metricMetadataState.unit}
onChange={(value): void => { onChange={(value): void => {
setMetricMetadata((prev) => ({ ...prev, unit: value })); setMetricMetadataState((prev) => ({ ...prev, unit: value }));
}} }}
data-testid="unit-select" data-testid="unit-select"
source={YAxisSource.EXPLORER} source={YAxisSource.EXPLORER}
/> />
); );
} }
if (field.key === 'temporality') { if (field.key === TableFields.Temporality) {
const temporalityValue =
metricMetadataState.temporality === MetrictypesTemporalityDTO.unspecified
? undefined
: metricMetadataState.temporality;
return ( return (
<Select <Select
data-testid="temporality-select" data-testid="temporality-select"
options={Object.values(Temporality).map((key) => ({ options={METRIC_METADATA_TEMPORALITY_OPTIONS}
value: key, value={temporalityValue}
label: key,
}))}
value={metricMetadata.temporality}
onChange={(value): void => { onChange={(value): void => {
setMetricMetadata((prev) => ({ setMetricMetadataState((prev) => ({
...prev, ...prev,
temporality: value as Temporality, temporality: value,
})); }));
}} }}
/> />
); );
} }
if (field.key === 'description') { if (field.key === TableFields.DESCRIPTION) {
return ( return (
<Input <Input
data-testid="description-input" data-testid="description-input"
name={field.key} name={field.key}
defaultValue={ defaultValue={metricMetadataState.description}
metricMetadata[
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
]
}
onChange={(e): void => { onChange={(e): void => {
setMetricMetadata((prev) => ({ setMetricMetadataState((prev) => ({
...prev, ...prev,
[field.key]: e.target.value, [field.key]: e.target.value,
})); }));
@@ -164,7 +202,7 @@ function Metadata({
} }
return <FieldRenderer field="-" />; return <FieldRenderer field="-" />;
}, },
[isEditing, metadata?.unit, metricMetadata, renderUneditableField], [isEditing, metadata?.unit, metricMetadataState, renderUneditableField],
); );
const columns: ColumnsType<DataType> = useMemo( const columns: ColumnsType<DataType> = useMemo(
@@ -201,52 +239,61 @@ function Metadata({
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
updateMetricMetadata( updateMetricMetadata(
{ {
metricName, pathParams: {
payload: { metricName,
...metricMetadata,
isMonotonic: determineIsMonotonic(
metricMetadata.metricType,
metricMetadata.temporality,
),
}, },
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
}, },
{ {
onSuccess: (response): void => { onSuccess: (): void => {
if (response?.statusCode === 200) { logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, { [MetricsExplorerEventKeys.MetricName]: metricName,
[MetricsExplorerEventKeys.MetricName]: metricName, [MetricsExplorerEventKeys.Tab]: 'summary',
[MetricsExplorerEventKeys.Tab]: 'summary', [MetricsExplorerEventKeys.Modal]: 'metric-details',
[MetricsExplorerEventKeys.Modal]: 'metric-details', });
}); notifications.success({
notifications.success({ message: 'Metadata updated successfully',
message: 'Metadata updated successfully', });
}); setIsEditing(false);
refetchMetricDetails(); invalidateListMetrics(queryClient);
setIsEditing(false); invalidateGetMetricMetadata(queryClient, {
queryClient.invalidateQueries(['metricsList']); metricName,
} else { });
notifications.error({
message:
'Failed to update metadata, please try again. If the issue persists, please contact support.',
});
}
}, },
onError: (): void => onError: (error): void => {
const errorMessage = (error as AxiosError<RenderErrorResponseDTO>).response
?.data.error?.message;
notifications.error({ notifications.error({
message: message: errorMessage || METRIC_METADATA_UPDATE_ERROR_MESSAGE,
'Failed to update metadata, please try again. If the issue persists, please contact support.', });
}), },
}, },
); );
}, [ }, [
updateMetricMetadata, updateMetricMetadata,
metricName, metricName,
metricMetadata, metricMetadataState,
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 (
@@ -254,10 +301,7 @@ function Metadata({
<Button <Button
className="action-button" className="action-button"
type="text" type="text"
onClick={(e): void => { onClick={cancelEdit}
e.stopPropagation();
setIsEditing(false);
}}
disabled={isUpdatingMetricsMetadata} disabled={isUpdatingMetricsMetadata}
> >
<X size={14} /> <X size={14} />
@@ -278,6 +322,9 @@ function Metadata({
</div> </div>
); );
} }
if (isErrorMetricMetadata) {
return null;
}
return ( return (
<div className="action-menu"> <div className="action-menu">
<Button <Button
@@ -294,7 +341,13 @@ function Metadata({
</Button> </Button>
</div> </div>
); );
}, [handleSave, isEditing, isUpdatingMetricsMetadata]); }, [
isEditing,
isErrorMetricMetadata,
isUpdatingMetricsMetadata,
cancelEdit,
handleSave,
]);
const items = useMemo( const items = useMemo(
() => [ () => [
@@ -306,7 +359,14 @@ function Metadata({
</div> </div>
), ),
key: 'metric-metadata', key: 'metric-metadata',
children: ( children: isErrorMetricMetadata ? (
<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"
@@ -318,9 +378,23 @@ 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,7 +38,12 @@
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;
@@ -47,6 +52,18 @@
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;
@@ -72,6 +89,7 @@
.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;
@@ -102,7 +120,19 @@
} }
} }
.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;
@@ -148,7 +178,6 @@
.all-attributes-search-input { .all-attributes-search-input {
width: 300px; width: 300px;
border: 1px solid var(--bg-slate-300);
} }
} }
@@ -161,6 +190,7 @@
.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 {
@@ -217,6 +247,10 @@
.ant-collapse-content-box { .ant-collapse-content-box {
padding: 0; padding: 0;
.metric-metadata-error-state {
height: 267px;
}
} }
.ant-collapse-header { .ant-collapse-header {
@@ -237,6 +271,7 @@
} }
.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 {
@@ -330,18 +365,26 @@
.metric-details-content { .metric-details-content {
.metrics-accordion { .metrics-accordion {
.metrics-accordion-header { .metrics-accordion-header {
.action-button { .action-menu {
.ant-typography { .action-button {
color: var(--bg-slate-400); .ant-typography {
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-slate-400); color: var(--bg-vanilla-200);
background-color: var(--bg-robin-300); background-color: var(--bg-robin-300);
} }
} }
@@ -395,3 +438,13 @@
} }
} }
} }
.metric-details-error-state {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}

View File

@@ -1,16 +1,8 @@
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 { import { Button, Divider, Drawer, Typography } from 'antd';
Button,
Divider,
Drawer,
Empty,
Skeleton,
Tooltip,
Typography,
} from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails'; import { useGetMetricMetadata } from 'api/generated/services/metrics';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, Crosshair, X } from 'lucide-react'; import { Compass, Crosshair, X } from 'lucide-react';
@@ -19,16 +11,12 @@ 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 { import { getMetricDetailsQuery } from './utils';
formatNumberToCompactFormat,
formatTimestampToReadableDate,
getMetricDetailsQuery,
} from './utils';
import './MetricDetails.styles.scss'; import './MetricDetails.styles.scss';
import '../Summary/Summary.styles.scss'; import '../Summary/Summary.styles.scss';
@@ -43,55 +31,49 @@ function MetricDetails({
const { handleExplorerTabChange } = useHandleExplorerTabChange(); const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { const {
data, data: metricMetadataResponse,
isLoading, isLoading: isLoadingMetricMetadata,
isFetching, isError: isErrorMetricMetadata,
error: metricDetailsError, refetch: refetchMetricMetadata,
refetch: refetchMetricDetails, } = useGetMetricMetadata(
} = useGetMetricDetails(metricName ?? '', { {
enabled: !!metricName, metricName,
}); },
{
const metric = data?.payload?.data; query: {
enabled: !!metricName,
const lastReceived = useMemo(() => { },
if (!metric) { },
return null;
}
return formatTimestampToReadableDate(metric.lastReceived);
}, [metric]);
const showInspectFeature = useMemo(
() => isInspectEnabled(metric?.metadata?.metric_type),
[metric],
); );
const isMetricDetailsLoading = isLoading || isFetching; const metadata = useMemo(() => {
if (!metricMetadataResponse) {
const timeSeries = useMemo(() => {
if (!metric) {
return null; return null;
} }
const timeSeriesActive = formatNumberToCompactFormat(metric.timeSeriesActive); const {
const timeSeriesTotal = formatNumberToCompactFormat(metric.timeSeriesTotal); type,
description,
unit,
temporality,
isMonotonic,
} = metricMetadataResponse.data;
return ( return {
<Tooltip type,
title="Active time series are those that have received data points in the last 1 description,
hour." unit,
placement="top" temporality,
> isMonotonic,
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span> };
</Tooltip> }, [metricMetadataResponse]);
);
}, [metric]); const showInspectFeature = useMemo(() => isInspectEnabled(metadata?.type), [
metadata?.type,
]);
const goToMetricsExplorerwithSelectedMetric = useCallback(() => { const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
if (metricName) { if (metricName) {
const compositeQuery = getMetricDetailsQuery( const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
metricName,
metric?.metadata?.metric_type,
);
handleExplorerTabChange( handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TIME_SERIES,
{ {
@@ -107,9 +89,7 @@ function MetricDetails({
[MetricsExplorerEventKeys.Modal]: 'metric-details', [MetricsExplorerEventKeys.Modal]: 'metric-details',
}); });
} }
}, [metricName, handleExplorerTabChange, metric?.metadata?.metric_type]); }, [metricName, handleExplorerTabChange, metadata?.type]);
const isMetricDetailsError = metricDetailsError || !metric;
useEffect(() => { useEffect(() => {
logEvent(MetricsExplorerEvents.ModalOpened, { logEvent(MetricsExplorerEvents.ModalOpened, {
@@ -117,6 +97,9 @@ function MetricDetails({
}); });
}, []); }, []);
const isActionButtonDisabled =
!metricName || isLoadingMetricMetadata || isErrorMetricMetadata;
return ( return (
<Drawer <Drawer
width="60%" width="60%"
@@ -124,13 +107,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>{metric?.name}</Typography.Text> <Typography.Text>{metricName}</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={!metricName} disabled={isActionButtonDisabled}
data-testid="open-in-explorer-button" data-testid="open-in-explorer-button"
> >
Open in Explorer Open in Explorer
@@ -140,10 +123,11 @@ 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 (metric?.name) { if (metricName) {
openInspectModal(metric.name); openInspectModal(metricName);
} }
}} }}
data-testid="inspect-metric-button" data-testid="inspect-metric-button"
@@ -163,60 +147,18 @@ function MetricDetails({
destroyOnClose destroyOnClose
closeIcon={<X size={16} />} closeIcon={<X size={16} />}
> >
{isMetricDetailsLoading && ( <div className="metric-details-content">
<div data-testid="metric-details-skeleton"> <Highlights metricName={metricName} />
<Skeleton active /> <DashboardsAndAlertsPopover metricName={metricName} />
</div> <Metadata
)} metricName={metricName}
{isMetricDetailsError && !isMetricDetailsLoading && ( metadata={metadata}
<Empty description="Error fetching metric details" /> isErrorMetricMetadata={isErrorMetricMetadata}
)} isLoadingMetricMetadata={isLoadingMetricMetadata}
{!isMetricDetailsLoading && !isMetricDetailsError && ( refetchMetricMetadata={refetchMetricMetadata}
<div className="metric-details-content"> />
<div className="metric-details-content-grid"> <AllAttributes metricName={metricName} metricType={metadata?.type} />
<div className="labels-row"> </div>
<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

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

View File

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

@@ -0,0 +1,67 @@
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,16 +1,24 @@
/* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen } from '@testing-library/react'; import { 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', () => ({
@@ -72,13 +80,18 @@ 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
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata') const mockMetricMetadata = transformMetricMetadata(
.mockReturnValue({ getMockMetricMetadataData().data as GetMetricMetadata200,
mutate: mockUseUpdateMetricMetadata, ) as MetricMetadata;
isLoading: false,
} as any);
const mockErrorNotification = jest.fn(); const mockErrorNotification = jest.fn();
const mockSuccessNotification = jest.fn(); const mockSuccessNotification = jest.fn();
@@ -89,47 +102,50 @@ jest.spyOn(useNotificationsHooks, 'useNotifications').mockReturnValue({
}, },
} as any); } as any);
const mockMetricName = 'test_metric'; const mockRefetchMetricMetadata = jest.fn();
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={mockMetricName} metricName={MOCK_METRIC_NAME}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails} isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
expect(screen.getByText('Metric Type')).toBeInTheDocument(); expect(screen.getByText('Metric Type')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.metric_type)).toBeInTheDocument(); expect(screen.getByText('Gauge')).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(mockMetricMetadata.temporality)).toBeInTheDocument(); expect(screen.getByText('Delta')).toBeInTheDocument();
}); });
it('editing the metadata should show the form inputs', () => { it('editing the metadata should show the form inputs', async () => {
render( render(
<Metadata <Metadata
metricName={mockMetricName} metricName={MOCK_METRIC_NAME}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails} isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
fireEvent.click(editButton); await userEvent.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();
@@ -139,57 +155,53 @@ 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={mockMetricName} metricName={MOCK_METRIC_NAME}
metadata={{ metadata={{
...mockMetricMetadata, ...mockMetricMetadata,
unit: '', unit: '',
}} }}
refetchMetricDetails={mockRefetchMetricDetails} isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
fireEvent.click(editButton); await userEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input'); const metricDescriptionInput = screen.getByTestId('description-input');
expect(metricDescriptionInput).toBeInTheDocument(); expect(metricDescriptionInput).toBeInTheDocument();
fireEvent.change(metricDescriptionInput, { await userEvent.clear(metricDescriptionInput);
target: { value: 'Updated description' }, await userEvent.type(metricDescriptionInput, 'Updated description');
});
const metricTypeSelect = screen.getByTestId('metric-type-select'); const metricTypeSelect = screen.getByTestId('metric-type-select');
expect(metricTypeSelect).toBeInTheDocument(); expect(metricTypeSelect).toBeInTheDocument();
fireEvent.change(metricTypeSelect, { await userEvent.selectOptions(metricTypeSelect, MetrictypesTypeDTO.sum);
target: { value: MetricType.SUM },
});
const temporalitySelect = screen.getByTestId('temporality-select'); const temporalitySelect = screen.getByTestId('temporality-select');
expect(temporalitySelect).toBeInTheDocument(); expect(temporalitySelect).toBeInTheDocument();
fireEvent.change(temporalitySelect, { await userEvent.selectOptions(temporalitySelect, Temporality.CUMULATIVE);
target: { value: Temporality.CUMULATIVE },
});
const unitSelect = screen.getByTestId('unit-select'); const unitSelect = screen.getByTestId('unit-select');
expect(unitSelect).toBeInTheDocument(); expect(unitSelect).toBeInTheDocument();
fireEvent.change(unitSelect, { await userEvent.selectOptions(unitSelect, 'By');
target: { value: 'By' },
});
const saveButton = screen.getByText('Save'); const saveButton = screen.getByText('Save');
expect(saveButton).toBeInTheDocument(); expect(saveButton).toBeInTheDocument();
fireEvent.click(saveButton); await userEvent.click(saveButton);
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith( expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
metricName: mockMetricName, data: expect.objectContaining({
payload: expect.objectContaining({ type: MetrictypesTypeDTO.sum,
description: 'Updated description', temporality: MetrictypesTemporalityDTO.cumulative,
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),
@@ -201,56 +213,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={mockMetricName} metricName={MOCK_METRIC_NAME}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails} isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
fireEvent.click(editButton); await userEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input'); const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, { await userEvent.clear(metricDescriptionInput);
target: { value: 'Updated description' }, await userEvent.type(metricDescriptionInput, 'Updated description');
});
const saveButton = screen.getByText('Save'); const saveButton = screen.getByText('Save');
fireEvent.click(saveButton); await userEvent.click(saveButton);
const onSuccessCallback = const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess; mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ statusCode: 200 }); onSuccessCallback({ status: 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={mockMetricName} metricName={MOCK_METRIC_NAME}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails} isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
fireEvent.click(editButton); await userEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input'); const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, { await userEvent.clear(metricDescriptionInput);
target: { value: 'Updated description' }, await userEvent.type(metricDescriptionInput, 'Updated description');
});
const saveButton = screen.getByText('Save'); const saveButton = screen.getByText('Save');
fireEvent.click(saveButton); await userEvent.click(saveButton);
const onSuccessCallback = const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess; onErrorCallback({ status: 500 });
onSuccessCallback({ statusCode: 500 });
expect(mockErrorNotification).toHaveBeenCalledWith({ expect(mockErrorNotification).toHaveBeenCalledWith({
message: message:
@@ -261,22 +273,23 @@ 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={mockMetricName} metricName={MOCK_METRIC_NAME}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails} isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
fireEvent.click(editButton); await userEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input'); const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, { await userEvent.clear(metricDescriptionInput);
target: { value: 'Updated description' }, await userEvent.type(metricDescriptionInput, 'Updated description');
});
const saveButton = screen.getByText('Save'); const saveButton = screen.getByText('Save');
fireEvent.click(saveButton); await userEvent.click(saveButton);
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError; const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
@@ -289,39 +302,43 @@ describe('Metadata', () => {
}); });
}); });
it('cancel button should cancel the edit mode', () => { it('cancel button should cancel the edit mode', async () => {
render( render(
<Metadata <Metadata
metricName={mockMetricName} metricName={MOCK_METRIC_NAME}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails} isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
fireEvent.click(editButton); await userEvent.click(editButton);
const cancelButton = screen.getByText('Cancel'); const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toBeInTheDocument(); expect(cancelButton).toBeInTheDocument();
fireEvent.click(cancelButton); await userEvent.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', () => { it('should not allow editing of unit if it is already set', async () => {
render( render(
<Metadata <Metadata
metricName={mockMetricName} metricName={MOCK_METRIC_NAME}
metadata={mockMetricMetadata} metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails} isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricMetadata={mockRefetchMetricMetadata}
/>, />,
); );
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
fireEvent.click(editButton); await userEvent.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,68 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails'; import * as metricsExplorerHooks from 'api/generated/services/metrics';
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')
@@ -88,7 +36,50 @@ 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
@@ -101,27 +92,15 @@ describe('MetricDetails', () => {
); );
expect(screen.getByText(mockMetricName)).toBeInTheDocument(); expect(screen.getByText(mockMetricName)).toBeInTheDocument();
expect(screen.getByText(mockMetricDescription)).toBeInTheDocument(); expect(screen.getByTestId('all-attributes')).toBeInTheDocument();
expect( expect(
screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)), screen.getByTestId('dashboards-and-alerts-popover'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByTestId('highlights')).toBeInTheDocument();
expect(screen.getByTestId('metadata')).toBeInTheDocument();
}); });
it('renders the "open in explorer" and "inspect" buttons', () => { it('renders the "open in explorer" and "inspect" buttons', async () => {
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}
@@ -135,93 +114,10 @@ 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();
fireEvent.click(screen.getByTestId('open-in-explorer-button')); await userEvent.click(screen.getByTestId('open-in-explorer-button'));
expect(mockHandleExplorerTabChange).toHaveBeenCalled(); expect(mockHandleExplorerTabChange).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('inspect-metric-button')); await userEvent.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

@@ -0,0 +1,168 @@
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,5 +1,7 @@
import { Temporality } from 'api/metricsExplorer/getMetricDetails'; import {
import { MetricType } from 'api/metricsExplorer/getMetricsList'; MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { import {
determineIsMonotonic, determineIsMonotonic,
@@ -10,35 +12,48 @@ 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(MetricType.HISTOGRAM)).toBe(true); expect(determineIsMonotonic(MetrictypesTypeDTO.histogram)).toBe(true);
}); });
it('should return true for exponential histogram metrics', () => { it('should return true for exponential histogram metrics', () => {
expect(determineIsMonotonic(MetricType.EXPONENTIAL_HISTOGRAM)).toBe(true); expect(determineIsMonotonic(MetrictypesTypeDTO.exponentialhistogram)).toBe(
});
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(determineIsMonotonic(MetricType.SUM, Temporality.DELTA)).toBe(false); expect(
determineIsMonotonic(
MetrictypesTypeDTO.sum,
MetrictypesTemporalityDTO.delta,
),
).toBe(false);
}); });
it('should return false by default', () => { it('should return false by default', () => {
expect(determineIsMonotonic('' as MetricType, '' as Temporality)).toBe( expect(
false, determineIsMonotonic(
); '' as MetrictypesTypeDTO,
'' as MetrictypesTemporalityDTO,
),
).toBe(false);
}); });
}); });
@@ -115,13 +130,16 @@ 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(TEST_METRIC_NAME, MetricType.SUM); const query = getMetricDetailsQuery(
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(
MetricType.SUM, MetrictypesTypeDTO.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');
@@ -129,13 +147,16 @@ describe('MetricDetails utils', () => {
}); });
it('should create correct query for GAUGE metric type', () => { it('should create correct query for GAUGE metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.GAUGE); const query = getMetricDetailsQuery(
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(
MetricType.GAUGE, MetrictypesTypeDTO.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');
@@ -143,13 +164,16 @@ describe('MetricDetails utils', () => {
}); });
it('should create correct query for SUMMARY metric type', () => { it('should create correct query for SUMMARY metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUMMARY); const query = getMetricDetailsQuery(
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(
MetricType.SUMMARY, MetrictypesTypeDTO.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');
@@ -157,13 +181,16 @@ describe('MetricDetails utils', () => {
}); });
it('should create correct query for HISTOGRAM metric type', () => { it('should create correct query for HISTOGRAM metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.HISTOGRAM); const query = getMetricDetailsQuery(
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(
MetricType.HISTOGRAM, MetrictypesTypeDTO.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');
@@ -173,14 +200,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,
MetricType.EXPONENTIAL_HISTOGRAM, MetrictypesTypeDTO.exponentialhistogram,
); );
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(
MetricType.EXPONENTIAL_HISTOGRAM, MetrictypesTypeDTO.exponentialhistogram,
); );
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');
@@ -203,7 +230,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,
MetricType.SUM, MetrictypesTypeDTO.sum,
filter, filter,
); );
@@ -221,7 +248,7 @@ describe('MetricDetails utils', () => {
const groupBy = 'service'; const groupBy = 'service';
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(
TEST_METRIC_NAME, TEST_METRIC_NAME,
MetricType.SUM, MetrictypesTypeDTO.sum,
undefined, undefined,
groupBy, groupBy,
); );
@@ -236,7 +263,7 @@ describe('MetricDetails utils', () => {
const groupBy = 'endpoint'; const groupBy = 'endpoint';
const query = getMetricDetailsQuery( const query = getMetricDetailsQuery(
TEST_METRIC_NAME, TEST_METRIC_NAME,
MetricType.SUM, MetrictypesTypeDTO.sum,
filter, filter,
groupBy, groupBy,
); );
@@ -250,7 +277,10 @@ 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(TEST_METRIC_NAME, MetricType.SUM); const query = getMetricDetailsQuery(
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,6 +1,55 @@
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',
metric_type: 'Metric Type', 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,34 +1,39 @@
import { import {
MetricDetails, MetricsexplorertypesMetricAlertDTO,
MetricDetailsAlert, MetricsexplorertypesMetricAttributeDTO,
MetricDetailsAttribute, MetricsexplorertypesMetricDashboardDTO,
MetricDetailsDashboard, MetricsexplorertypesMetricHighlightsResponseDTO,
} from 'api/metricsExplorer/getMetricDetails'; MetricsexplorertypesMetricMetadataDTO,
import { MetricType } from 'api/metricsExplorer/getMetricsList'; MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
export interface MetricDetailsProps { export interface MetricDetailsProps {
onClose: () => void; onClose: () => void;
isOpen: boolean; isOpen: boolean;
metricName: string | null; metricName: string;
isModalTimeSelection: boolean; isModalTimeSelection: boolean;
openInspectModal?: (metricName: string) => void; openInspectModal?: (metricName: string) => void;
} }
export interface HighlightsProps {
metricName: string;
}
export interface DashboardsAndAlertsPopoverProps { export interface DashboardsAndAlertsPopoverProps {
dashboards: MetricDetailsDashboard[] | null; metricName: string;
alerts: MetricDetailsAlert[] | null;
} }
export interface MetadataProps { export interface MetadataProps {
metricName: string; metricName: string;
metadata: MetricDetails['metadata'] | undefined; metadata: MetricMetadata | null;
refetchMetricDetails: () => void; isErrorMetricMetadata: boolean;
isLoadingMetricMetadata: boolean;
refetchMetricMetadata: () => void;
} }
export interface AllAttributesProps { export interface AllAttributesProps {
attributes: MetricDetailsAttribute[];
metricName: string; metricName: string;
metricType: MetricType | undefined; metricType: MetrictypesTypeDTO | undefined;
} }
export interface AllAttributesValueProps { export interface AllAttributesValueProps {
@@ -36,3 +41,38 @@ 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,12 +1,23 @@
import { Temporality } from 'api/metricsExplorer/getMetricDetails'; import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics';
import { MetricType } from 'api/metricsExplorer/getMetricsList'; import {
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';
export function formatTimestampToReadableDate(timestamp: string): string { import { MetricMetadata, MetricMetadataFormState } from './types';
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);
@@ -39,7 +50,10 @@ export function formatTimestampToReadableDate(timestamp: string): string {
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
export function formatNumberToCompactFormat(num: number): string { export function formatNumberToCompactFormat(num: number | undefined): string {
if (!num) {
return '-';
}
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
notation: 'compact', notation: 'compact',
maximumFractionDigits: 1, maximumFractionDigits: 1,
@@ -47,27 +61,30 @@ export function formatNumberToCompactFormat(num: number): string {
} }
export function determineIsMonotonic( export function determineIsMonotonic(
metricType: MetricType, metricType: MetrictypesTypeDTO,
temporality?: Temporality, temporality?: MetrictypesTemporalityDTO,
): boolean { ): boolean {
if ( if (
metricType === MetricType.HISTOGRAM || metricType === MetrictypesTypeDTO.histogram ||
metricType === MetricType.EXPONENTIAL_HISTOGRAM metricType === MetrictypesTypeDTO.exponentialhistogram
) { ) {
return true; return true;
} }
if (metricType === MetricType.GAUGE || metricType === MetricType.SUMMARY) { if (
metricType === MetrictypesTypeDTO.gauge ||
metricType === MetrictypesTypeDTO.summary
) {
return false; return false;
} }
if (metricType === MetricType.SUM) { if (metricType === MetrictypesTypeDTO.sum) {
return temporality === Temporality.CUMULATIVE; return temporality === MetrictypesTemporalityDTO.cumulative;
} }
return false; return false;
} }
export function getMetricDetailsQuery( export function getMetricDetailsQuery(
metricName: string, metricName: string,
metricType: MetricType | undefined, metricType: MetrictypesTypeDTO | undefined,
filter?: { key: string; value: string }, filter?: { key: string; value: string },
groupBy?: string, groupBy?: string,
): Query { ): Query {
@@ -75,23 +92,23 @@ export function getMetricDetailsQuery(
let spaceAggregation; let spaceAggregation;
let aggregateOperator; let aggregateOperator;
switch (metricType) { switch (metricType) {
case MetricType.SUM: case MetrictypesTypeDTO.sum:
timeAggregation = 'rate'; timeAggregation = 'rate';
spaceAggregation = 'sum'; spaceAggregation = 'sum';
aggregateOperator = 'rate'; aggregateOperator = 'rate';
break; break;
case MetricType.GAUGE: case MetrictypesTypeDTO.gauge:
timeAggregation = 'avg'; timeAggregation = 'avg';
spaceAggregation = 'avg'; spaceAggregation = 'avg';
aggregateOperator = 'avg'; aggregateOperator = 'avg';
break; break;
case MetricType.SUMMARY: case MetrictypesTypeDTO.summary:
timeAggregation = 'noop'; timeAggregation = 'noop';
spaceAggregation = 'sum'; spaceAggregation = 'sum';
aggregateOperator = 'noop'; aggregateOperator = 'noop';
break; break;
case MetricType.HISTOGRAM: case MetrictypesTypeDTO.histogram:
case MetricType.EXPONENTIAL_HISTOGRAM: case MetrictypesTypeDTO.exponentialhistogram:
timeAggregation = 'noop'; timeAggregation = 'noop';
spaceAggregation = 'p90'; spaceAggregation = 'p90';
aggregateOperator = 'noop'; aggregateOperator = 'noop';
@@ -160,3 +177,38 @@ 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,5 +1,4 @@
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,
@@ -10,22 +9,24 @@ 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({
queryFilters, queryFilterExpression,
onFilterChange,
}: { }: {
queryFilters: TagFilter; queryFilterExpression: Filter;
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>('');
@@ -67,9 +68,12 @@ 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.items, ...queryFilters,
{ {
id: 'metric_name', id: 'metric_name',
op: 'CONTAINS', op: 'CONTAINS',
@@ -83,13 +87,11 @@ function MetricNameSearch({
], ],
op: 'and', op: 'and',
}; };
setSearchParams({ const newFilterExpression = convertFiltersToExpression(newFilters);
...Object.fromEntries(searchParams.entries()), onFilterChange(newFilterExpression.expression);
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
setIsPopoverOpen(false); setIsPopoverOpen(false);
}, },
[queryFilters.items, setSearchParams, searchParams], [queryFilterExpression, onFilterChange],
); );
const metricNameFilterValues = useMemo( const metricNameFilterValues = useMemo(

View File

@@ -0,0 +1,76 @@
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,23 +1,19 @@
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 { MetricType } from 'api/metricsExplorer/getMetricsList'; import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
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 { import { METRIC_TYPE_VIEW_VALUES_MAP } from './constants';
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(
@@ -26,9 +22,9 @@ function MetricTypeSearch({
key: 'all', key: 'all',
value: 'All', value: 'All',
}, },
...Object.keys(METRIC_TYPE_LABEL_MAP).map((key) => ({ ...Object.keys(METRIC_TYPE_VIEW_VALUES_MAP).map((key) => ({
key: METRIC_TYPE_VALUES_MAP[key as MetricType], key: METRIC_TYPE_VIEW_VALUES_MAP[key as MetrictypesTypeDTO],
value: METRIC_TYPE_LABEL_MAP[key as MetricType], value: METRIC_TYPE_VIEW_VALUES_MAP[key as MetrictypesTypeDTO],
})), })),
], ],
[], [],
@@ -36,16 +32,17 @@ function MetricTypeSearch({
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedMetricType: string): void => { (selectedMetricType: string): void => {
let newFilters;
if (selectedMetricType !== 'all') { if (selectedMetricType !== 'all') {
const newFilters = { newFilters = {
items: [ items: [
...queryFilters.items, ...queryFilters.items,
{ {
id: 'metric_type', id: 'type',
op: '=', op: '=',
key: { key: {
id: 'metric_type', id: 'type',
key: 'metric_type', key: 'type',
type: 'tag', type: 'tag',
}, },
value: selectedMetricType, value: selectedMetricType,
@@ -53,23 +50,17 @@ function MetricTypeSearch({
], ],
op: 'AND', op: 'AND',
}; };
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
} else { } else {
const newFilters = { newFilters = {
items: queryFilters.items.filter((item) => item.id !== 'metric_type'), items: queryFilters.items.filter((item) => item.id !== '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, setSearchParams, searchParams], [queryFilters.items, onFilterChange],
); );
const menu = ( const menu = (

View File

@@ -0,0 +1,79 @@
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,27 +1,58 @@
import { useCallback } from 'react';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch'; import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { HardHat, Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import { MetricsSearchProps } from './types'; import { MetricsSearchProps } from './types';
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element { function MetricsSearch({
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 className="qb-search-container"> <div data-testid="qb-search-container" 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>
<QueryBuilderSearch <QuerySearch
query={query} onChange={handleOnChange}
onChange={onChange} dataSource={DataSource.METRICS}
suffixIcon={<HardHat size={16} />} queryData={{
isMetricsExplorer ...query,
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,6 +9,7 @@ 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';
@@ -24,7 +25,8 @@ function MetricsTable({
setOrderBy, setOrderBy,
totalCount, totalCount,
openMetricDetails, openMetricDetails,
queryFilters, queryFilterExpression,
onFilterChange,
}: MetricsTableProps): JSX.Element { }: MetricsTableProps): JSX.Element {
const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback( const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback(
( (
@@ -36,13 +38,20 @@ function MetricsTable({
): void => { ): void => {
if ('field' in sorter && sorter.order) { if ('field' in sorter && sorter.order) {
setOrderBy({ setOrderBy({
columnName: sorter.field as string, key: {
order: sorter.order === 'ascend' ? 'asc' : 'desc', name: sorter.field as string,
},
direction:
sorter.order === 'ascend'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}); });
} else { } else {
setOrderBy({ setOrderBy({
columnName: 'samples', key: {
order: 'desc', name: 'samples',
},
direction: Querybuildertypesv5OrderDirectionDTO.desc,
}); });
} }
}, },
@@ -51,19 +60,17 @@ function MetricsTable({
return ( return (
<div className="metrics-table-container"> <div className="metrics-table-container">
{!isError && !isLoading && ( <div className="metrics-table-title" data-testid="metrics-table-title">
<div className="metrics-table-title" data-testid="metrics-table-title"> <Typography.Title level={4} className="metrics-table-title">
<Typography.Title level={4} className="metrics-table-title"> List View
List View </Typography.Title>
</Typography.Title> <Tooltip
<Tooltip 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."
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"
placement="right" >
> <Info size={16} />
<Info size={16} /> </Tooltip>
</Tooltip> </div>
</div>
)}
<Table <Table
loading={{ loading={{
spinning: isLoading, spinning: isLoading,
@@ -75,7 +82,7 @@ function MetricsTable({
), ),
}} }}
dataSource={data} dataSource={data}
columns={getMetricsTableColumns(queryFilters)} columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
locale={{ locale={{
emptyText: isLoading ? null : ( emptyText: isLoading ? null : (
<div <div

View File

@@ -1,9 +1,10 @@
import { useMemo } from 'react'; import { useCallback, 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 { stratify, treemapBinary } from 'd3-hierarchy'; import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
import { HierarchyNode, stratify, treemapBinary } from 'd3-hierarchy';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { import {
@@ -12,21 +13,24 @@ import {
TREEMAP_SQUARE_PADDING, TREEMAP_SQUARE_PADDING,
TREEMAP_VIEW_OPTIONS, TREEMAP_VIEW_OPTIONS,
} from './constants'; } from './constants';
import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types'; import {
MetricsTreemapInternalProps,
MetricsTreemapProps,
TreemapTile,
} from './types';
import { import {
getTreemapTileStyle, getTreemapTileStyle,
getTreemapTileTextStyle, getTreemapTileTextStyle,
transformTreemapData, transformTreemapData,
} from './utils'; } from './utils';
function MetricsTreemap({ function MetricsTreemapInternal({
viewType,
data,
isLoading, isLoading,
isError, isError,
data,
viewType,
openMetricDetails, openMetricDetails,
setHeatmapView, }: MetricsTreemapInternalProps): JSX.Element {
}: MetricsTreemapProps): JSX.Element {
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
const treemapWidth = useMemo( const treemapWidth = useMemo(
@@ -40,9 +44,9 @@ function MetricsTreemap({
const treemapData = useMemo(() => { const treemapData = useMemo(() => {
const extracedTreemapData = const extracedTreemapData =
(viewType === TreemapViewType.TIMESERIES (viewType === MetricsexplorertypesTreemapModeDTO.timeseries
? data?.data?.[TreemapViewType.TIMESERIES] ? data?.timeseries
: data?.data?.[TreemapViewType.SAMPLES]) || []; : data?.samples) || [];
return transformTreemapData(extracedTreemapData, viewType); return transformTreemapData(extracedTreemapData, viewType);
}, [data, viewType]); }, [data, viewType]);
@@ -54,41 +58,126 @@ function MetricsTreemap({
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 <Skeleton style={treemapStylesWithoutPadding} active />
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
active
/>
</div> </div>
); );
} }
if ( if (isError) {
!data ||
!data.data ||
(data?.status === 'success' && !data?.data?.[viewType])
) {
return (
<Empty
description="No metrics found"
data-testid="metrics-treemap-empty-state"
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
/>
);
}
if (data?.status === 'error' || isError) {
return ( return (
<Empty <Empty
description="Error fetching metrics. If the problem persists, please contact support." description="Error fetching metrics. If the problem persists, please contact support."
data-testid="metrics-treemap-error-state" data-testid="metrics-treemap-error-state"
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }} style={treemapStylesWithPadding}
/> />
); );
} }
if (!data || !data?.[viewType]?.length) {
return (
<Empty
description="No metrics found"
data-testid="metrics-treemap-empty-state"
style={treemapStylesWithPadding}
/>
);
}
return (
<svg width={treemapWidth} height={TREEMAP_HEIGHT} className="metrics-treemap">
<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={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"
@@ -108,72 +197,16 @@ function MetricsTreemap({
options={TREEMAP_VIEW_OPTIONS} options={TREEMAP_VIEW_OPTIONS}
value={viewType} value={viewType}
onChange={setHeatmapView} onChange={setHeatmapView}
disabled={isLoading}
/> />
</div> </div>
<svg <MetricsTreemapInternal
width={treemapWidth} isLoading={isLoading}
height={TREEMAP_HEIGHT} isError={isError}
className="metrics-treemap" data={data}
> viewType={viewType}
<rect openMetricDetails={openMetricDetails}
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,6 +38,7 @@
.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,11 +4,24 @@ 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 { initialQueriesMap } from 'constants/queryBuilder'; import {
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 { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
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';
@@ -23,32 +36,38 @@ 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 { OrderByPayload, TreemapViewType } from './types'; import { convertNanoToMilliseconds, formatDataForMetricsTable } from './utils';
import {
convertNanoToMilliseconds,
formatDataForMetricsTable,
getMetricsListQuery,
} from './utils';
import './Summary.styles.scss'; import './Summary.styles.scss';
const DEFAULT_ORDER_BY: OrderByPayload = { const DEFAULT_ORDER_BY: Querybuildertypesv5OrderByDTO = {
columnName: 'samples', key: {
order: 'desc', name: 'samples',
},
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<OrderByPayload>(DEFAULT_ORDER_BY); const [orderBy, setOrderBy] = useState<Querybuildertypesv5OrderByDTO>(
const [heatmapView, setHeatmapView] = useState<TreemapViewType>( DEFAULT_ORDER_BY,
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(
@@ -65,16 +84,10 @@ function Summary(): JSX.Element {
(state) => state.globalTime, (state) => state.globalTime,
); );
const queryFilters: TagFilter = useMemo(() => { const [
const encodedFilters = searchParams.get(SUMMARY_FILTERS_KEY); currentQueryFilterExpression,
if (encodedFilters) { setCurrentQueryFilterExpression,
return JSON.parse(encodedFilters); ] = useState<string>(query?.filter?.expression || '');
}
return {
items: [],
op: 'AND',
};
}, [searchParams]);
useEffect(() => { useEffect(() => {
logEvent(MetricsExplorerEvents.TabChanged, { logEvent(MetricsExplorerEvents.TabChanged, {
@@ -87,105 +100,101 @@ function Summary(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// This is used to avoid the filters from being serialized with the id const queryFilterExpression = useMemo(() => {
const queryFiltersWithoutId = useMemo(() => { const filters = query.filters || { items: [], op: 'AND' };
const filtersWithoutId = { return convertFiltersToExpression(filters);
...queryFilters, }, [query.filters]);
items: queryFilters.items.map(({ id: _id, ...rest }) => rest),
};
return JSON.stringify(filtersWithoutId);
}, [queryFilters]);
const metricsListQuery = useMemo(() => { const metricsListQuery: MetricsexplorertypesStatsRequestDTO = useMemo(() => {
const baseQuery = getMetricsListQuery();
return { return {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: convertNanoToMilliseconds(minTime), start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime), end: convertNanoToMilliseconds(maxTime),
limit: pageSize,
offset: (currentPage - 1) * pageSize,
orderBy, orderBy,
filter: {
expression: queryFilterExpression.expression,
},
}; };
}, [queryFilters, minTime, maxTime, orderBy, pageSize, currentPage]); }, [
minTime,
maxTime,
orderBy,
pageSize,
currentPage,
queryFilterExpression.expression,
]);
const metricsTreemapQuery = useMemo( const metricsTreemapQuery: MetricsexplorertypesTreemapRequestDTO = 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,
},
}), }),
[queryFilters, heatmapView, minTime, maxTime], [heatmapView, minTime, maxTime, queryFilterExpression.expression],
); );
const { const {
data: metricsData, data: metricsData,
isLoading: isMetricsLoading, mutate: getMetricsStats,
isFetching: isMetricsFetching, isLoading: isGetMetricsStatsLoading,
isError: isMetricsError, isError: isGetMetricsStatsError,
} = useGetMetricsList(metricsListQuery, { } = useGetMetricsStats();
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,
isLoading: isTreeMapLoading, mutate: getMetricsTreemap,
isFetching: isTreeMapFetching, isLoading: isGetMetricsTreemapLoading,
isError: isTreeMapError, isError: isGetMetricsTreemapError,
} = useGetMetricsTreeMap(metricsTreemapQuery, { } = useGetMetricsTreemap();
enabled: !!metricsTreemapQuery && !isInspectModalOpen,
queryKey: [
'metricsTreemap',
queryFiltersWithoutId,
heatmapView,
minTime,
maxTime,
],
});
const isProportionViewError = useMemo( useEffect(() => {
() => isTreeMapError || treeMapData?.statusCode !== 200, getMetricsStats({
[isTreeMapError, treeMapData], data: metricsListQuery,
); });
}, [metricsListQuery, getMetricsStats]);
useEffect(() => {
getMetricsTreemap({
data: metricsTreemapQuery,
});
}, [metricsTreemapQuery, getMetricsTreemap]);
const handleFilterChange = useCallback( const handleFilterChange = useCallback(
(value: TagFilter) => { (expression: string) => {
setSearchParams({ const newFilters: TagFilter = {
...Object.fromEntries(searchParams.entries()), items: convertExpressionToFilters(expression),
[SUMMARY_FILTERS_KEY]: JSON.stringify(value), op: 'AND',
};
redirectWithQueryBuilderData({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilters,
filter: {
expression,
},
},
],
},
}); });
setCurrentQueryFilterExpression(expression);
setCurrentPage(1); setCurrentPage(1);
if (value.items.length > 0) { if (expression) {
logEvent(MetricsExplorerEvents.FilterApplied, { logEvent(MetricsExplorerEvents.FilterApplied, {
[MetricsExplorerEventKeys.Tab]: 'summary', [MetricsExplorerEventKeys.Tab]: 'summary',
}); });
} }
}, },
[setSearchParams, searchParams], [currentQuery, redirectWithQueryBuilderData],
);
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 => {
@@ -202,7 +211,7 @@ function Summary(): JSX.Element {
}; };
const formattedMetricsData = useMemo( const formattedMetricsData = useMemo(
() => formatDataForMetricsTable(metricsData?.payload?.data?.metrics || []), () => formatDataForMetricsTable(metricsData?.data.metrics || []),
[metricsData], [metricsData],
); );
@@ -254,7 +263,9 @@ function Summary(): JSX.Element {
}); });
}; };
const handleSetHeatmapView = (view: TreemapViewType): void => { const handleSetHeatmapView = (
view: MetricsexplorertypesTreemapModeDTO,
): void => {
setHeatmapView(view); setHeatmapView(view);
logEvent(MetricsExplorerEvents.TreemapViewChanged, { logEvent(MetricsExplorerEvents.TreemapViewChanged, {
[MetricsExplorerEventKeys.Tab]: 'summary', [MetricsExplorerEventKeys.Tab]: 'summary',
@@ -262,68 +273,67 @@ function Summary(): JSX.Element {
}); });
}; };
const handleSetOrderBy = (orderBy: OrderByPayload): void => { const handleSetOrderBy = (orderBy: Querybuildertypesv5OrderByDTO): void => {
setOrderBy(orderBy); setOrderBy(orderBy);
logEvent(MetricsExplorerEvents.OrderByApplied, { logEvent(MetricsExplorerEvents.OrderByApplied, {
[MetricsExplorerEventKeys.Tab]: 'summary', [MetricsExplorerEventKeys.Tab]: 'summary',
[MetricsExplorerEventKeys.ColumnName]: orderBy.columnName, [MetricsExplorerEventKeys.ColumnName]: orderBy.key?.name,
[MetricsExplorerEventKeys.Order]: orderBy.order, [MetricsExplorerEventKeys.Order]: orderBy.direction,
}); });
}; };
const isMetricsListDataEmpty = useMemo( const isMetricsListDataEmpty =
() => formattedMetricsData.length === 0 && !isGetMetricsStatsLoading;
formattedMetricsData.length === 0 && !isMetricsLoading && !isMetricsFetching,
[formattedMetricsData, isMetricsLoading, isMetricsFetching],
);
const isMetricsTreeMapDataEmpty = useMemo( const isMetricsTreeMapDataEmpty =
() => !treeMapData?.data[heatmapView]?.length && !isGetMetricsTreemapLoading;
!treeMapData?.payload?.data[heatmapView]?.length &&
!isTreeMapLoading && const showFullScreenLoading =
!isTreeMapFetching, (isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
[ formattedMetricsData.length === 0 &&
treeMapData?.payload?.data, !treeMapData?.data[heatmapView]?.length;
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 query={searchQuery} onChange={handleFilterChange} /> <MetricsSearch
{isMetricsLoading || isTreeMapLoading ? ( query={query}
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?.payload} data={treeMapData?.data}
isLoading={isTreeMapLoading || isTreeMapFetching} isLoading={isGetMetricsTreemapLoading}
isError={isProportionViewError} isError={isGetMetricsTreemapError}
viewType={heatmapView} viewType={heatmapView}
openMetricDetails={openMetricDetails} openMetricDetails={openMetricDetails}
setHeatmapView={handleSetHeatmapView} setHeatmapView={handleSetHeatmapView}
/> />
<MetricsTable <MetricsTable
isLoading={isMetricsLoading || isMetricsFetching} isLoading={isGetMetricsStatsLoading}
isError={isListViewError} isError={isGetMetricsStatsError}
data={formattedMetricsData} data={formattedMetricsData}
pageSize={pageSize} pageSize={pageSize}
currentPage={currentPage} currentPage={currentPage}
onPaginationChange={onPaginationChange} onPaginationChange={onPaginationChange}
setOrderBy={handleSetOrderBy} setOrderBy={handleSetOrderBy}
totalCount={metricsData?.payload?.data?.total || 0} totalCount={metricsData?.data.total || 0}
openMetricDetails={openMetricDetails} openMetricDetails={openMetricDetails}
queryFilters={queryFilters} queryFilterExpression={queryFilterExpression}
onFilterChange={handleFilterChange}
/> />
</> </>
)} )}
</div> </div>
{isMetricDetailsOpen && ( {isMetricDetailsOpen && selectedMetricName && (
<MetricDetails <MetricDetails
isOpen={isMetricDetailsOpen} isOpen={isMetricDetailsOpen}
onClose={closeMetricDetails} onClose={closeMetricDetails}

View File

@@ -0,0 +1,63 @@
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,9 +30,8 @@ const mockData: MetricsListItemRowData[] = [
}, },
]; ];
const mockQueryFilters: TagFilter = { const mockQueryFilterExpression: Filter = {
items: [], expression: '',
op: 'AND',
}; };
jest.mock('react-router-dom-v5-compat', () => { jest.mock('react-router-dom-v5-compat', () => {
@@ -82,7 +81,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters} queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -106,8 +106,9 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters} queryFilterExpression={mockQueryFilterExpression}
isLoading isLoading
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -130,7 +131,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters} queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -158,7 +160,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters} queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -187,7 +190,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()} setOrderBy={jest.fn()}
totalCount={2} totalCount={2}
openMetricDetails={mockOpenMetricDetails} openMetricDetails={mockOpenMetricDetails}
queryFilters={mockQueryFilters} queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -212,7 +216,8 @@ describe('MetricsTable', () => {
setOrderBy={mockSetOrderBy} setOrderBy={mockSetOrderBy}
totalCount={2} totalCount={2}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters} queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/> />
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@@ -222,8 +227,10 @@ describe('MetricsTable', () => {
fireEvent.click(samplesHeader); fireEvent.click(samplesHeader);
expect(mockSetOrderBy).toHaveBeenCalledWith({ expect(mockSetOrderBy).toHaveBeenCalledWith({
columnName: 'samples', key: {
order: 'asc', name: 'samples',
},
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 = [
{ {
metric_name: 'Metric 1', metricName: 'Metric 1',
percentage: 0.5, percentage: 0.5,
total_value: 15, totalValue: 15,
}, },
{ {
metric_name: 'Metric 2', metricName: 'Metric 2',
percentage: 0.6, percentage: 0.6,
total_value: 10, totalValue: 10,
}, },
]; ];
@@ -47,14 +47,11 @@ describe('MetricsTreemap', () => {
isLoading={false} isLoading={false}
isError={false} isError={false}
data={{ data={{
status: 'success', timeseries: [mockData[0]],
data: { samples: [mockData[1]],
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}} }}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES} viewType={MetricsexplorertypesTreemapModeDTO.samples}
setHeatmapView={jest.fn()} setHeatmapView={jest.fn()}
/> />
</Provider> </Provider>
@@ -72,14 +69,11 @@ describe('MetricsTreemap', () => {
isLoading isLoading
isError={false} isError={false}
data={{ data={{
status: 'success', timeseries: [mockData[0]],
data: { samples: [mockData[1]],
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}} }}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES} viewType={MetricsexplorertypesTreemapModeDTO.samples}
setHeatmapView={jest.fn()} setHeatmapView={jest.fn()}
/> />
</Provider> </Provider>
@@ -99,14 +93,11 @@ describe('MetricsTreemap', () => {
isLoading={false} isLoading={false}
isError isError
data={{ data={{
status: 'success', timeseries: [mockData[0]],
data: { samples: [mockData[1]],
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}} }}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES} viewType={MetricsexplorertypesTreemapModeDTO.samples}
setHeatmapView={jest.fn()} setHeatmapView={jest.fn()}
/> />
</Provider> </Provider>
@@ -128,9 +119,9 @@ describe('MetricsTreemap', () => {
<MetricsTreemap <MetricsTreemap
isLoading={false} isLoading={false}
isError={false} isError={false}
data={null} data={undefined}
openMetricDetails={jest.fn()} openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES} viewType={MetricsexplorertypesTreemapModeDTO.samples}
setHeatmapView={jest.fn()} setHeatmapView={jest.fn()}
/> />
</Provider> </Provider>

View File

@@ -1,109 +1,81 @@
import { Color } from '@signozhq/design-tokens';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList'; import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
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 { import { formatDataForMetricsTable, getMetricsTableColumns } from '../utils';
formatDataForMetricsTable,
getMetricsTableColumns, const mockQueryExpression: Filter = {
MetricTypeRenderer, expression: '',
} 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(getMetricsTableColumns(mockQueryFilters)).toHaveLength(6); expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange),
).toHaveLength(6);
// Metric Name column // Metric Name column
expect(getMetricsTableColumns(mockQueryFilters)[0].dataIndex).toBe( expect(
'metric_name', getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].dataIndex,
); ).toBe('metric_name');
expect(getMetricsTableColumns(mockQueryFilters)[0].width).toBe(400); expect(
expect(getMetricsTableColumns(mockQueryFilters)[0].sorter).toBe(false); getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].width,
).toBe(400);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].sorter,
).toBe(false);
// Description column // Description column
expect(getMetricsTableColumns(mockQueryFilters)[1].dataIndex).toBe( expect(
'description', getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].dataIndex,
); ).toBe('description');
expect(getMetricsTableColumns(mockQueryFilters)[1].width).toBe(400); expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].width,
).toBe(400);
// Type column // Type column
expect(getMetricsTableColumns(mockQueryFilters)[2].dataIndex).toBe( expect(
'metric_type', getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].dataIndex,
); ).toBe('metric_type');
expect(getMetricsTableColumns(mockQueryFilters)[2].width).toBe(150); expect(
expect(getMetricsTableColumns(mockQueryFilters)[2].sorter).toBe(false); getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].width,
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].sorter,
).toBe(false);
// Unit column // Unit column
expect(getMetricsTableColumns(mockQueryFilters)[3].dataIndex).toBe('unit'); expect(
expect(getMetricsTableColumns(mockQueryFilters)[3].width).toBe(150); getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].dataIndex,
).toBe('unit');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].width,
).toBe(150);
// Samples column // Samples column
expect(getMetricsTableColumns(mockQueryFilters)[4].dataIndex).toBe( expect(
TreemapViewType.SAMPLES, getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].dataIndex,
); ).toBe(TreemapViewType.SAMPLES);
expect(getMetricsTableColumns(mockQueryFilters)[4].width).toBe(150); expect(
expect(getMetricsTableColumns(mockQueryFilters)[4].sorter).toBe(true); getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].width,
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].sorter,
).toBe(true);
// Time Series column // Time Series column
expect(getMetricsTableColumns(mockQueryFilters)[5].dataIndex).toBe( expect(
TreemapViewType.TIMESERIES, getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].dataIndex,
); ).toBe(TreemapViewType.TIMESERIES);
expect(getMetricsTableColumns(mockQueryFilters)[5].width).toBe(150); expect(
expect(getMetricsTableColumns(mockQueryFilters)[5].sorter).toBe(true); getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].width,
}); ).toBe(150);
expect(
describe('MetricTypeRenderer', () => { getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].sorter,
it('should render correct icon and color for each metric type', () => { ).toBe(true);
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();
});
}); });
}); });
@@ -111,12 +83,12 @@ describe('formatDataForMetricsTable', () => {
it('should format metrics data correctly', () => { it('should format metrics data correctly', () => {
const mockData = [ const mockData = [
{ {
metric_name: 'test_metric', metricName: 'test_metric',
description: 'Test description', description: 'Test description',
type: MetricType.GAUGE, type: MetrictypesTypeDTO.gauge,
unit: 'bytes', unit: 'bytes',
[TreemapViewType.SAMPLES]: 1000, samples: 1000,
[TreemapViewType.TIMESERIES]: 2000, timeseries: 2000,
lastReceived: '2023-01-01T00:00:00Z', lastReceived: '2023-01-01T00:00:00Z',
}, },
]; ];
@@ -163,12 +135,12 @@ describe('formatDataForMetricsTable', () => {
it('should handle empty/null values', () => { it('should handle empty/null values', () => {
const mockData = [ const mockData = [
{ {
metric_name: '', metricName: '',
description: '', description: '',
type: MetricType.GAUGE, type: MetrictypesTypeDTO.gauge,
unit: '', unit: '',
[TreemapViewType.SAMPLES]: 0, samples: 0,
[TreemapViewType.TIMESERIES]: 0, timeseries: 0,
lastReceived: '2023-01-01T00:00:00Z', lastReceived: '2023-01-01T00:00:00Z',
}, },
]; ];

View File

@@ -1,15 +1,17 @@
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: TreemapViewType; value: MetricsexplorertypesTreemapModeDTO;
label: string; label: string;
}[] = [ }[] = [
{ value: TreemapViewType.TIMESERIES, label: 'Time Series' }, { value: MetricsexplorertypesTreemapModeDTO.timeseries, label: 'Time Series' },
{ value: TreemapViewType.SAMPLES, label: 'Samples' }, { value: MetricsexplorertypesTreemapModeDTO.samples, label: 'Samples' },
]; ];
export const TREEMAP_HEIGHT = 200; export const TREEMAP_HEIGHT = 200;
@@ -17,6 +19,7 @@ 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',
@@ -25,7 +28,16 @@ export const METRIC_TYPE_LABEL_MAP = {
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram', [MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
}; };
export const METRIC_TYPE_VALUES_MAP = { export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = {
[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',
@@ -33,7 +45,14 @@ export const METRIC_TYPE_VALUES_MAP = {
[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,9 +1,11 @@
import React from 'react'; import React from 'react';
import { MetricsTreeMapResponse } from 'api/metricsExplorer/getMetricsTreeMap';
import { import {
IBuilderQuery, MetricsexplorertypesTreemapModeDTO,
TagFilter, MetricsexplorertypesTreemapResponseDTO,
} from 'types/api/queryBuilder/queryBuilderData'; Querybuildertypesv5OrderByDTO,
} 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;
@@ -12,24 +14,36 @@ 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: OrderByPayload) => void; setOrderBy: (orderBy: Querybuildertypesv5OrderByDTO) => void;
totalCount: number; totalCount: number;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void; openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
queryFilters: TagFilter; queryFilterExpression: Filter;
onFilterChange: (expression: string) => void;
} }
export interface MetricsSearchProps { export interface MetricsSearchProps {
query: IBuilderQuery; query: IBuilderQuery;
onChange: (value: TagFilter) => void; onChange: (expression: string) => void;
currentQueryFilterExpression: string;
setCurrentQueryFilterExpression: (expression: string) => void;
isLoading: boolean;
} }
export interface MetricsTreemapProps { export interface MetricsTreemapProps {
data: MetricsTreeMapResponse | null | undefined; data: MetricsexplorertypesTreemapResponseDTO | undefined;
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
viewType: TreemapViewType; viewType: MetricsexplorertypesTreemapModeDTO;
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,39 +1,31 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Tooltip, Typography } from 'antd'; import { Tooltip } from 'antd';
import { ColumnType } from 'antd/es/table'; import { ColumnType } from 'antd/es/table';
import { import {
MetricsListItemData, MetricsexplorertypesStatDTO,
MetricsListPayload, MetricsexplorertypesTreemapEntryDTO,
MetricType, MetricsexplorertypesTreemapModeDTO,
} from 'api/metricsExplorer/getMetricsList'; } from 'api/generated/services/sigNoz.schemas';
import { import { MetricsListPayload } from 'api/metricsExplorer/getMetricsList';
SamplesData, import { Filter } from 'api/v5/v5';
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 MetricTypeSearch from './MetricTypeSearch'; import MetricTypeViewRenderer from './MetricTypeViewRenderer';
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types'; import { MetricsListItemRowData, TreemapTile } from './types';
export const getMetricsTableColumns = ( export const getMetricsTableColumns = (
queryFilters: TagFilter, queryFilterExpression: Filter,
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 queryFilters={queryFilters} /> <MetricNameSearch
queryFilterExpression={queryFilterExpression}
onFilterChange={onFilterChange}
/>
</div> </div>
), ),
dataIndex: 'metric_name', dataIndex: 'metric_name',
@@ -55,7 +47,10 @@ 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 queryFilters={queryFilters} /> {/* <MetricTypeSearch
queryFilters={queryFilters}
onFilterChange={onFilterChange}
/> */}
</div> </div>
), ),
dataIndex: 'metric_type', dataIndex: 'metric_type',
@@ -69,13 +64,13 @@ export const getMetricsTableColumns = (
}, },
{ {
title: 'SAMPLES', title: 'SAMPLES',
dataIndex: TreemapViewType.SAMPLES, dataIndex: MetricsexplorertypesTreemapModeDTO.samples,
width: 150, width: 150,
sorter: true, sorter: true,
}, },
{ {
title: 'TIME SERIES', title: 'TIME SERIES',
dataIndex: TreemapViewType.TIMESERIES, dataIndex: MetricsexplorertypesTreemapModeDTO.timeseries,
width: 150, width: 150,
sorter: true, sorter: true,
}, },
@@ -89,60 +84,6 @@ 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,
@@ -160,6 +101,9 @@ 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
@@ -182,13 +126,13 @@ export const formatNumberIntoHumanReadableFormat = (
}; };
export const formatDataForMetricsTable = ( export const formatDataForMetricsTable = (
data: MetricsListItemData[], data: MetricsexplorertypesStatDTO[],
): MetricsListItemRowData[] => ): MetricsListItemRowData[] =>
data.map((metric) => ({ data.map((metric) => ({
key: metric.metric_name, key: metric.metricName,
metric_name: ( metric_name: (
<ValidateRowValueWrapper value={metric.metric_name}> <ValidateRowValueWrapper value={metric.metricName}>
<Tooltip title={metric.metric_name}>{metric.metric_name}</Tooltip> <Tooltip title={metric.metricName}>{metric.metricName}</Tooltip>
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
description: ( description: (
@@ -198,39 +142,54 @@ export const formatDataForMetricsTable = (
</Tooltip> </Tooltip>
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
metric_type: <MetricTypeRenderer type={metric.type} />, metric_type: <MetricTypeViewRenderer type={metric.type} />,
unit: ( unit: (
<ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}> <ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}>
{getUniversalNameFromMetricUnit(metric.unit)} {getUniversalNameFromMetricUnit(metric.unit)}
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
[TreemapViewType.SAMPLES]: ( [MetricsexplorertypesTreemapModeDTO.samples]: (
<ValidateRowValueWrapper value={metric[TreemapViewType.SAMPLES]}> <ValidateRowValueWrapper
<Tooltip title={metric[TreemapViewType.SAMPLES].toLocaleString()}> value={metric[MetricsexplorertypesTreemapModeDTO.samples]}
{formatNumberIntoHumanReadableFormat(metric[TreemapViewType.SAMPLES])} >
<Tooltip
title={metric[MetricsexplorertypesTreemapModeDTO.samples].toLocaleString()}
>
{formatNumberIntoHumanReadableFormat(
metric[MetricsexplorertypesTreemapModeDTO.samples],
)}
</Tooltip> </Tooltip>
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
[TreemapViewType.TIMESERIES]: ( [MetricsexplorertypesTreemapModeDTO.timeseries]: (
<ValidateRowValueWrapper value={metric[TreemapViewType.TIMESERIES]}> <ValidateRowValueWrapper
<Tooltip title={metric[TreemapViewType.TIMESERIES].toLocaleString()}> value={metric[MetricsexplorertypesTreemapModeDTO.timeseries]}
{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: TimeseriesData[] | SamplesData[], data: MetricsexplorertypesTreemapEntryDTO[],
viewType: TreemapViewType, viewType: MetricsexplorertypesTreemapModeDTO,
): TreemapTile[] => { ): TreemapTile[] => {
const totalSize = (data as (TimeseriesData | SamplesData)[]).reduce( const totalSize = data.reduce(
(acc: number, item: TimeseriesData | SamplesData) => acc + item.percentage, (acc: number, item: MetricsexplorertypesTreemapEntryDTO) =>
acc + item.percentage,
0, 0,
); );
const children = data.map((item) => ({ const children = data.map((item) => ({
id: item.metric_name, id: item.metricName,
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

@@ -0,0 +1,131 @@
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,6 +14,7 @@ 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';
@@ -95,13 +96,24 @@ function LegendColors({
); );
}; };
// Handle color change // Handle color change (throttled to avoid excessive updates)
const handleColorChange = (label: string, color: string): void => { const handleColorChange = useMemo(
setCustomLegendColors((prev) => ({ () =>
...prev, throttle((label: string, color: string): void => {
[label]: color, setCustomLegendColors((prev) => ({
})); ...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,6 +169,10 @@
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,6 +2,9 @@
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';
@@ -204,6 +207,18 @@ 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}
@@ -313,19 +328,17 @@ function Threshold({
<ShowCaseValue value={value} className="unit-input" /> <ShowCaseValue value={value} className="unit-input" />
)} )}
{isEditMode ? ( {isEditMode ? (
<Select <YAxisUnitSelector
defaultValue={unit} value={unit}
options={unitOptions(
selectedGraph === PANEL_TYPES.TABLE
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
: yAxisUnit || '',
)}
onChange={handleUnitChange} onChange={handleUnitChange}
showSearch placeholder="Select unit"
className="unit-selection" source={YAxisSource.DASHBOARDS}
initialValue={unit}
categoriesOverride={unitSelectCategories}
containerClassName="unit-selection"
/> />
) : ( ) : (
<ShowCaseValue value={unit} className="unit-selection-prev" /> <ShowCaseValue value={unitLabel} className="unit-selection-prev" />
)} )}
</div> </div>
<div className="thresholds-color-selector"> <div className="thresholds-color-selector">
@@ -356,7 +369,10 @@ function Threshold({
)} )}
</div> </div>
{isInvalidUnitComparison && ( {isInvalidUnitComparison && (
<Typography.Text className="invalid-unit"> <Typography.Text
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