mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-27 10:42:53 +00:00
Compare commits
12 Commits
nv/7069
...
feat/poc-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec3754b65 | ||
|
|
c36f780964 | ||
|
|
e0e0870e23 | ||
|
|
ea406dac52 | ||
|
|
c6152b33e9 | ||
|
|
ca87244961 | ||
|
|
e41a85febd | ||
|
|
9d4154a117 | ||
|
|
4b9dd63e8c | ||
|
|
30e7736a74 | ||
|
|
6bd194dfb4 | ||
|
|
f27e3cabb2 |
@@ -320,4 +320,3 @@ user:
|
||||
# The name of the organization to create or look up for the root user.
|
||||
org:
|
||||
name: default
|
||||
id: 00000000-0000-0000-0000-000000000000
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.113.0
|
||||
image: signoz/signoz:v0.112.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.142.1
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -241,7 +241,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.113.0
|
||||
image: signoz/signoz:v0.112.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.142.1
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -167,7 +167,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -82,12 +82,6 @@ exporters:
|
||||
timeout: 45s
|
||||
sending_queue:
|
||||
enabled: false
|
||||
metadataexporter:
|
||||
cache:
|
||||
provider: in_memory
|
||||
dsn: tcp://clickhouse:9000/signoz_metadata
|
||||
enabled: true
|
||||
timeout: 45s
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
@@ -99,19 +93,19 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces, metadataexporter, signozmeter]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
image: signoz/signoz:${VERSION:-v0.112.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.1}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -229,7 +229,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
image: signoz/signoz:${VERSION:-v0.112.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.1}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -157,7 +157,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -82,12 +82,6 @@ exporters:
|
||||
timeout: 45s
|
||||
sending_queue:
|
||||
enabled: false
|
||||
metadataexporter:
|
||||
cache:
|
||||
provider: in_memory
|
||||
dsn: tcp://clickhouse:9000/signoz_metadata
|
||||
enabled: true
|
||||
timeout: 45s
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
@@ -99,19 +93,19 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces, metadataexporter, signozmeter]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
|
||||
@@ -842,17 +842,6 @@ components:
|
||||
- temporality
|
||||
- isMonotonic
|
||||
type: object
|
||||
MetrictypesComparisonSpaceAggregationParam:
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
threshold:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- operator
|
||||
- threshold
|
||||
type: object
|
||||
MetrictypesSpaceAggregation:
|
||||
enum:
|
||||
- sum
|
||||
@@ -1149,8 +1138,6 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
$ref: '#/components/schemas/MetrictypesComparisonSpaceAggregationParam'
|
||||
metricName:
|
||||
type: string
|
||||
reduceTo:
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/units"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/formatter"
|
||||
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
|
||||
@@ -335,7 +335,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
|
||||
prevState := r.State()
|
||||
|
||||
valueFormatter := units.FormatterFromUnit(r.Unit())
|
||||
valueFormatter := formatter.FromUnit(r.Unit())
|
||||
|
||||
var res ruletypes.Vector
|
||||
var err error
|
||||
|
||||
@@ -117,7 +117,6 @@
|
||||
"lucide-react": "0.498.0",
|
||||
"mini-css-extract-plugin": "2.4.5",
|
||||
"motion": "12.4.13",
|
||||
"nuqs": "2.8.8",
|
||||
"overlayscrollbars": "^2.8.1",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"papaparse": "5.4.1",
|
||||
@@ -131,7 +130,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-drag-listview": "2.0.0",
|
||||
"react-error-boundary": "4.0.11",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-force-graph": "^1.43.0",
|
||||
"react-full-screen": "1.1.1",
|
||||
"react-grid-layout": "^1.3.4",
|
||||
"react-helmet-async": "1.3.0",
|
||||
@@ -163,8 +162,7 @@
|
||||
"webpack": "5.94.0",
|
||||
"webpack-dev-server": "^5.2.1",
|
||||
"webpack-retry-chunk-load-plugin": "3.1.1",
|
||||
"xstate": "^4.31.0",
|
||||
"zustand": "5.0.11"
|
||||
"xstate": "^4.31.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -289,4 +287,4 @@
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,15 +308,3 @@ export const PublicDashboardPage = Loadable(
|
||||
/* webpackChunkName: "Public Dashboard Page" */ 'pages/PublicDashboard'
|
||||
),
|
||||
);
|
||||
|
||||
export const AlertTypeSelectionPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "Alert Type Selection Page" */ 'pages/AlertTypeSelection'
|
||||
),
|
||||
);
|
||||
|
||||
export const MeterExplorerPage = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertTypeSelectionPage from 'pages/AlertTypeSelection';
|
||||
import MessagingQueues from 'pages/MessagingQueues';
|
||||
import MeterExplorer from 'pages/MeterExplorer';
|
||||
|
||||
import {
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
AllAlertChannels,
|
||||
AllErrors,
|
||||
ApiMonitoring,
|
||||
@@ -27,8 +29,6 @@ import {
|
||||
LogsExplorer,
|
||||
LogsIndexToFields,
|
||||
LogsSaveViews,
|
||||
MessagingQueuesMainPage,
|
||||
MeterExplorerPage,
|
||||
MetricsExplorer,
|
||||
OldLogsExplorer,
|
||||
Onboarding,
|
||||
@@ -399,28 +399,28 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_KAFKA,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
component: MessagingQueues,
|
||||
key: 'MESSAGING_QUEUES_KAFKA',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
component: MessagingQueues,
|
||||
key: 'MESSAGING_QUEUES_CELERY_TASK',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
component: MessagingQueues,
|
||||
key: 'MESSAGING_QUEUES_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
component: MessagingQueues,
|
||||
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
|
||||
isPrivate: true,
|
||||
},
|
||||
@@ -463,21 +463,21 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
exact: true,
|
||||
component: MeterExplorerPage,
|
||||
component: MeterExplorer,
|
||||
key: 'METER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER,
|
||||
exact: true,
|
||||
component: MeterExplorerPage,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER_VIEWS,
|
||||
exact: true,
|
||||
component: MeterExplorerPage,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
@@ -1006,18 +1006,6 @@ export interface MetricsexplorertypesUpdateMetricMetadataRequestDTO {
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface MetrictypesComparisonSpaceAggregationParamDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
operator: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export enum MetrictypesSpaceAggregationDTO {
|
||||
sum = 'sum',
|
||||
avg = 'avg',
|
||||
@@ -1379,7 +1367,6 @@ export interface Querybuildertypesv5LogAggregationDTO {
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5MetricAggregationDTO {
|
||||
comparisonSpaceAggregationParam?: MetrictypesComparisonSpaceAggregationParamDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -50,7 +50,6 @@ export interface HostListResponse {
|
||||
total: number;
|
||||
sentAnyHostMetricsData: boolean;
|
||||
isSendingK8SAgentMetrics: boolean;
|
||||
endTimeBeforeRetention: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
26
frontend/src/api/metricsExplorer/v2/getMetricMetadata.ts
Normal file
26
frontend/src/api/metricsExplorer/v2/getMetricMetadata.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
|
||||
export const getMetricMetadata = async (
|
||||
metricName: string,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
|
||||
try {
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const response = await axios.get(`/metrics/${encodedMetricName}/metadata`, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
@@ -248,11 +248,6 @@ declare module 'chart.js' {
|
||||
}
|
||||
}
|
||||
|
||||
const intlNumberFormatter = new Intl.NumberFormat('en-US', {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 20,
|
||||
});
|
||||
|
||||
/**
|
||||
* Formats a number for display, preserving leading zeros after the decimal point
|
||||
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
|
||||
@@ -275,7 +270,10 @@ export const formatDecimalWithLeadingZeros = (
|
||||
}
|
||||
|
||||
// Use toLocaleString to get a full decimal representation without scientific notation.
|
||||
const numStr = intlNumberFormatter.format(value);
|
||||
const numStr = value.toLocaleString('en-US', {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 20,
|
||||
});
|
||||
|
||||
const [integerPart, decimalPart = ''] = numStr.split('.');
|
||||
|
||||
|
||||
@@ -86,13 +86,8 @@ function LogDetailInner({
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Don't close if clicking on drawer content, overlays, or portal elements
|
||||
if (
|
||||
target.closest('[data-log-detail-ignore="true"]') ||
|
||||
target.closest('.cm-tooltip-autocomplete') ||
|
||||
target.closest('.drawer-popover') ||
|
||||
target.closest('.query-status-popover')
|
||||
) {
|
||||
// Don't close if clicking on explicitly ignored regions
|
||||
if (target.closest('[data-log-detail-ignore="true"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -405,11 +400,7 @@ function LogDetailInner({
|
||||
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(log?.body)}
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
|
||||
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -475,7 +466,6 @@ function LogDetailInner({
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
@@ -491,7 +481,6 @@ function LogDetailInner({
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
|
||||
@@ -27,11 +27,7 @@ function AddToQueryHOC({
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className={cx('addToQueryContainer', fontSize)} onClick={handleQueryAdd}>
|
||||
<Popover
|
||||
overlayClassName="drawer-popover"
|
||||
placement="top"
|
||||
content={popOverContent}
|
||||
>
|
||||
<Popover placement="top" content={popOverContent}>
|
||||
{children}
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ function CopyClipboardHOC({
|
||||
<span onClick={onClick} role="presentation" tabIndex={-1}>
|
||||
<Popover
|
||||
placement="top"
|
||||
overlayClassName="drawer-popover"
|
||||
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
|
||||
export const defaultTableStyle: CSSProperties = {
|
||||
minWidth: '40rem',
|
||||
maxWidth: '90rem',
|
||||
maxWidth: '60rem',
|
||||
};
|
||||
|
||||
export const defaultListViewPanelStyle: CSSProperties = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
@@ -85,7 +86,6 @@ interface QuerySearchProps {
|
||||
signalSource?: string;
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
@@ -96,7 +96,6 @@ function QuerySearch({
|
||||
onRun,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
@@ -253,8 +252,7 @@ function QuerySearch({
|
||||
async (searchText?: string): Promise<void> => {
|
||||
if (
|
||||
dataSource === DataSource.METRICS &&
|
||||
!queryData.aggregateAttribute?.key &&
|
||||
!showFilterSuggestionsWithoutMetric
|
||||
!queryData.aggregateAttribute?.key
|
||||
) {
|
||||
setKeySuggestions([]);
|
||||
return;
|
||||
@@ -303,7 +301,6 @@ function QuerySearch({
|
||||
queryData.aggregateAttribute?.key,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -567,7 +564,15 @@ function QuerySearch({
|
||||
const lastPos = lastPosRef.current;
|
||||
|
||||
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
|
||||
setCursorPos(newPos);
|
||||
setCursorPos((lastPos) => {
|
||||
if (newPos.ch !== lastPos.ch && newPos.ch === 0) {
|
||||
Sentry.captureEvent({
|
||||
message: `Cursor jumped to start of line from ${lastPos.ch} to ${newPos.ch}`,
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
return newPos;
|
||||
});
|
||||
lastPosRef.current = newPos;
|
||||
|
||||
if (doc) {
|
||||
@@ -1323,10 +1328,7 @@ function QuerySearch({
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
<Tooltip
|
||||
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
|
||||
placement="left"
|
||||
>
|
||||
<Tooltip title={getTooltipContent()} placement="left">
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/search-syntax/"
|
||||
target="_blank"
|
||||
@@ -1560,7 +1562,6 @@ QuerySearch.defaultProps = {
|
||||
hardcodedAttributeKeys: undefined,
|
||||
placeholder:
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
|
||||
showFilterSuggestionsWithoutMetric: false,
|
||||
};
|
||||
|
||||
export default QuerySearch;
|
||||
|
||||
@@ -14,10 +14,16 @@ interface ITimelineV2Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
}
|
||||
|
||||
function TimelineV2(props: ITimelineV2Props): JSX.Element {
|
||||
const { startTimestamp, endTimestamp, timelineHeight } = props;
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -30,8 +36,10 @@ function TimelineV2(props: ITimelineV2Props): JSX.Element {
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
setIntervals(getIntervals(intervalisedSpread, spread));
|
||||
}, [startTimestamp, endTimestamp, width]);
|
||||
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
|
||||
|
||||
setIntervals(intervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
|
||||
@@ -64,6 +64,71 @@ export const resolveTimeFromInterval = (
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number, // ms offset from trace start (e.g. viewStart - traceStart)
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
|
||||
const intervalSpreadNormalized =
|
||||
intervalSpread < 1.0
|
||||
? intervalSpread
|
||||
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
|
||||
10 ** (integerPartLength - 1);
|
||||
|
||||
let intervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
const standardInterval = INTERVAL_UNITS[idx];
|
||||
if (intervalSpread * standardInterval.multiplier >= 1) {
|
||||
intervalUnit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervals: Interval[] = [
|
||||
{
|
||||
// ✅ start label should reflect window start offset (relative to trace start)
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let tempBaseSpread = baseSpread;
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (tempBaseSpread && intervals.length < 20) {
|
||||
let intervalTime: number;
|
||||
|
||||
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
|
||||
intervalTime = elapsedIntervals + tempBaseSpread;
|
||||
tempBaseSpread = 0;
|
||||
} else {
|
||||
intervalTime = elapsedIntervals + intervalSpreadNormalized;
|
||||
tempBaseSpread -= intervalSpreadNormalized;
|
||||
}
|
||||
|
||||
elapsedIntervals = intervalTime;
|
||||
|
||||
// ✅ label time = window offset + elapsed time inside window
|
||||
const labelTime = offsetTimestamp + intervalTime;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
|
||||
export function getIntervalsOld(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
@@ -106,9 +171,10 @@ export function getIntervals(
|
||||
}
|
||||
elapsedIntervals = intervalTime;
|
||||
const interval: Interval = {
|
||||
label: `${toFixed(resolveTimeFromInterval(intervalTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(intervalTime + offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
};
|
||||
intervals.push(interval);
|
||||
|
||||
@@ -40,7 +40,6 @@ function ValueGraph({
|
||||
value,
|
||||
rawValue,
|
||||
thresholds,
|
||||
yAxisUnit,
|
||||
}: ValueGraphProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -88,7 +87,7 @@ function ValueGraph({
|
||||
const {
|
||||
threshold,
|
||||
isConflictingThresholds,
|
||||
} = getBackgroundColorAndThresholdCheck(thresholds, rawValue, yAxisUnit);
|
||||
} = getBackgroundColorAndThresholdCheck(thresholds, rawValue);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -156,7 +155,6 @@ interface ValueGraphProps {
|
||||
value: string;
|
||||
rawValue: number;
|
||||
thresholds: ThresholdProps[];
|
||||
yAxisUnit?: string;
|
||||
}
|
||||
|
||||
export default ValueGraph;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { evaluateThresholdWithConvertedValue } from 'container/GridTableComponent/utils';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
|
||||
function doesValueSatisfyThreshold(
|
||||
function compareThreshold(
|
||||
rawValue: number,
|
||||
threshold: ThresholdProps,
|
||||
yAxisUnit?: string,
|
||||
): boolean {
|
||||
if (
|
||||
threshold.thresholdOperator === undefined ||
|
||||
@@ -12,14 +11,31 @@ function doesValueSatisfyThreshold(
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
switch (threshold.thresholdOperator) {
|
||||
case '>':
|
||||
return rawValue > threshold.thresholdValue;
|
||||
case '>=':
|
||||
return rawValue >= threshold.thresholdValue;
|
||||
case '<':
|
||||
return rawValue < threshold.thresholdValue;
|
||||
case '<=':
|
||||
return rawValue <= threshold.thresholdValue;
|
||||
case '=':
|
||||
return rawValue === threshold.thresholdValue;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return evaluateThresholdWithConvertedValue(
|
||||
rawValue,
|
||||
threshold.thresholdValue,
|
||||
threshold.thresholdOperator,
|
||||
threshold.thresholdUnit,
|
||||
yAxisUnit,
|
||||
);
|
||||
function extractNumbersFromString(inputString: string): number[] {
|
||||
const regex = /[+-]?\d+(\.\d+)?/g;
|
||||
const matches = inputString.match(regex);
|
||||
|
||||
if (matches) {
|
||||
return matches.map(Number);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getHighestPrecedenceThreshold(
|
||||
@@ -44,32 +60,21 @@ function getHighestPrecedenceThreshold(
|
||||
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(
|
||||
thresholds: ThresholdProps[],
|
||||
rawValue: number,
|
||||
yAxisUnit?: string,
|
||||
): {
|
||||
threshold: ThresholdProps;
|
||||
isConflictingThresholds: boolean;
|
||||
} {
|
||||
const matchingThresholds = thresholds.filter((threshold) => {
|
||||
const numbers = extractNumbersFromString(rawValue.toString());
|
||||
if (numbers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return doesValueSatisfyThreshold(numbers[0], threshold, yAxisUnit);
|
||||
});
|
||||
const matchingThresholds = thresholds.filter((threshold) =>
|
||||
compareThreshold(
|
||||
extractNumbersFromString(
|
||||
getYAxisFormattedValue(rawValue.toString(), threshold.thresholdUnit || ''),
|
||||
)[0],
|
||||
threshold,
|
||||
),
|
||||
);
|
||||
|
||||
if (matchingThresholds.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -22,8 +22,6 @@ function YAxisUnitSelector({
|
||||
'data-testid': dataTestId,
|
||||
source,
|
||||
initialValue,
|
||||
categoriesOverride,
|
||||
containerClassName,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
const universalUnit = mapMetricUnitToUniversalUnit(value);
|
||||
|
||||
@@ -68,14 +66,10 @@ function YAxisUnitSelector({
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(search));
|
||||
};
|
||||
|
||||
const categoriesToRender = useMemo(() => {
|
||||
return categoriesOverride || getYAxisCategories(source);
|
||||
}, [categoriesOverride, source]);
|
||||
const categories = getYAxisCategories(source);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('y-axis-unit-selector-component', containerClassName)}
|
||||
>
|
||||
<div className="y-axis-unit-selector-component">
|
||||
<Select
|
||||
showSearch
|
||||
value={universalUnit}
|
||||
@@ -96,7 +90,7 @@ function YAxisUnitSelector({
|
||||
data-testid={dataTestId}
|
||||
allowClear
|
||||
>
|
||||
{categoriesToRender.map((category) => (
|
||||
{categories.map((category) => (
|
||||
<Select.OptGroup key={category.name} label={category.name}>
|
||||
{category.units.map((unit) => (
|
||||
<Select.Option key={unit.id} value={unit.id}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { YAxisCategoryNames } from '../constants';
|
||||
import { UniversalYAxisUnit, YAxisSource } from '../types';
|
||||
import { YAxisSource } from '../types';
|
||||
import YAxisUnitSelector from '../YAxisUnitSelector';
|
||||
|
||||
describe('YAxisUnitSelector', () => {
|
||||
@@ -124,34 +123,4 @@ describe('YAxisUnitSelector', () => {
|
||||
const warningIcon = screen.queryByLabelText('warning');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,6 @@ export interface YAxisUnitSelectorProps {
|
||||
'data-testid'?: string;
|
||||
source: YAxisSource;
|
||||
initialValue?: string;
|
||||
categoriesOverride?: YAxisCategory[];
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export enum UniversalYAxisUnit {
|
||||
|
||||
@@ -117,7 +117,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
const [showSlowApiWarning, setShowSlowApiWarning] = useState(false);
|
||||
const [slowApiWarningShown, setSlowApiWarningShown] = useState(false);
|
||||
|
||||
const { currentVersion } = useSelector<AppState, AppReducer>(
|
||||
const { latestVersion } = useSelector<AppState, AppReducer>(
|
||||
(state) => state.app,
|
||||
);
|
||||
|
||||
@@ -213,9 +213,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
},
|
||||
{
|
||||
queryFn: (): Promise<SuccessResponse<ChangelogSchema> | ErrorResponse> =>
|
||||
getChangelogByVersion(currentVersion, changelogForTenant),
|
||||
queryKey: ['getChangelogByVersion', currentVersion, changelogForTenant],
|
||||
enabled: isLoggedIn && Boolean(currentVersion),
|
||||
getChangelogByVersion(latestVersion, changelogForTenant),
|
||||
queryKey: ['getChangelogByVersion', latestVersion, changelogForTenant],
|
||||
enabled: isLoggedIn && Boolean(latestVersion),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -226,7 +226,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
!changelog &&
|
||||
!getChangelogByVersionResponse.isLoading &&
|
||||
isLoggedIn &&
|
||||
Boolean(currentVersion)
|
||||
Boolean(latestVersion)
|
||||
) {
|
||||
getChangelogByVersionResponse.refetch();
|
||||
}
|
||||
@@ -237,9 +237,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
if (
|
||||
isCloudUserVal &&
|
||||
Boolean(currentVersion) &&
|
||||
Boolean(latestVersion) &&
|
||||
seenChangelogVersion != null &&
|
||||
currentVersion !== seenChangelogVersion &&
|
||||
latestVersion !== seenChangelogVersion &&
|
||||
daysSinceAccountCreation > MIN_ACCOUNT_AGE_FOR_CHANGELOG && // Show to only users older than 2 weeks
|
||||
!isWorkspaceAccessRestricted
|
||||
) {
|
||||
@@ -255,7 +255,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isCloudUserVal,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
seenChangelogVersion,
|
||||
toggleChangelogModal,
|
||||
isWorkspaceAccessRestricted,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
enqueueFetchOfAllVariables,
|
||||
@@ -32,9 +31,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = useDashboardVariablesSelector(
|
||||
(state) => state.dashboardId,
|
||||
);
|
||||
const sortedVariablesArray = useDashboardVariablesSelector(
|
||||
(state) => state.sortedVariablesArray,
|
||||
);
|
||||
@@ -100,28 +96,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
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) => {
|
||||
if (prev) {
|
||||
const oldVariables = { ...prev?.data.variables };
|
||||
@@ -156,12 +130,10 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Cascade: enqueue query-type descendants for refetching.
|
||||
// Safe to call synchronously now that the store already has the updated value.
|
||||
// Cascade: enqueue query-type descendants for refetching
|
||||
enqueueDescendantsOfVariable(name);
|
||||
},
|
||||
[
|
||||
dashboardId,
|
||||
dashboardVariables,
|
||||
updateLocalStorageDashboardVariables,
|
||||
updateUrlVariable,
|
||||
|
||||
@@ -5,7 +5,7 @@ import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQ
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import { isArray, isEmpty } from 'lodash-es';
|
||||
import { isArray, isEmpty, isString } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -54,7 +54,7 @@ function QueryVariableInput({
|
||||
onChange,
|
||||
onDropdownVisibleChange,
|
||||
handleClear,
|
||||
getDefaultValue,
|
||||
applyDefaultIfNeeded,
|
||||
} = useDashboardVariableSelectHelper({
|
||||
variableData,
|
||||
optionsData,
|
||||
@@ -68,93 +68,81 @@ function QueryVariableInput({
|
||||
try {
|
||||
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 (
|
||||
!variablesRes?.variableValues ||
|
||||
!Array.isArray(variablesRes?.variableValues)
|
||||
variablesRes?.variableValues &&
|
||||
Array.isArray(variablesRes?.variableValues)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
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),
|
||||
const newOptionsData = sortValues(
|
||||
variablesRes?.variableValues,
|
||||
variableData.sort,
|
||||
);
|
||||
} else if (
|
||||
variableData.selectedValue &&
|
||||
!sortedNewOptions.includes(variableData.selectedValue)
|
||||
) {
|
||||
isSelectedValueMissingInNewOptions = true;
|
||||
}
|
||||
|
||||
// If multi-select with ALL option enabled, and ALL is currently selected, we want to maintain that state and select all new options
|
||||
// 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);
|
||||
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
|
||||
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(sortedNewOptions.map((option) => option.toString()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
|
||||
let valueNotInList = false;
|
||||
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
if (isArray(variableData.selectedValue)) {
|
||||
variableData.selectedValue.forEach((val) => {
|
||||
if (!newOptionsData.includes(val)) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
isString(variableData.selectedValue) &&
|
||||
!newOptionsData.includes(variableData.selectedValue)
|
||||
) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
sortedNewOptions.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
sortedNewOptions.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
if (variableData.name && (valueNotInList || variableData.allSelected)) {
|
||||
if (
|
||||
variableData.allSelected &&
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption
|
||||
) {
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
newOptionsData,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
} else {
|
||||
const defaultValue = getDefaultValue(sortedNewOptions);
|
||||
if (defaultValue !== undefined) {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
defaultValue,
|
||||
allSelected,
|
||||
);
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(newOptionsData.map((option) => option.toString()));
|
||||
}
|
||||
} else {
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
newOptionsData.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOptionsData(newOptionsData);
|
||||
// Apply default if no value is selected (e.g., new variable, first load)
|
||||
applyDefaultIfNeeded(newOptionsData);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -167,7 +155,7 @@ function QueryVariableInput({
|
||||
onValueUpdate,
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
getDefaultValue,
|
||||
applyDefaultIfNeeded,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { act, render } from '@testing-library/react';
|
||||
import * as dashboardVariablesStoreModule from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
dashboardVariablesStore,
|
||||
setDashboardVariablesStore,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
IDashboardVariablesStoreState,
|
||||
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
enqueueFetchOfAllVariables,
|
||||
initializeVariableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
@@ -19,17 +17,6 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
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
|
||||
const mockSetSelectedDashboard = jest.fn();
|
||||
const mockUpdateLocalStorageDashboardVariables = jest.fn();
|
||||
@@ -69,14 +56,10 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
|
||||
}));
|
||||
|
||||
// VariableItem mock captures the onValueUpdate prop for use in onValueUpdate tests
|
||||
// Mock VariableItem to avoid rendering complexity
|
||||
jest.mock('../VariableItem', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any): JSX.Element => {
|
||||
mockVariableItemCallbacks.onValueUpdate = props.onValueUpdate;
|
||||
return <div data-testid="variable-item" />;
|
||||
},
|
||||
default: (): JSX.Element => <div data-testid="variable-item" />,
|
||||
}));
|
||||
|
||||
function createVariable(
|
||||
@@ -217,162 +200,4 @@ describe('DashboardVariableSelection', () => {
|
||||
expect(initializeVariableFetchStore).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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { variableFetchStore } from 'providers/Dashboard/store/variableFetchStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import QueryVariableInput from '../QueryVariableInput';
|
||||
|
||||
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
|
||||
}));
|
||||
|
||||
function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
children,
|
||||
queryClient,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
queryClient: QueryClient;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function createVariable(
|
||||
overrides: Partial<IDashboardVariable> = {},
|
||||
): IDashboardVariable {
|
||||
return {
|
||||
id: 'env-id',
|
||||
name: 'env',
|
||||
description: '',
|
||||
type: 'QUERY',
|
||||
sort: 'DISABLED',
|
||||
showALLOption: false,
|
||||
multiSelect: false,
|
||||
order: 0,
|
||||
queryValue: 'SELECT env FROM table',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Put the named variable into 'loading' state so useQuery fires on mount */
|
||||
function setVariableLoading(name: string): void {
|
||||
variableFetchStore.update((draft) => {
|
||||
draft.states[name] = 'loading';
|
||||
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
function resetFetchStore(): void {
|
||||
variableFetchStore.set(() => ({
|
||||
states: {},
|
||||
lastUpdated: {},
|
||||
cycleIds: {},
|
||||
}));
|
||||
}
|
||||
|
||||
describe('QueryVariableInput - getOptions logic', () => {
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetFetchStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetFetchStore();
|
||||
});
|
||||
|
||||
it('applies default value (first option) when selectedValue is empty on first load', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging', 'dev'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: undefined });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
'production', // first option by default
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps existing selectedValue when it is present in new options', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: 'staging' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
'staging',
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('selects all new options when allSelected=true and value is missing from new options', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({
|
||||
selectedValue: ['old-env'],
|
||||
allSelected: true,
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
});
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
['production', 'staging'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onValueUpdate a second time when options have not changed', async () => {
|
||||
const mockQueryFn = jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
(dashboardVariablesQuery as jest.Mock).mockImplementation(mockQueryFn);
|
||||
|
||||
const variable = createVariable({ selectedValue: 'production' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Wait for first fetch and onValueUpdate call
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockOnValueUpdate.mockClear();
|
||||
|
||||
// Trigger a second fetch cycle with the same API response
|
||||
act(() => {
|
||||
variableFetchStore.update((draft) => {
|
||||
draft.states['env'] = 'revalidating';
|
||||
draft.cycleIds['env'] = (draft.cycleIds['env'] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for second query to fire
|
||||
await waitFor(() => {
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Options are unchanged, so onValueUpdate must not fire again
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onValueUpdate when API returns a non-array response', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: null },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: 'production' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dashboardVariablesQuery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire the query when variableData.name is empty', () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production'] },
|
||||
});
|
||||
|
||||
// Variable with no name — useVariableFetchState will be called with ''
|
||||
// and the query key will have an empty name, leaving it disabled
|
||||
const variable = createVariable({ name: '' });
|
||||
// Note: we do NOT put it in 'loading' state since name is empty
|
||||
// (no variableFetchStore entry for '' means isVariableFetching=false)
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(dashboardVariablesQuery).not.toHaveBeenCalled();
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -46,9 +46,6 @@ interface UseDashboardVariableSelectHelperReturn {
|
||||
applyDefaultIfNeeded: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => void;
|
||||
getDefaultValue: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => string | string[] | undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -251,6 +248,5 @@ export function useDashboardVariableSelectHelper({
|
||||
defaultValue,
|
||||
onChange,
|
||||
applyDefaultIfNeeded,
|
||||
getDefaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function evaluateCondition(
|
||||
* @param columnUnit - The current unit of the value.
|
||||
* @returns A boolean indicating whether the value meets the threshold condition.
|
||||
*/
|
||||
export function evaluateThresholdWithConvertedValue(
|
||||
function evaluateThresholdWithConvertedValue(
|
||||
value: number,
|
||||
thresholdValue: number,
|
||||
thresholdOperator?: string,
|
||||
|
||||
@@ -99,7 +99,6 @@ function GridValueComponent({
|
||||
<ValueGraph
|
||||
thresholds={thresholds || []}
|
||||
rawValue={value}
|
||||
yAxisUnit={yAxisUnit}
|
||||
value={
|
||||
yAxisUnit
|
||||
? getYAxisFormattedValue(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Skeleton,
|
||||
@@ -14,93 +14,12 @@ import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import HostsEmptyOrIncorrectMetrics from './HostsEmptyOrIncorrectMetrics';
|
||||
import {
|
||||
EmptyOrLoadingViewProps,
|
||||
formatDataForTable,
|
||||
getHostsListColumns,
|
||||
HostRowData,
|
||||
HostsListTableProps,
|
||||
} 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({
|
||||
isLoading,
|
||||
isFetching,
|
||||
@@ -127,11 +46,6 @@ export default function HostsListTable({
|
||||
[data],
|
||||
);
|
||||
|
||||
const endTimeBeforeRetention = useMemo(
|
||||
() => data?.payload?.data?.endTimeBeforeRetention || false,
|
||||
[data],
|
||||
);
|
||||
|
||||
const formattedHostMetricsData = useMemo(
|
||||
() => formatDataForTable(hostMetricsData),
|
||||
[hostMetricsData],
|
||||
@@ -170,6 +84,12 @@ export default function HostsListTable({
|
||||
});
|
||||
};
|
||||
|
||||
const showNoFilteredHostsMessage =
|
||||
!isFetching &&
|
||||
!isLoading &&
|
||||
formattedHostMetricsData.length === 0 &&
|
||||
filters.items.length > 0;
|
||||
|
||||
const showHostsEmptyState =
|
||||
!isFetching &&
|
||||
!isLoading &&
|
||||
@@ -177,36 +97,63 @@ export default function HostsListTable({
|
||||
(!sentAnyHostMetricsData || isSendingIncorrectK8SAgentMetrics) &&
|
||||
!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 =
|
||||
(isLoading || isFetching) && formattedHostMetricsData.length === 0;
|
||||
|
||||
const emptyOrLoadingView = EmptyOrLoadingView({
|
||||
isError,
|
||||
errorMessage: data?.error ?? '',
|
||||
showHostsEmptyState,
|
||||
sentAnyHostMetricsData,
|
||||
isSendingIncorrectK8SAgentMetrics,
|
||||
showEndTimeBeforeRetentionMessage,
|
||||
showNoRecordsInSelectedTimeRangeMessage,
|
||||
showTableLoadingState,
|
||||
});
|
||||
if (isError) {
|
||||
return <Typography>{data?.error || 'Something went wrong'}</Typography>;
|
||||
}
|
||||
|
||||
if (emptyOrLoadingView) {
|
||||
return <>{emptyOrLoadingView}</>;
|
||||
if (showHostsEmptyState) {
|
||||
return (
|
||||
<HostsEmptyOrIncorrectMetrics
|
||||
noData={!sentAnyHostMetricsData}
|
||||
incorrectData={isSendingIncorrectK8SAgentMetrics}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showNoFilteredHostsMessage) {
|
||||
return (
|
||||
<div className="no-filtered-hosts-message-container">
|
||||
<div className="no-filtered-hosts-message-content">
|
||||
<img
|
||||
src="/Icons/emptyState.svg"
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
|
||||
<Typography.Text className="no-filtered-hosts-message">
|
||||
This query had no results. Edit your query and try again!
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showTableLoadingState) {
|
||||
return (
|
||||
<div className="hosts-list-loading-state">
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
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 { HostsListTableProps } from '../utils';
|
||||
|
||||
const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container';
|
||||
|
||||
const createMockHost = (): HostData =>
|
||||
({
|
||||
describe('HostsListTable', () => {
|
||||
const mockHost = {
|
||||
hostName: 'test-host-1',
|
||||
active: true,
|
||||
cpu: 0.75,
|
||||
@@ -18,46 +14,20 @@ const createMockHost = (): HostData =>
|
||||
wait: 0.03,
|
||||
load15: 1.5,
|
||||
os: 'linux',
|
||||
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
} as HostData);
|
||||
};
|
||||
|
||||
const createMockTableData = (
|
||||
overrides: Partial<HostListResponse['data']> = {},
|
||||
): SuccessResponse<HostListResponse> => {
|
||||
const mockHost = createMockHost();
|
||||
return {
|
||||
statusCode: 200,
|
||||
message: 'Success',
|
||||
error: null,
|
||||
const mockTableData = {
|
||||
payload: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'list',
|
||||
records: [mockHost],
|
||||
groups: null,
|
||||
total: 1,
|
||||
sentAnyHostMetricsData: true,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
endTimeBeforeRetention: false,
|
||||
...overrides,
|
||||
hosts: [mockHost],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('HostsListTable', () => {
|
||||
const mockHost = createMockHost();
|
||||
const mockTableData = createMockTableData();
|
||||
|
||||
const mockOnHostClick = jest.fn();
|
||||
const mockSetCurrentPage = jest.fn();
|
||||
const mockSetOrderBy = jest.fn();
|
||||
const mockSetPageSize = jest.fn();
|
||||
|
||||
const mockProps: HostsListTableProps = {
|
||||
const mockProps = {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
@@ -73,7 +43,7 @@ describe('HostsListTable', () => {
|
||||
pageSize: 10,
|
||||
setOrderBy: mockSetOrderBy,
|
||||
setPageSize: mockSetPageSize,
|
||||
};
|
||||
} as any;
|
||||
|
||||
it('renders loading state if isLoading is true and tableData is empty', () => {
|
||||
const { container } = render(
|
||||
@@ -81,7 +51,7 @@ describe('HostsListTable', () => {
|
||||
{...mockProps}
|
||||
isLoading
|
||||
hostMetricsData={[]}
|
||||
tableData={createMockTableData({ records: [] })}
|
||||
tableData={{ payload: { data: { hosts: [] } } }}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy();
|
||||
@@ -93,7 +63,7 @@ describe('HostsListTable', () => {
|
||||
{...mockProps}
|
||||
isFetching
|
||||
hostMetricsData={[]}
|
||||
tableData={createMockTableData({ records: [] })}
|
||||
tableData={{ payload: { data: { hosts: [] } } }}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.hosts-list-loading-state')).toBeTruthy();
|
||||
@@ -104,56 +74,19 @@ describe('HostsListTable', () => {
|
||||
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', () => {
|
||||
const { container } = render(
|
||||
<HostsListTable
|
||||
{...mockProps}
|
||||
hostMetricsData={[]}
|
||||
tableData={createMockTableData({
|
||||
records: [],
|
||||
})}
|
||||
tableData={{
|
||||
payload: {
|
||||
data: { hosts: [] },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.no-filtered-hosts-message-container'),
|
||||
).toBeTruthy();
|
||||
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders empty state if sentAnyHostMetricsData is false', () => {
|
||||
@@ -161,114 +94,58 @@ describe('HostsListTable', () => {
|
||||
<HostsListTable
|
||||
{...mockProps}
|
||||
hostMetricsData={[]}
|
||||
tableData={createMockTableData({
|
||||
sentAnyHostMetricsData: false,
|
||||
records: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders empty state if isSendingK8SAgentMetrics is true', () => {
|
||||
const { container } = render(
|
||||
<HostsListTable
|
||||
{...mockProps}
|
||||
hostMetricsData={[]}
|
||||
tableData={createMockTableData({
|
||||
isSendingK8SAgentMetrics: true,
|
||||
records: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders end time before retention message when endTimeBeforeRetention is true', () => {
|
||||
const { container } = render(
|
||||
<HostsListTable
|
||||
{...mockProps}
|
||||
hostMetricsData={[]}
|
||||
tableData={createMockTableData({
|
||||
sentAnyHostMetricsData: true,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
endTimeBeforeRetention: true,
|
||||
records: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Your requested end time is earlier than the earliest detected time of host metrics data, please adjust your end time\./,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no records message when noRecordsInSelectedTimeRangeAndFilters is true', () => {
|
||||
const { container } = render(
|
||||
<HostsListTable
|
||||
{...mockProps}
|
||||
hostMetricsData={[]}
|
||||
tableData={createMockTableData({
|
||||
sentAnyHostMetricsData: true,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
records: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.no-filtered-hosts-message-container'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/No host metrics in the selected time range and filters/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no filtered hosts message when filters are present and no hosts are found', () => {
|
||||
const { container } = render(
|
||||
<HostsListTable
|
||||
{...mockProps}
|
||||
hostMetricsData={[]}
|
||||
filters={{
|
||||
items: [
|
||||
{
|
||||
id: 'host_name',
|
||||
key: {
|
||||
key: 'host_name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
isIndexed: true,
|
||||
},
|
||||
op: '=',
|
||||
value: 'unknown',
|
||||
tableData={{
|
||||
...mockTableData,
|
||||
payload: {
|
||||
...mockTableData.payload,
|
||||
data: {
|
||||
...mockTableData.payload.data,
|
||||
sentAnyHostMetricsData: false,
|
||||
hosts: [],
|
||||
},
|
||||
],
|
||||
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();
|
||||
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders empty state if isSendingIncorrectK8SAgentMetrics is true', () => {
|
||||
const { container } = render(
|
||||
<HostsListTable
|
||||
{...mockProps}
|
||||
hostMetricsData={[]}
|
||||
tableData={{
|
||||
...mockTableData,
|
||||
payload: {
|
||||
...mockTableData.payload,
|
||||
data: {
|
||||
...mockTableData.payload.data,
|
||||
isSendingIncorrectK8SAgentMetrics: true,
|
||||
hosts: [],
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(EMPTY_STATE_CONTAINER_CLASS)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders table data', () => {
|
||||
const { container } = render(
|
||||
<HostsListTable
|
||||
{...mockProps}
|
||||
tableData={createMockTableData({
|
||||
isSendingK8SAgentMetrics: false,
|
||||
sentAnyHostMetricsData: true,
|
||||
})}
|
||||
tableData={{
|
||||
...mockTableData,
|
||||
payload: {
|
||||
...mockTableData.payload,
|
||||
data: {
|
||||
...mockTableData.payload.data,
|
||||
isSendingIncorrectK8SAgentMetrics: false,
|
||||
sentAnyHostMetricsData: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.hosts-list-table')).toBeTruthy();
|
||||
|
||||
@@ -107,17 +107,6 @@ export interface HostsListTableProps {
|
||||
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 => ({
|
||||
filters: {
|
||||
items: [],
|
||||
|
||||
@@ -121,23 +121,9 @@ function BodyTitleRenderer({
|
||||
return (
|
||||
<TitleWrapper onClick={handleNodeClick}>
|
||||
{typeof value !== 'object' && (
|
||||
<span
|
||||
onClick={(e): void => {
|
||||
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>
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
{!parentIsArray && typeof value !== 'object' && (
|
||||
|
||||
@@ -13,7 +13,7 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
|
||||
<span className="field-renderer-container">
|
||||
{dataType && newField && logType ? (
|
||||
<>
|
||||
<Tooltip placement="left" title={newField} mouseLeaveDelay={0}>
|
||||
<Tooltip placement="left" title={newField}>
|
||||
<Typography.Text ellipsis className="label">
|
||||
{newField}{' '}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -46,7 +46,7 @@ function Overview({
|
||||
handleChangeSelectedView,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||
const [isAttributesExpanded, setIsAttributesExpanded] = useState<boolean>(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -245,7 +245,7 @@ function TableView({
|
||||
<Typography.Text>{renderedField}</Typography.Text>
|
||||
|
||||
{traceId && (
|
||||
<Tooltip title="Inspect in Trace" mouseLeaveDelay={0}>
|
||||
<Tooltip title="Inspect in Trace">
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
onClick={(
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
import { getColorsForSeverityLabels, isRedLike } from '../utils';
|
||||
|
||||
describe('getColorsForSeverityLabels', () => {
|
||||
it('should return slate for blank labels', () => {
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300);
|
||||
});
|
||||
|
||||
it('should return correct colors for known severity variants', () => {
|
||||
expect(getColorsForSeverityLabels('INFO', 0)).toBe(Color.BG_ROBIN_600);
|
||||
expect(getColorsForSeverityLabels('ERROR', 0)).toBe(Color.BG_CHERRY_600);
|
||||
expect(getColorsForSeverityLabels('WARN', 0)).toBe(Color.BG_AMBER_600);
|
||||
expect(getColorsForSeverityLabels('DEBUG', 0)).toBe(Color.BG_AQUA_600);
|
||||
expect(getColorsForSeverityLabels('TRACE', 0)).toBe(Color.BG_FOREST_600);
|
||||
expect(getColorsForSeverityLabels('FATAL', 0)).toBe(Color.BG_SAKURA_600);
|
||||
});
|
||||
|
||||
it('should return non-red colors for unrecognized labels at any index', () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const color = getColorsForSeverityLabels('4', i);
|
||||
expect(isRedLike(color)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return non-red colors for numeric severity text', () => {
|
||||
const numericLabels = ['1', '2', '4', '9', '13', '17', '21'];
|
||||
numericLabels.forEach((label) => {
|
||||
const color = getColorsForSeverityLabels(label, 0);
|
||||
expect(isRedLike(color)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
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> = {
|
||||
TRACE: Color.BG_FOREST_600,
|
||||
Trace: Color.BG_FOREST_500,
|
||||
@@ -76,13 +67,8 @@ export function getColorsForSeverityLabels(
|
||||
label: string,
|
||||
index: number,
|
||||
): string {
|
||||
const trimmed = label.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return Color.BG_SLATE_300;
|
||||
}
|
||||
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
|
||||
// Check if we have a direct mapping for this severity variant
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[label.trim()];
|
||||
if (variantColor) {
|
||||
return variantColor;
|
||||
}
|
||||
@@ -117,8 +103,5 @@ export function getColorsForSeverityLabels(
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
|
||||
return (
|
||||
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
|
||||
Color.BG_SLATE_400
|
||||
);
|
||||
return colors[index % colors.length] || themeColors.red;
|
||||
}
|
||||
|
||||
@@ -111,19 +111,23 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
);
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: number, log: Record<string, unknown>): JSX.Element => (
|
||||
<TableRow
|
||||
tableColumns={tableColumns}
|
||||
index={index}
|
||||
log={log}
|
||||
logs={tableViewProps.logs}
|
||||
hasActions
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onShowLogDetails={onSetActiveLog}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
onClearActiveLog={onCloseActiveLog}
|
||||
/>
|
||||
),
|
||||
(index: number, log: Record<string, unknown>): JSX.Element => {
|
||||
return (
|
||||
<div key={log.id as string}>
|
||||
<TableRow
|
||||
tableColumns={tableColumns}
|
||||
index={index}
|
||||
log={log}
|
||||
logs={tableViewProps.logs}
|
||||
hasActions
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onShowLogDetails={onSetActiveLog}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
onClearActiveLog={onCloseActiveLog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
tableColumns,
|
||||
onSetActiveLog,
|
||||
|
||||
@@ -87,7 +87,7 @@ function Explorer(): JSX.Element {
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
|
||||
|
||||
const unitsLength = useMemo(() => units.length, [units]);
|
||||
const firstUnit = useMemo(() => units[0], [units]);
|
||||
const firstUnit = useMemo(() => units?.[0], [units]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the y axis unit to the first metric unit if
|
||||
@@ -349,7 +349,7 @@ function Explorer(): JSX.Element {
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
{isMetricDetailsOpen && selectedMetricName && (
|
||||
{isMetricDetailsOpen && (
|
||||
<MetricDetails
|
||||
metricName={selectedMetricName}
|
||||
isOpen={isMetricDetailsOpen}
|
||||
|
||||
@@ -39,7 +39,10 @@ function RelatedMetricsCard({ metric }: RelatedMetricsCardProps): JSX.Element {
|
||||
dataSource={DataSource.METRICS}
|
||||
/>
|
||||
)}
|
||||
<DashboardsAndAlertsPopover metricName={metric.name} />
|
||||
<DashboardsAndAlertsPopover
|
||||
dashboards={metric.dashboards}
|
||||
alerts={metric.alerts}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries, useQueryClient } from 'react-query';
|
||||
import { useQueries } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import {
|
||||
invalidateGetMetricMetadata,
|
||||
useUpdateMetricMetadata,
|
||||
} from 'api/generated/services/metrics';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { isAxiosError } from 'axios';
|
||||
import classNames from 'classnames';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
@@ -28,10 +23,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { TimeSeriesProps } from './types';
|
||||
import {
|
||||
buildUpdateMetricYAxisUnitPayload,
|
||||
splitQueryIntoOneChartPerQuery,
|
||||
} from './utils';
|
||||
import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
function TimeSeries({
|
||||
showOneChartPerQuery,
|
||||
@@ -43,7 +35,6 @@ function TimeSeries({
|
||||
yAxisUnit,
|
||||
setYAxisUnit,
|
||||
showYAxisUnitSelector,
|
||||
metrics,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -51,7 +42,6 @@ function TimeSeries({
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
@@ -148,51 +138,54 @@ function TimeSeries({
|
||||
setYAxisUnit(value);
|
||||
};
|
||||
|
||||
// TODO: Enable once we have resolved all related metrics v2 api issues
|
||||
// Show the save unit button if
|
||||
// 1. There is only one metric
|
||||
// 2. The metric has no saved unit
|
||||
// 3. The user has selected a unit
|
||||
const showSaveUnitButton = useMemo(
|
||||
() =>
|
||||
metricUnits.length === 1 &&
|
||||
Boolean(metrics[0]) &&
|
||||
!metricUnits[0] &&
|
||||
yAxisUnit,
|
||||
[metricUnits, metrics, yAxisUnit],
|
||||
);
|
||||
// const showSaveUnitButton = useMemo(
|
||||
// () =>
|
||||
// metricUnits.length === 1 &&
|
||||
// Boolean(metrics?.[0]) &&
|
||||
// !metricUnits[0] &&
|
||||
// yAxisUnit,
|
||||
// [metricUnits, metrics, yAxisUnit],
|
||||
// );
|
||||
|
||||
const {
|
||||
mutate: updateMetricMetadata,
|
||||
isLoading: isUpdatingMetricMetadata,
|
||||
} = useUpdateMetricMetadata();
|
||||
// const {
|
||||
// mutate: updateMetricMetadata,
|
||||
// isLoading: isUpdatingMetricMetadata,
|
||||
// } = useUpdateMetricMetadata();
|
||||
|
||||
const handleSaveUnit = (): void => {
|
||||
if (metrics[0] && yAxisUnit) {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName: metricNames[0],
|
||||
},
|
||||
data: buildUpdateMetricYAxisUnitPayload(
|
||||
metricNames[0],
|
||||
metrics[0],
|
||||
yAxisUnit,
|
||||
),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Unit saved successfully');
|
||||
invalidateGetMetricMetadata(queryClient, {
|
||||
metricName: metricNames[0],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to save unit');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
// const handleSaveUnit = (): void => {
|
||||
// updateMetricMetadata(
|
||||
// {
|
||||
// metricName: metricNames[0],
|
||||
// payload: {
|
||||
// unit: yAxisUnit,
|
||||
// description: metrics[0]?.description ?? '',
|
||||
// metricType: metrics[0]?.type as MetricType,
|
||||
// temporality: metrics[0]?.temporality,
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// onSuccess: () => {
|
||||
// notifications.success({
|
||||
// message: 'Unit saved successfully',
|
||||
// });
|
||||
// queryClient.invalidateQueries([
|
||||
// REACT_QUERY_KEY.GET_METRIC_DETAILS,
|
||||
// metricNames[0],
|
||||
// ]);
|
||||
// },
|
||||
// onError: () => {
|
||||
// notifications.error({
|
||||
// message: 'Failed to save unit',
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
// };
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -205,7 +198,8 @@ function TimeSeries({
|
||||
source={YAxisSource.EXPLORER}
|
||||
data-testid="y-axis-unit-selector"
|
||||
/>
|
||||
{showSaveUnitButton && (
|
||||
{/* TODO: Enable once we have resolved all related metrics v2 api issues */}
|
||||
{/* {showSaveUnitButton && (
|
||||
<div className="save-unit-container">
|
||||
<Typography.Text>
|
||||
Save the selected unit for this metric?
|
||||
@@ -219,7 +213,7 @@ function TimeSeries({
|
||||
<Typography.Paragraph>Yes</Typography.Paragraph>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,8 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import * as useOptionsMenuHooks from 'container/OptionsMenu';
|
||||
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
|
||||
@@ -16,12 +14,12 @@ import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import * as timezoneHooks from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
import * as useGetMetricsHooks from '../utils';
|
||||
import { MOCK_METRIC_METADATA } from './testUtils';
|
||||
|
||||
const mockSetSearchParams = jest.fn();
|
||||
const queryClient = new QueryClient();
|
||||
@@ -137,6 +135,14 @@ jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
|
||||
|
||||
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector';
|
||||
|
||||
const mockMetric: MetricMetadata = {
|
||||
type: MetricType.SUM,
|
||||
description: 'metric1 description',
|
||||
unit: 'metric1 unit',
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
isMonotonic: true,
|
||||
};
|
||||
|
||||
function renderExplorer(): void {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -184,7 +190,7 @@ describe('Explorer', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
@@ -201,7 +207,7 @@ describe('Explorer', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
@@ -214,7 +220,7 @@ describe('Explorer', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
@@ -231,7 +237,7 @@ describe('Explorer', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
@@ -244,7 +250,7 @@ describe('Explorer', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
@@ -263,10 +269,10 @@ describe('Explorer', () => {
|
||||
isError: false,
|
||||
metrics: [
|
||||
{
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
type: MetricType.SUM,
|
||||
description: 'metric1 description',
|
||||
unit: '',
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
isMonotonic: true,
|
||||
},
|
||||
],
|
||||
@@ -283,7 +289,7 @@ describe('Explorer', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
@@ -318,7 +324,7 @@ describe('Explorer', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { UseMutationResult } from 'react-query';
|
||||
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataResponse } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { UseUpdateMetricMetadataProps } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
|
||||
import TimeSeries from '../TimeSeries';
|
||||
import { TimeSeriesProps } from '../types';
|
||||
import { MOCK_METRIC_METADATA } from './testUtils';
|
||||
|
||||
const mockUpdateMetricMetadata = jest.fn();
|
||||
const updateMetricMetadataSpy = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useUpdateMetricMetadata',
|
||||
);
|
||||
type UseUpdateMetricMetadataReturnType = ReturnType<
|
||||
typeof metricsExplorerHooks.useUpdateMetricMetadata
|
||||
type MockUpdateMetricMetadata = UseMutationResult<
|
||||
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
|
||||
Error,
|
||||
UseUpdateMetricMetadataProps
|
||||
>;
|
||||
const mockUpdateMetricMetadata = jest.fn();
|
||||
jest
|
||||
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
|
||||
.mockReturnValue(({
|
||||
mutate: mockUpdateMetricMetadata,
|
||||
isLoading: false,
|
||||
} as Partial<MockUpdateMetricMetadata>) as MockUpdateMetricMetadata);
|
||||
|
||||
jest.mock('container/TimeSeriesView/TimeSeriesView', () => ({
|
||||
__esModule: true,
|
||||
@@ -50,6 +60,14 @@ jest.mock('react-redux', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockMetric: MetricMetadata = {
|
||||
type: MetricType.SUM,
|
||||
description: 'metric1 description',
|
||||
unit: 'metric1 unit',
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
isMonotonic: true,
|
||||
};
|
||||
|
||||
const mockSetWarning = jest.fn();
|
||||
const mockSetIsMetricDetailsOpen = jest.fn();
|
||||
const mockSetYAxisUnit = jest.fn();
|
||||
@@ -78,13 +96,6 @@ function renderTimeSeries(
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTimeSeries({
|
||||
@@ -107,7 +118,7 @@ describe('TimeSeries', () => {
|
||||
const { container } = renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric, mockMetric],
|
||||
yAxisUnit: 'seconds',
|
||||
});
|
||||
|
||||
@@ -122,17 +133,18 @@ describe('TimeSeries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Save unit button when metric had no unit but one is selected', async () => {
|
||||
// TODO: Unskip this test once the save unit button is implemented
|
||||
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
|
||||
it.skip('shows Save unit button when metric had no unit but one is selected', () => {
|
||||
const { findByText, getByRole } = renderTimeSeries({
|
||||
metricUnits: [undefined],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric],
|
||||
yAxisUnit: 'seconds',
|
||||
showYAxisUnitSelector: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await findByText('Save the selected unit for this metric?'),
|
||||
findByText('Save the selected unit for this metric?'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const yesButton = getByRole('button', { name: 'Yes' });
|
||||
@@ -140,25 +152,24 @@ describe('TimeSeries', () => {
|
||||
expect(yesButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('clicking on save unit button shoould upated metric metadata', async () => {
|
||||
// TODO: Unskip this test once the save unit button is implemented
|
||||
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
|
||||
it.skip('clicking on save unit button shoould upated metric metadata', () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderTimeSeries({
|
||||
metricUnits: [''],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
metrics: [mockMetric],
|
||||
yAxisUnit: 'seconds',
|
||||
showYAxisUnitSelector: true,
|
||||
});
|
||||
|
||||
const yesButton = getByRole('button', { name: /Yes/i });
|
||||
await user.click(yesButton);
|
||||
user.click(yesButton);
|
||||
|
||||
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
|
||||
{
|
||||
pathParams: {
|
||||
metricName: 'metric1',
|
||||
},
|
||||
data: expect.objectContaining({ unit: 'seconds' }),
|
||||
metricName: 'metric1',
|
||||
payload: expect.objectContaining({ unit: 'seconds' }),
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import {
|
||||
MetricsexplorertypesMetricMetadataDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const MOCK_METRIC_METADATA: MetricsexplorertypesMetricMetadataDTO = {
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
description: 'metric1 description',
|
||||
unit: 'metric1 unit',
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
isMonotonic: true,
|
||||
};
|
||||
@@ -1,8 +1,14 @@
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { GetMetricMetadata200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * 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 {
|
||||
IBuilderFormula,
|
||||
@@ -16,7 +22,6 @@ import {
|
||||
splitQueryIntoOneChartPerQuery,
|
||||
useGetMetrics,
|
||||
} from '../utils';
|
||||
import { MOCK_METRIC_METADATA } from './testUtils';
|
||||
|
||||
const MOCK_QUERY_DATA_1: IBuilderQuery = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
@@ -86,19 +91,32 @@ describe('splitQueryIntoOneChartPerQuery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const MOCK_METRIC_METADATA: MetricMetadata = {
|
||||
description: 'Metric 1 description',
|
||||
unit: 'unit1',
|
||||
type: MetricType.GAUGE,
|
||||
temporality: Temporality.DELTA,
|
||||
isMonotonic: true,
|
||||
};
|
||||
|
||||
describe('useGetMetrics', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
|
||||
.mockReturnValue([
|
||||
{
|
||||
({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
data: MOCK_METRIC_METADATA,
|
||||
status: 'success',
|
||||
httpStatusCode: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
data: MOCK_METRIC_METADATA,
|
||||
},
|
||||
},
|
||||
} as UseQueryResult<GetMetricMetadata200, Error>,
|
||||
} as Partial<
|
||||
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
|
||||
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -115,11 +133,12 @@ describe('useGetMetrics', () => {
|
||||
jest
|
||||
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
|
||||
.mockReturnValue([
|
||||
{
|
||||
({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
} as UseQueryResult<GetMetricMetadata200, Error>,
|
||||
} as Partial<
|
||||
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
|
||||
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
|
||||
]);
|
||||
const { result } = renderHook(() => useGetMetrics(['metric1']));
|
||||
expect(result.current.metrics).toHaveLength(1);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
|
||||
export enum ExplorerTabs {
|
||||
TIME_SERIES = 'time-series',
|
||||
@@ -18,7 +18,7 @@ export interface TimeSeriesProps {
|
||||
isMetricUnitsError: boolean;
|
||||
metricUnits: (string | undefined)[];
|
||||
metricNames: string[];
|
||||
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[];
|
||||
metrics: (MetricMetadata | undefined)[];
|
||||
handleOpenMetricDetails: (metricName: string) => void;
|
||||
yAxisUnit: string | undefined;
|
||||
setYAxisUnit: (unit: string) => void;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics';
|
||||
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { mapMetricUnitToUniversalUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import { useGetMultipleMetrics } from 'hooks/metricsExplorer/useGetMultipleMetrics';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
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.
|
||||
* @param query - The query to split
|
||||
@@ -71,14 +68,16 @@ export function useGetMetrics(
|
||||
): {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[];
|
||||
metrics: (MetricMetadata | undefined)[];
|
||||
} {
|
||||
const metricsData = useGetMultipleMetrics(metricNames, {
|
||||
enabled: metricNames.length > 0 && isEnabled,
|
||||
});
|
||||
return {
|
||||
isLoading: metricsData.some((metric) => metric.isLoading),
|
||||
metrics: metricsData.map((metric) => metric.data?.data),
|
||||
metrics: metricsData
|
||||
.map((metric) => metric.data?.data)
|
||||
.map((data) => data?.data),
|
||||
isError: metricsData.some((metric) => metric.isError),
|
||||
};
|
||||
}
|
||||
@@ -90,24 +89,9 @@ export function useGetMetrics(
|
||||
* @returns The units of the metrics, can be undefined if the metric has no unit
|
||||
*/
|
||||
export function getMetricUnits(
|
||||
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[],
|
||||
metrics: (MetricMetadata | undefined)[],
|
||||
): (string | undefined)[] {
|
||||
return metrics
|
||||
.map((metric) => metric?.unit)
|
||||
.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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, Input, Select, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import classNames from 'classnames';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
@@ -40,10 +40,8 @@ import {
|
||||
* returns true if the feature flag is enabled, false otherwise
|
||||
* Show the inspect button in metrics explorer if the feature flag is enabled
|
||||
*/
|
||||
export function isInspectEnabled(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
): boolean {
|
||||
return metricType === MetrictypesTypeDTO.gauge;
|
||||
export function isInspectEnabled(metricType: MetricType | undefined): boolean {
|
||||
return metricType === MetricType.GAUGE;
|
||||
}
|
||||
|
||||
export function getAllTimestampsOfMetrics(
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Input,
|
||||
Menu,
|
||||
Popover,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { Button, Collapse, Input, Menu, Popover, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricAttributes } from 'api/generated/services/metrics';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -21,33 +12,9 @@ import { PANEL_TYPES } from '../../../constants/queryBuilder';
|
||||
import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import MetricDetailsErrorState from './MetricDetailsErrorState';
|
||||
import {
|
||||
AllAttributesEmptyTextProps,
|
||||
AllAttributesProps,
|
||||
AllAttributesValueProps,
|
||||
} from './types';
|
||||
import { AllAttributesProps, AllAttributesValueProps } from './types';
|
||||
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({
|
||||
filterKey,
|
||||
filterValue,
|
||||
@@ -143,23 +110,13 @@ export function AllAttributesValue({
|
||||
|
||||
function AllAttributes({
|
||||
metricName,
|
||||
attributes,
|
||||
metricType,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
|
||||
|
||||
const {
|
||||
data: attributesData,
|
||||
isLoading: isLoadingAttributes,
|
||||
isError: isErrorAttributes,
|
||||
refetch: refetchAttributes,
|
||||
} = useGetMetricAttributes({
|
||||
metricName,
|
||||
});
|
||||
|
||||
const attributes = useMemo(() => attributesData?.data.attributes ?? [], [
|
||||
attributesData,
|
||||
]);
|
||||
const [activeKey, setActiveKey] = useState<string | string[]>(
|
||||
'all-attributes',
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
@@ -221,7 +178,7 @@ function AllAttributes({
|
||||
attributes.filter(
|
||||
(attribute) =>
|
||||
attribute.key.toLowerCase().includes(searchString.toLowerCase()) ||
|
||||
attribute.values?.some((value) =>
|
||||
attribute.value.some((value) =>
|
||||
value.toLowerCase().includes(searchString.toLowerCase()),
|
||||
),
|
||||
),
|
||||
@@ -238,7 +195,7 @@ function AllAttributes({
|
||||
},
|
||||
value: {
|
||||
key: attribute.key,
|
||||
value: attribute.values,
|
||||
value: attribute.value,
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
@@ -313,7 +270,6 @@ function AllAttributes({
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isLoadingAttributes || isErrorAttributes}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -321,49 +277,25 @@ function AllAttributes({
|
||||
children: (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
loading={isLoadingAttributes}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className="metrics-accordion-content all-attributes-content"
|
||||
scroll={{ y: 600 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<AllAttributesEmptyText
|
||||
isErrorAttributes={isErrorAttributes}
|
||||
refetchAttributes={refetchAttributes}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
searchString,
|
||||
isLoadingAttributes,
|
||||
isErrorAttributes,
|
||||
columns,
|
||||
tableData,
|
||||
refetchAttributes,
|
||||
],
|
||||
[columns, tableData, searchString],
|
||||
);
|
||||
|
||||
if (isLoadingAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
className="metrics-accordion"
|
||||
className="metrics-accordion metrics-metadata-accordion"
|
||||
activeKey={activeKey}
|
||||
onChange={(keys): void => setActiveKey(keys as string[])}
|
||||
onChange={(keys): void => setActiveKey(keys)}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,84 +2,36 @@ import { useMemo } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Dropdown, Typography } from 'antd';
|
||||
import { Skeleton } from 'antd/lib';
|
||||
import {
|
||||
useGetMetricAlerts,
|
||||
useGetMetricDashboards,
|
||||
} from 'api/generated/services/metrics';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { Bell, Grid } from 'lucide-react';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
import { DashboardsAndAlertsPopoverProps } from './types';
|
||||
|
||||
function DashboardsAndAlertsPopover({
|
||||
metricName,
|
||||
alerts,
|
||||
dashboards,
|
||||
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
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(() => {
|
||||
if (alerts && alerts.length > 0) {
|
||||
return alerts.map((alert) => ({
|
||||
key: alert.alertId,
|
||||
key: alert.alert_id,
|
||||
label: (
|
||||
<Typography.Link
|
||||
key={alert.alertId}
|
||||
key={alert.alert_id}
|
||||
onClick={(): void => {
|
||||
params.set(QueryParams.ruleId, alert.alertId);
|
||||
params.set(QueryParams.ruleId, alert.alert_id);
|
||||
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
{alert.alertName || alert.alertId}
|
||||
{alert.alert_name || alert.alert_id}
|
||||
</Typography.Link>
|
||||
),
|
||||
}));
|
||||
@@ -87,44 +39,41 @@ function DashboardsAndAlertsPopover({
|
||||
return null;
|
||||
}, [alerts, params]);
|
||||
|
||||
const uniqueDashboards = useMemo(
|
||||
() =>
|
||||
dashboards?.filter(
|
||||
(item, index, self) =>
|
||||
index === self.findIndex((t) => t.dashboard_id === item.dashboard_id),
|
||||
),
|
||||
[dashboards],
|
||||
);
|
||||
|
||||
const dashboardsPopoverContent = useMemo(() => {
|
||||
if (dashboards && dashboards.length > 0) {
|
||||
return dashboards.map((dashboard) => ({
|
||||
key: dashboard.dashboardId,
|
||||
if (uniqueDashboards && uniqueDashboards.length > 0) {
|
||||
return uniqueDashboards.map((dashboard) => ({
|
||||
key: dashboard.dashboard_id,
|
||||
label: (
|
||||
<Typography.Link
|
||||
key={dashboard.dashboardId}
|
||||
key={dashboard.dashboard_id}
|
||||
onClick={(): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, {
|
||||
dashboardId: dashboard.dashboardId,
|
||||
dashboardId: dashboard.dashboard_id,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
{dashboard.dashboardName || dashboard.dashboardId}
|
||||
{dashboard.dashboard_name || dashboard.dashboard_id}
|
||||
</Typography.Link>
|
||||
),
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}, [dashboards, safeNavigate]);
|
||||
}, [uniqueDashboards, safeNavigate]);
|
||||
|
||||
if (isLoadingAlerts || isLoadingDashboards) {
|
||||
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" />;
|
||||
if (!dashboardsPopoverContent && !alertsPopoverContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -143,7 +92,8 @@ function DashboardsAndAlertsPopover({
|
||||
>
|
||||
<Grid size={12} color={Color.BG_SIENNA_500} />
|
||||
<Typography.Text>
|
||||
{pluralize(dashboards.length, 'dashboard')}
|
||||
{uniqueDashboards?.length} dashboard
|
||||
{uniqueDashboards?.length === 1 ? '' : 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
@@ -162,7 +112,7 @@ function DashboardsAndAlertsPopover({
|
||||
>
|
||||
<Bell size={12} color={Color.BG_SAKURA_500} />
|
||||
<Typography.Text>
|
||||
{pluralize(alerts.length, 'alert rule')}
|
||||
{alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { useGetMetricHighlights } from 'api/generated/services/metrics';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
|
||||
import { HighlightsProps } from './types';
|
||||
import {
|
||||
formatNumberToCompactFormat,
|
||||
formatTimestampToReadableDate,
|
||||
} from './utils';
|
||||
|
||||
function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
const {
|
||||
data: metricHighlightsData,
|
||||
isLoading: isLoadingMetricHighlights,
|
||||
isError: isErrorMetricHighlights,
|
||||
refetch: refetchMetricHighlights,
|
||||
} = useGetMetricHighlights(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: !!metricName,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const metricHighlights = metricHighlightsData?.data;
|
||||
|
||||
const timeSeriesActive = formatNumberToCompactFormat(
|
||||
metricHighlights?.activeTimeSeries,
|
||||
);
|
||||
const timeSeriesTotal = formatNumberToCompactFormat(
|
||||
metricHighlights?.totalTimeSeries,
|
||||
);
|
||||
const lastReceivedText = formatTimestampToReadableDate(
|
||||
metricHighlights?.lastReceived,
|
||||
);
|
||||
|
||||
if (isLoadingMetricHighlights) {
|
||||
return (
|
||||
<div
|
||||
className="metric-details-content-grid"
|
||||
data-testid="metric-highlights-loading-state"
|
||||
>
|
||||
<Skeleton title={false} paragraph={{ rows: 2 }} active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorMetricHighlights) {
|
||||
return (
|
||||
<div className="metric-details-content-grid">
|
||||
<div
|
||||
className="metric-highlights-error-state"
|
||||
data-testid="metric-highlights-error-state"
|
||||
>
|
||||
<InfoIcon size={16} color={Color.BG_CHERRY_500} />
|
||||
<Typography.Text>
|
||||
Something went wrong while fetching metric highlights
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="link"
|
||||
size="large"
|
||||
onClick={(): void => {
|
||||
refetchMetricHighlights();
|
||||
}}
|
||||
>
|
||||
Retry ?
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="metric-details-content-grid">
|
||||
<div className="labels-row">
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
SAMPLES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
TIME SERIES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
LAST RECEIVED
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-data-points"
|
||||
>
|
||||
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-time-series-total"
|
||||
>
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-last-received"
|
||||
>
|
||||
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Highlights;
|
||||
@@ -1,58 +1,45 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
|
||||
import { Button, Collapse, Input, Select, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
invalidateGetMetricMetadata,
|
||||
invalidateListMetrics,
|
||||
useUpdateMetricMetadata,
|
||||
} from 'api/generated/services/metrics';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Edit2, Save, X } from 'lucide-react';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import MetricTypeRendererV2 from '../Summary/MetricTypeViewRenderer';
|
||||
import {
|
||||
METRIC_METADATA_KEYS,
|
||||
METRIC_METADATA_TEMPORALITY_OPTIONS,
|
||||
METRIC_METADATA_TYPE_OPTIONS,
|
||||
METRIC_METADATA_UPDATE_ERROR_MESSAGE,
|
||||
} from './constants';
|
||||
import MetricDetailsErrorState from './MetricDetailsErrorState';
|
||||
import { MetadataProps, MetricMetadataFormState, TableFields } from './types';
|
||||
import { transformUpdateMetricMetadataRequest } from './utils';
|
||||
METRIC_TYPE_LABEL_MAP,
|
||||
METRIC_TYPE_VALUES_MAP,
|
||||
} from '../Summary/constants';
|
||||
import { MetricTypeRenderer } from '../Summary/utils';
|
||||
import { METRIC_METADATA_KEYS } from './constants';
|
||||
import { MetadataProps } from './types';
|
||||
import { determineIsMonotonic } from './utils';
|
||||
|
||||
function Metadata({
|
||||
metricName,
|
||||
metadata,
|
||||
isErrorMetricMetadata,
|
||||
isLoadingMetricMetadata,
|
||||
refetchMetricMetadata,
|
||||
refetchMetricDetails,
|
||||
}: MetadataProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const [
|
||||
metricMetadataState,
|
||||
setMetricMetadataState,
|
||||
] = useState<MetricMetadataFormState>({
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
description: '',
|
||||
temporality: MetrictypesTemporalityDTO.unspecified,
|
||||
unit: '',
|
||||
isMonotonic: false,
|
||||
metricMetadata,
|
||||
setMetricMetadata,
|
||||
] = useState<UpdateMetricMetadataProps>({
|
||||
metricType: metadata?.metric_type || MetricType.SUM,
|
||||
description: metadata?.description || '',
|
||||
temporality: metadata?.temporality,
|
||||
unit: metadata?.unit,
|
||||
});
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
@@ -64,135 +51,110 @@ function Metadata({
|
||||
);
|
||||
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(
|
||||
() =>
|
||||
metadata
|
||||
? Object.keys(metadata).map((key) => ({
|
||||
key,
|
||||
value: {
|
||||
value: metadata[key as keyof typeof metadata],
|
||||
? Object.keys({
|
||||
...metadata,
|
||||
temporality: metadata?.temporality,
|
||||
})
|
||||
// Filter out monotonic as user input is not required
|
||||
.filter((key) => key !== 'monotonic')
|
||||
.map((key) => ({
|
||||
key,
|
||||
},
|
||||
}))
|
||||
value: {
|
||||
value: metadata[key as keyof typeof metadata],
|
||||
key,
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
[metadata],
|
||||
);
|
||||
|
||||
// Render un-editable field value
|
||||
const renderUneditableField = useCallback(
|
||||
(key: keyof MetricMetadataFormState, value: string) => {
|
||||
if (isErrorMetricMetadata) {
|
||||
return <FieldRenderer field="-" />;
|
||||
}
|
||||
if (key === TableFields.TYPE) {
|
||||
return <MetricTypeRendererV2 type={value as MetrictypesTypeDTO} />;
|
||||
}
|
||||
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 renderUneditableField = useCallback((key: string, value: string) => {
|
||||
if (key === 'metric_type') {
|
||||
return <MetricTypeRenderer type={value as MetricType} />;
|
||||
}
|
||||
let fieldValue = value;
|
||||
if (key === 'unit') {
|
||||
fieldValue = getUniversalNameFromMetricUnit(value);
|
||||
}
|
||||
return <FieldRenderer field={fieldValue || '-'} />;
|
||||
}, []);
|
||||
|
||||
const renderColumnValue = useCallback(
|
||||
(field: {
|
||||
value: string;
|
||||
key: keyof MetricMetadataFormState;
|
||||
}): JSX.Element => {
|
||||
(field: { value: string; key: string }): JSX.Element => {
|
||||
if (!isEditing) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
// Don't allow editing of unit if it's already set
|
||||
const metricUnitAlreadySet =
|
||||
field.key === TableFields.UNIT && Boolean(metadata?.unit);
|
||||
const metricUnitAlreadySet = field.key === 'unit' && Boolean(metadata?.unit);
|
||||
if (metricUnitAlreadySet) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
// Monotonic is not editable
|
||||
if (field.key === TableFields.IS_MONOTONIC) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
if (field.key === TableFields.TYPE) {
|
||||
if (field.key === 'metric_type') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="metric-type-select"
|
||||
options={METRIC_METADATA_TYPE_OPTIONS}
|
||||
value={metricMetadataState.type}
|
||||
options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
|
||||
value: key,
|
||||
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||
}))}
|
||||
value={metricMetadata.metricType}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadataState((prev) => ({
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
type: value,
|
||||
metricType: value as MetricType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === TableFields.UNIT) {
|
||||
if (field.key === 'unit') {
|
||||
return (
|
||||
<YAxisUnitSelector
|
||||
value={metricMetadataState.unit}
|
||||
value={metricMetadata.unit}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadataState((prev) => ({ ...prev, unit: value }));
|
||||
setMetricMetadata((prev) => ({ ...prev, unit: value }));
|
||||
}}
|
||||
data-testid="unit-select"
|
||||
source={YAxisSource.EXPLORER}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === TableFields.Temporality) {
|
||||
const temporalityValue =
|
||||
metricMetadataState.temporality === MetrictypesTemporalityDTO.unspecified
|
||||
? undefined
|
||||
: metricMetadataState.temporality;
|
||||
if (field.key === 'temporality') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="temporality-select"
|
||||
options={METRIC_METADATA_TEMPORALITY_OPTIONS}
|
||||
value={temporalityValue}
|
||||
options={Object.values(Temporality).map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}))}
|
||||
value={metricMetadata.temporality}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadataState((prev) => ({
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
temporality: value,
|
||||
temporality: value as Temporality,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === TableFields.DESCRIPTION) {
|
||||
if (field.key === 'description') {
|
||||
return (
|
||||
<Input
|
||||
data-testid="description-input"
|
||||
name={field.key}
|
||||
defaultValue={metricMetadataState.description}
|
||||
defaultValue={
|
||||
metricMetadata[
|
||||
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
|
||||
]
|
||||
}
|
||||
onChange={(e): void => {
|
||||
setMetricMetadataState((prev) => ({
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}));
|
||||
@@ -202,7 +164,7 @@ function Metadata({
|
||||
}
|
||||
return <FieldRenderer field="-" />;
|
||||
},
|
||||
[isEditing, metadata?.unit, metricMetadataState, renderUneditableField],
|
||||
[isEditing, metadata?.unit, metricMetadata, renderUneditableField],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<DataType> = useMemo(
|
||||
@@ -239,61 +201,52 @@ function Metadata({
|
||||
const handleSave = useCallback(() => {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName,
|
||||
metricName,
|
||||
payload: {
|
||||
...metricMetadata,
|
||||
isMonotonic: determineIsMonotonic(
|
||||
metricMetadata.metricType,
|
||||
metricMetadata.temporality,
|
||||
),
|
||||
},
|
||||
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
|
||||
},
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Metadata updated successfully',
|
||||
});
|
||||
setIsEditing(false);
|
||||
invalidateListMetrics(queryClient);
|
||||
invalidateGetMetricMetadata(queryClient, {
|
||||
metricName,
|
||||
});
|
||||
onSuccess: (response): void => {
|
||||
if (response?.statusCode === 200) {
|
||||
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Metadata updated successfully',
|
||||
});
|
||||
refetchMetricDetails();
|
||||
setIsEditing(false);
|
||||
queryClient.invalidateQueries(['metricsList']);
|
||||
} else {
|
||||
notifications.error({
|
||||
message:
|
||||
'Failed to update metadata, please try again. If the issue persists, please contact support.',
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error): void => {
|
||||
const errorMessage = (error as AxiosError<RenderErrorResponseDTO>).response
|
||||
?.data.error?.message;
|
||||
onError: (): void =>
|
||||
notifications.error({
|
||||
message: errorMessage || METRIC_METADATA_UPDATE_ERROR_MESSAGE,
|
||||
});
|
||||
},
|
||||
message:
|
||||
'Failed to update metadata, please try again. If the issue persists, please contact support.',
|
||||
}),
|
||||
},
|
||||
);
|
||||
}, [
|
||||
updateMetricMetadata,
|
||||
metricName,
|
||||
metricMetadataState,
|
||||
metricMetadata,
|
||||
notifications,
|
||||
refetchMetricDetails,
|
||||
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(() => {
|
||||
if (isEditing) {
|
||||
return (
|
||||
@@ -301,7 +254,10 @@ function Metadata({
|
||||
<Button
|
||||
className="action-button"
|
||||
type="text"
|
||||
onClick={cancelEdit}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
disabled={isUpdatingMetricsMetadata}
|
||||
>
|
||||
<X size={14} />
|
||||
@@ -322,9 +278,6 @@ function Metadata({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isErrorMetricMetadata) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="action-menu">
|
||||
<Button
|
||||
@@ -341,13 +294,7 @@ function Metadata({
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
isEditing,
|
||||
isErrorMetricMetadata,
|
||||
isUpdatingMetricsMetadata,
|
||||
cancelEdit,
|
||||
handleSave,
|
||||
]);
|
||||
}, [handleSave, isEditing, isUpdatingMetricsMetadata]);
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
@@ -359,14 +306,7 @@ function Metadata({
|
||||
</div>
|
||||
),
|
||||
key: 'metric-metadata',
|
||||
children: isErrorMetricMetadata ? (
|
||||
<div className="metric-metadata-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchMetricMetadata}
|
||||
errorMessage="Something went wrong while fetching metric metadata"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
children: (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
@@ -378,23 +318,9 @@ function Metadata({
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
actionButton,
|
||||
columns,
|
||||
isErrorMetricMetadata,
|
||||
refetchMetricMetadata,
|
||||
tableData,
|
||||
],
|
||||
[actionButton, columns, tableData],
|
||||
);
|
||||
|
||||
if (isLoadingMetricMetadata) {
|
||||
return (
|
||||
<div className="metrics-metadata-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
|
||||
@@ -38,12 +38,7 @@
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.metrics-metadata-error {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.metric-details-content-grid {
|
||||
height: 50px;
|
||||
.labels-row,
|
||||
.values-row {
|
||||
display: grid;
|
||||
@@ -52,18 +47,6 @@
|
||||
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 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -89,7 +72,6 @@
|
||||
.dashboards-and-alerts-popover-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 32px;
|
||||
|
||||
.dashboards-and-alerts-popover {
|
||||
border-radius: 20px;
|
||||
@@ -120,19 +102,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-metadata-skeleton-container {
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
.all-attributes-skeleton-container {
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.metrics-accordion {
|
||||
.all-attributes-error-state {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.ant-table-body {
|
||||
&::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
@@ -178,6 +148,7 @@
|
||||
|
||||
.all-attributes-search-input {
|
||||
width: 300px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +161,6 @@
|
||||
.ant-typography:first-child {
|
||||
font-family: 'Geist Mono';
|
||||
color: var(--bg-robin-400);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.all-attributes-contribution {
|
||||
@@ -247,10 +217,6 @@
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0;
|
||||
|
||||
.metric-metadata-error-state {
|
||||
height: 267px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
@@ -271,7 +237,6 @@
|
||||
}
|
||||
|
||||
.metric-metadata-value {
|
||||
height: 67px;
|
||||
background: rgba(22, 25, 34, 0.4);
|
||||
overflow-x: scroll;
|
||||
.field-renderer-container {
|
||||
@@ -365,26 +330,18 @@
|
||||
.metric-details-content {
|
||||
.metrics-accordion {
|
||||
.metrics-accordion-header {
|
||||
.action-menu {
|
||||
.action-button {
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
.action-button {
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-accordion-content {
|
||||
.metric-metadata-key {
|
||||
.field-renderer-container {
|
||||
.label {
|
||||
color: var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
.all-attributes-key {
|
||||
.ant-typography:last-child {
|
||||
color: var(--bg-vanilla-200);
|
||||
color: var(--bg-slate-400);
|
||||
background-color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
@@ -438,13 +395,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric-details-error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Drawer, Typography } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricMetadata } from 'api/generated/services/metrics';
|
||||
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, Crosshair, X } from 'lucide-react';
|
||||
|
||||
@@ -11,12 +19,16 @@ import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { isInspectEnabled } from '../Inspect/utils';
|
||||
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
|
||||
import AllAttributes from './AllAttributes';
|
||||
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
|
||||
import Highlights from './Highlights';
|
||||
import Metadata from './Metadata';
|
||||
import { MetricDetailsProps } from './types';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
import {
|
||||
formatNumberToCompactFormat,
|
||||
formatTimestampToReadableDate,
|
||||
getMetricDetailsQuery,
|
||||
} from './utils';
|
||||
|
||||
import './MetricDetails.styles.scss';
|
||||
import '../Summary/Summary.styles.scss';
|
||||
@@ -31,49 +43,55 @@ function MetricDetails({
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const {
|
||||
data: metricMetadataResponse,
|
||||
isLoading: isLoadingMetricMetadata,
|
||||
isError: isErrorMetricMetadata,
|
||||
refetch: refetchMetricMetadata,
|
||||
} = useGetMetricMetadata(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: !!metricName,
|
||||
},
|
||||
},
|
||||
);
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error: metricDetailsError,
|
||||
refetch: refetchMetricDetails,
|
||||
} = useGetMetricDetails(metricName ?? '', {
|
||||
enabled: !!metricName,
|
||||
});
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse) {
|
||||
const metric = data?.payload?.data;
|
||||
|
||||
const lastReceived = useMemo(() => {
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
type,
|
||||
description,
|
||||
unit,
|
||||
temporality,
|
||||
isMonotonic,
|
||||
} = metricMetadataResponse.data;
|
||||
return formatTimestampToReadableDate(metric.lastReceived);
|
||||
}, [metric]);
|
||||
|
||||
return {
|
||||
type,
|
||||
description,
|
||||
unit,
|
||||
temporality,
|
||||
isMonotonic,
|
||||
};
|
||||
}, [metricMetadataResponse]);
|
||||
const showInspectFeature = useMemo(
|
||||
() => isInspectEnabled(metric?.metadata?.metric_type),
|
||||
[metric],
|
||||
);
|
||||
|
||||
const showInspectFeature = useMemo(() => isInspectEnabled(metadata?.type), [
|
||||
metadata?.type,
|
||||
]);
|
||||
const isMetricDetailsLoading = isLoading || isFetching;
|
||||
|
||||
const timeSeries = useMemo(() => {
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
const timeSeriesActive = formatNumberToCompactFormat(metric.timeSeriesActive);
|
||||
const timeSeriesTotal = formatNumberToCompactFormat(metric.timeSeriesTotal);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [metric]);
|
||||
|
||||
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
|
||||
if (metricName) {
|
||||
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metric?.metadata?.metric_type,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
@@ -89,7 +107,9 @@ function MetricDetails({
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
}
|
||||
}, [metricName, handleExplorerTabChange, metadata?.type]);
|
||||
}, [metricName, handleExplorerTabChange, metric?.metadata?.metric_type]);
|
||||
|
||||
const isMetricDetailsError = metricDetailsError || !metric;
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.ModalOpened, {
|
||||
@@ -97,9 +117,6 @@ function MetricDetails({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isActionButtonDisabled =
|
||||
!metricName || isLoadingMetricMetadata || isErrorMetricMetadata;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
@@ -107,13 +124,13 @@ function MetricDetails({
|
||||
<div className="metric-details-header">
|
||||
<div className="metric-details-title">
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>{metricName}</Typography.Text>
|
||||
<Typography.Text>{metric?.name}</Typography.Text>
|
||||
</div>
|
||||
<div className="metric-details-header-buttons">
|
||||
<Button
|
||||
onClick={goToMetricsExplorerwithSelectedMetric}
|
||||
icon={<Compass size={16} />}
|
||||
disabled={isActionButtonDisabled}
|
||||
disabled={!metricName}
|
||||
data-testid="open-in-explorer-button"
|
||||
>
|
||||
Open in Explorer
|
||||
@@ -123,11 +140,10 @@ function MetricDetails({
|
||||
<Button
|
||||
className="inspect-metrics-button"
|
||||
aria-label="Inspect Metric"
|
||||
disabled={isActionButtonDisabled}
|
||||
icon={<Crosshair size={18} />}
|
||||
onClick={(): void => {
|
||||
if (metricName) {
|
||||
openInspectModal(metricName);
|
||||
if (metric?.name) {
|
||||
openInspectModal(metric.name);
|
||||
}
|
||||
}}
|
||||
data-testid="inspect-metric-button"
|
||||
@@ -147,18 +163,60 @@ function MetricDetails({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} />}
|
||||
>
|
||||
<div className="metric-details-content">
|
||||
<Highlights metricName={metricName} />
|
||||
<DashboardsAndAlertsPopover metricName={metricName} />
|
||||
<Metadata
|
||||
metricName={metricName}
|
||||
metadata={metadata}
|
||||
isErrorMetricMetadata={isErrorMetricMetadata}
|
||||
isLoadingMetricMetadata={isLoadingMetricMetadata}
|
||||
refetchMetricMetadata={refetchMetricMetadata}
|
||||
/>
|
||||
<AllAttributes metricName={metricName} metricType={metadata?.type} />
|
||||
</div>
|
||||
{isMetricDetailsLoading && (
|
||||
<div data-testid="metric-details-skeleton">
|
||||
<Skeleton active />
|
||||
</div>
|
||||
)}
|
||||
{isMetricDetailsError && !isMetricDetailsLoading && (
|
||||
<Empty description="Error fetching metric details" />
|
||||
)}
|
||||
{!isMetricDetailsLoading && !isMetricDetailsError && (
|
||||
<div className="metric-details-content">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Typography } from 'antd';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
import { MetricDetailsErrorStateProps } from './types';
|
||||
|
||||
function MetricDetailsErrorState({
|
||||
refetch,
|
||||
errorMessage,
|
||||
}: MetricDetailsErrorStateProps): JSX.Element {
|
||||
return (
|
||||
<div className="metric-details-error-state">
|
||||
<InfoIcon size={20} color={Color.BG_CHERRY_500} />
|
||||
<Typography.Text>{errorMessage}</Typography.Text>
|
||||
{refetch && <Button onClick={refetch}>Retry</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricDetailsErrorState;
|
||||
@@ -1,13 +1,11 @@
|
||||
import * as reactUseHooks from 'react-use';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import { MetricDetailsAttribute } from '../../../../api/metricsExplorer/getMetricDetails';
|
||||
import ROUTES from '../../../../constants/routes';
|
||||
import AllAttributes, { AllAttributesValue } from '../AllAttributes';
|
||||
import { getMockMetricAttributesData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -22,28 +20,33 @@ jest
|
||||
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();
|
||||
jest
|
||||
.spyOn(reactUseHooks, 'useCopyToClipboard')
|
||||
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
|
||||
|
||||
const useGetMetricAttributesMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricAttributes',
|
||||
);
|
||||
|
||||
describe('AllAttributes', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders attributes section with title', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -53,8 +56,9 @@ describe('AllAttributes', () => {
|
||||
it('renders all attribute keys and values', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -71,8 +75,9 @@ describe('AllAttributes', () => {
|
||||
it('renders value counts correctly', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -81,44 +86,41 @@ describe('AllAttributes', () => {
|
||||
});
|
||||
|
||||
it('handles empty attributes array', () => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData({
|
||||
data: {
|
||||
attributes: [],
|
||||
totalKeys: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
metricName={mockMetricName}
|
||||
attributes={[]}
|
||||
metricType={mockMetricType}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('All Attributes')).toBeInTheDocument();
|
||||
expect(screen.getByText('No attributes found')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => {
|
||||
it('clicking on an attribute key opens the explorer with the attribute filter applied', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('attribute1'));
|
||||
fireEvent.click(screen.getByText('attribute1'));
|
||||
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters attributes based on search input', async () => {
|
||||
it('filters attributes based on search input', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
/>,
|
||||
);
|
||||
await userEvent.type(screen.getByPlaceholderText('Search'), 'value1');
|
||||
fireEvent.change(screen.getByPlaceholderText('Search'), {
|
||||
target: { value: 'value1' },
|
||||
});
|
||||
|
||||
expect(screen.getByText('attribute1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
@@ -142,7 +144,7 @@ describe('AllAttributesValue', () => {
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads more attributes when show more button is clicked', async () => {
|
||||
it('loads more attributes when show more button is clicked', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -153,7 +155,7 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('value6')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Show More'));
|
||||
fireEvent.click(screen.getByText('Show More'));
|
||||
expect(screen.getByText('value6')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -170,7 +172,7 @@ describe('AllAttributesValue', () => {
|
||||
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copy button should copy the attribute value to the clipboard', async () => {
|
||||
it('copy button should copy the attribute value to the clipboard', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -181,13 +183,13 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('value1'));
|
||||
fireEvent.click(screen.getByText('value1'));
|
||||
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Copy Attribute'));
|
||||
fireEvent.click(screen.getByText('Copy Attribute'));
|
||||
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
|
||||
});
|
||||
|
||||
it('explorer button should go to metrics explore with the attribute filter applied', async () => {
|
||||
it('explorer button should go to metrics explore with the attribute filter applied', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -198,10 +200,10 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('value1'));
|
||||
fireEvent.click(screen.getByText('value1'));
|
||||
|
||||
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Open in Explorer'));
|
||||
fireEvent.click(screen.getByText('Open in Explorer'));
|
||||
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
|
||||
'attribute1',
|
||||
'value1',
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
|
||||
import {
|
||||
getMockAlertsData,
|
||||
getMockDashboardsData,
|
||||
MOCK_ALERT_1,
|
||||
MOCK_ALERT_2,
|
||||
MOCK_DASHBOARD_1,
|
||||
MOCK_DASHBOARD_2,
|
||||
MOCK_METRIC_NAME,
|
||||
} from './testUtlls';
|
||||
|
||||
const mockAlert1 = {
|
||||
alert_id: '1',
|
||||
alert_name: 'Alert 1',
|
||||
};
|
||||
const mockAlert2 = {
|
||||
alert_id: '2',
|
||||
alert_name: 'Alert 2',
|
||||
};
|
||||
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();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
@@ -20,6 +28,7 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
set: mockSetQuery,
|
||||
@@ -30,156 +39,125 @@ jest.mock('hooks/useUrlQuery', () => ({
|
||||
default: jest.fn(() => mockUrlQuery),
|
||||
}));
|
||||
|
||||
const useGetMetricAlertsMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricAlerts',
|
||||
);
|
||||
const useGetMetricDashboardsMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricDashboards',
|
||||
);
|
||||
|
||||
describe('DashboardsAndAlertsPopover', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
|
||||
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
|
||||
});
|
||||
|
||||
it('renders the popover correctly with multiple dashboards and alerts', () => {
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={mockDashboards}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(`2 dashboards`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`2 alert rules`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`${mockDashboards.length} dashboards`),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`${mockAlerts.length} alert rules`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders null with no dashboards and alerts', () => {
|
||||
useGetMetricAlertsMock.mockReturnValue(
|
||||
getMockAlertsData({
|
||||
data: {
|
||||
alerts: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />,
|
||||
<DashboardsAndAlertsPopover alerts={[]} dashboards={[]} />,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('dashboards-and-alerts-popover-container'),
|
||||
).toBeNull();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders popover with single dashboard and alert', () => {
|
||||
useGetMetricAlertsMock.mockReturnValue(
|
||||
getMockAlertsData({
|
||||
data: {
|
||||
alerts: [MOCK_ALERT_1],
|
||||
},
|
||||
}),
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={[mockAlert1]}
|
||||
dashboards={[mockDashboard1]}
|
||||
/>,
|
||||
);
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [MOCK_DASHBOARD_1],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(screen.getByText(`1 dashboard`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`1 alert rule`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders popover with dashboard id if name is not available', async () => {
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [{ ...MOCK_DASHBOARD_1, dashboardName: '' }],
|
||||
},
|
||||
}),
|
||||
it('renders popover with dashboard id if name is not available', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={[{ ...mockDashboard1, dashboard_name: undefined } as any]}
|
||||
/>,
|
||||
);
|
||||
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
await userEvent.click(screen.getByText(`1 dashboard`));
|
||||
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardId)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(`1 dashboard`));
|
||||
expect(screen.getByText(mockDashboard1.dashboard_id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders popover with alert id if name is not available', async () => {
|
||||
useGetMetricAlertsMock.mockReturnValue(
|
||||
getMockAlertsData({
|
||||
data: {
|
||||
alerts: [{ ...MOCK_ALERT_1, alertName: '' }],
|
||||
},
|
||||
}),
|
||||
it('renders popover with alert id if name is not available', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={[{ ...mockAlert1, alert_name: undefined } as any]}
|
||||
dashboards={mockDashboards}
|
||||
/>,
|
||||
);
|
||||
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
await userEvent.click(screen.getByText(`1 alert rule`));
|
||||
expect(screen.getByText(MOCK_ALERT_1.alertId)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(`1 alert rule`));
|
||||
expect(screen.getByText(mockAlert1.alert_id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the dashboard when the dashboard is clicked', async () => {
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
it('navigates to the dashboard when the dashboard is clicked', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={mockDashboards}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click on 2 dashboards button
|
||||
await userEvent.click(screen.getByText(`2 dashboards`));
|
||||
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
|
||||
// Popover showing list of 2 dashboards should be visible
|
||||
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
|
||||
|
||||
// Click on the first dashboard
|
||||
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName));
|
||||
fireEvent.click(screen.getByText(mockDashboard1.dashboard_name));
|
||||
|
||||
// Should navigate to the dashboard
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`,
|
||||
`/dashboard/${mockDashboard1.dashboard_id}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the alert when the alert is clicked', async () => {
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
it('navigates to the alert when the alert is clicked', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={mockDashboards}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click on 2 alert rules button
|
||||
await userEvent.click(screen.getByText(`2 alert rules`));
|
||||
fireEvent.click(screen.getByText(`${mockAlerts.length} alert rules`));
|
||||
// Popover showing list of 2 alert rules should be visible
|
||||
expect(screen.getByText(MOCK_ALERT_1.alertName)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_ALERT_2.alertName)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockAlert1.alert_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockAlert2.alert_name)).toBeInTheDocument();
|
||||
|
||||
// Click on the first alert rule
|
||||
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
|
||||
fireEvent.click(screen.getByText(mockAlert1.alert_name));
|
||||
|
||||
// Should navigate to the alert rule
|
||||
expect(mockSetQuery).toHaveBeenCalledWith(
|
||||
QueryParams.ruleId,
|
||||
MOCK_ALERT_1.alertId,
|
||||
mockAlert1.alert_id,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders unique dashboards even when there are duplicates', async () => {
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2, MOCK_DASHBOARD_1],
|
||||
},
|
||||
}),
|
||||
it('renders unique dashboards even when there are duplicates', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={[...mockDashboards, mockDashboard1]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(`${mockDashboards.length} dashboards`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
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();
|
||||
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
|
||||
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
|
||||
import Highlights from '../Highlights';
|
||||
import { formatTimestampToReadableDate } from '../utils';
|
||||
import { getMockMetricHighlightsData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
const useGetMetricHighlightsMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricHighlights',
|
||||
);
|
||||
|
||||
describe('Highlights', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(getMockMetricHighlightsData());
|
||||
});
|
||||
|
||||
it('should render all highlights data correctly', () => {
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
const dataPoints = screen.getByTestId('metric-highlights-data-points');
|
||||
const timeSeriesTotal = screen.getByTestId(
|
||||
'metric-highlights-time-series-total',
|
||||
);
|
||||
const lastReceived = screen.getByTestId('metric-highlights-last-received');
|
||||
|
||||
expect(dataPoints.textContent).toBe('1M+');
|
||||
expect(timeSeriesTotal.textContent).toBe('1M total ⎯ 1M active');
|
||||
expect(lastReceived.textContent).toBe(
|
||||
formatTimestampToReadableDate('2026-01-24T00:00:00Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render error state correctly', () => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(
|
||||
getMockMetricHighlightsData(
|
||||
{},
|
||||
{
|
||||
isError: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('metric-highlights-error-state'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state when data is loading', () => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(
|
||||
getMockMetricHighlightsData(
|
||||
{},
|
||||
{
|
||||
isLoading: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('metric-highlights-loading-state'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,16 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
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 { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import {
|
||||
UniversalYAxisUnit,
|
||||
YAxisUnitSelectorProps,
|
||||
} from 'components/YAxisUnitSelector/types';
|
||||
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import * as useNotificationsHooks from 'hooks/useNotifications';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
import Metadata from '../Metadata';
|
||||
import { MetricMetadata } from '../types';
|
||||
import { transformMetricMetadata } from '../utils';
|
||||
import { getMockMetricMetadataData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
// Mock antd select for testing
|
||||
jest.mock('antd', () => ({
|
||||
@@ -80,18 +72,13 @@ jest.mock('react-query', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseUpdateMetricMetadataHook = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useUpdateMetricMetadata',
|
||||
);
|
||||
type UseUpdateMetricMetadataResult = ReturnType<
|
||||
typeof metricsExplorerHooks.useUpdateMetricMetadata
|
||||
>;
|
||||
const mockUseUpdateMetricMetadata = jest.fn();
|
||||
|
||||
const mockMetricMetadata = transformMetricMetadata(
|
||||
getMockMetricMetadataData().data as GetMetricMetadata200,
|
||||
) as MetricMetadata;
|
||||
jest
|
||||
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
|
||||
.mockReturnValue({
|
||||
mutate: mockUseUpdateMetricMetadata,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const mockErrorNotification = jest.fn();
|
||||
const mockSuccessNotification = jest.fn();
|
||||
@@ -102,50 +89,47 @@ jest.spyOn(useNotificationsHooks, 'useNotifications').mockReturnValue({
|
||||
},
|
||||
} as any);
|
||||
|
||||
const mockRefetchMetricMetadata = jest.fn();
|
||||
const mockMetricName = 'test_metric';
|
||||
const mockMetricMetadata = {
|
||||
metric_type: MetricType.GAUGE,
|
||||
description: 'test_description',
|
||||
unit: 'test_unit',
|
||||
temporality: Temporality.DELTA,
|
||||
};
|
||||
const mockRefetchMetricDetails = jest.fn();
|
||||
|
||||
describe('Metadata', () => {
|
||||
beforeEach(() => {
|
||||
mockUseUpdateMetricMetadataHook.mockReturnValue(({
|
||||
mutate: mockUseUpdateMetricMetadata,
|
||||
} as Partial<UseUpdateMetricMetadataResult>) as UseUpdateMetricMetadataResult);
|
||||
});
|
||||
|
||||
it('should render the metadata properly', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Metric Type')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gauge')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricMetadata.metric_type)).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricMetadata.description)).toBeInTheDocument();
|
||||
expect(screen.getByText('Unit')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricMetadata.unit)).toBeInTheDocument();
|
||||
expect(screen.getByText('Temporality')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delta')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricMetadata.temporality)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('editing the metadata should show the form inputs', async () => {
|
||||
it('editing the metadata should show the form inputs', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
fireEvent.click(editButton);
|
||||
|
||||
expect(screen.getByTestId('metric-type-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('temporality-select')).toBeInTheDocument();
|
||||
@@ -155,53 +139,57 @@ describe('Metadata', () => {
|
||||
it('should update the metadata when the form is submitted', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricName={mockMetricName}
|
||||
metadata={{
|
||||
...mockMetricMetadata,
|
||||
unit: '',
|
||||
}}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const metricDescriptionInput = screen.getByTestId('description-input');
|
||||
expect(metricDescriptionInput).toBeInTheDocument();
|
||||
await userEvent.clear(metricDescriptionInput);
|
||||
await userEvent.type(metricDescriptionInput, 'Updated description');
|
||||
fireEvent.change(metricDescriptionInput, {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
|
||||
const metricTypeSelect = screen.getByTestId('metric-type-select');
|
||||
expect(metricTypeSelect).toBeInTheDocument();
|
||||
await userEvent.selectOptions(metricTypeSelect, MetrictypesTypeDTO.sum);
|
||||
fireEvent.change(metricTypeSelect, {
|
||||
target: { value: MetricType.SUM },
|
||||
});
|
||||
|
||||
const temporalitySelect = screen.getByTestId('temporality-select');
|
||||
expect(temporalitySelect).toBeInTheDocument();
|
||||
await userEvent.selectOptions(temporalitySelect, Temporality.CUMULATIVE);
|
||||
fireEvent.change(temporalitySelect, {
|
||||
target: { value: Temporality.CUMULATIVE },
|
||||
});
|
||||
|
||||
const unitSelect = screen.getByTestId('unit-select');
|
||||
expect(unitSelect).toBeInTheDocument();
|
||||
await userEvent.selectOptions(unitSelect, 'By');
|
||||
fireEvent.change(unitSelect, {
|
||||
target: { value: 'By' },
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
await userEvent.click(saveButton);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
metricName: mockMetricName,
|
||||
payload: expect.objectContaining({
|
||||
description: 'Updated description',
|
||||
metricType: MetricType.SUM,
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
unit: 'By',
|
||||
isMonotonic: true,
|
||||
}),
|
||||
pathParams: {
|
||||
metricName: MOCK_METRIC_NAME,
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
@@ -213,56 +201,56 @@ describe('Metadata', () => {
|
||||
it('should show success notification when metadata is updated successfully', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
await userEvent.click(editButton);
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const metricDescriptionInput = screen.getByTestId('description-input');
|
||||
await userEvent.clear(metricDescriptionInput);
|
||||
await userEvent.type(metricDescriptionInput, 'Updated description');
|
||||
fireEvent.change(metricDescriptionInput, {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
await userEvent.click(saveButton);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
const onSuccessCallback =
|
||||
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback({ status: 200 });
|
||||
onSuccessCallback({ statusCode: 200 });
|
||||
|
||||
expect(mockSuccessNotification).toHaveBeenCalledWith({
|
||||
message: 'Metadata updated successfully',
|
||||
});
|
||||
expect(mockRefetchMetricDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show error notification when metadata update fails with non-200 response', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
await userEvent.click(editButton);
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const metricDescriptionInput = screen.getByTestId('description-input');
|
||||
await userEvent.clear(metricDescriptionInput);
|
||||
await userEvent.type(metricDescriptionInput, 'Updated description');
|
||||
fireEvent.change(metricDescriptionInput, {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
await userEvent.click(saveButton);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
|
||||
onErrorCallback({ status: 500 });
|
||||
const onSuccessCallback =
|
||||
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback({ statusCode: 500 });
|
||||
|
||||
expect(mockErrorNotification).toHaveBeenCalledWith({
|
||||
message:
|
||||
@@ -273,23 +261,22 @@ describe('Metadata', () => {
|
||||
it('should show error notification when metadata update fails', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
await userEvent.click(editButton);
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const metricDescriptionInput = screen.getByTestId('description-input');
|
||||
await userEvent.clear(metricDescriptionInput);
|
||||
await userEvent.type(metricDescriptionInput, 'Updated description');
|
||||
fireEvent.change(metricDescriptionInput, {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
await userEvent.click(saveButton);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
|
||||
|
||||
@@ -302,43 +289,39 @@ describe('Metadata', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('cancel button should cancel the edit mode', async () => {
|
||||
it('cancel button should cancel the edit mode', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
await userEvent.click(cancelButton);
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
const editButton2 = screen.getByText('Edit');
|
||||
expect(editButton2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not allow editing of unit if it is already set', async () => {
|
||||
it('should not allow editing of unit if it is already set', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const unitSelect = screen.queryByTestId('unit-select');
|
||||
expect(unitSelect).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,16 +1,68 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import MetricDetails from '../MetricDetails';
|
||||
import { getMockMetricMetadataData } from './testUtlls';
|
||||
|
||||
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 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();
|
||||
jest
|
||||
.spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange')
|
||||
@@ -36,50 +88,7 @@ jest.mock('react-query', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/AllAttributes',
|
||||
() =>
|
||||
function MockAllAttributes(): JSX.Element {
|
||||
return <div data-testid="all-attributes">All Attributes</div>;
|
||||
},
|
||||
);
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover',
|
||||
() =>
|
||||
function MockDashboardsAndAlertsPopover(): JSX.Element {
|
||||
return (
|
||||
<div data-testid="dashboards-and-alerts-popover">
|
||||
Dashboards and Alerts Popover
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/Highlights',
|
||||
() =>
|
||||
function MockHighlights(): JSX.Element {
|
||||
return <div data-testid="highlights">Highlights</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/Metadata',
|
||||
() =>
|
||||
function MockMetadata(): JSX.Element {
|
||||
return <div data-testid="metadata">Metadata</div>;
|
||||
},
|
||||
);
|
||||
|
||||
const useGetMetricMetadataMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricMetadata',
|
||||
);
|
||||
|
||||
describe('MetricDetails', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricMetadataMock.mockReturnValue(getMockMetricMetadataData());
|
||||
});
|
||||
|
||||
it('renders metric details correctly', () => {
|
||||
render(
|
||||
<MetricDetails
|
||||
@@ -92,15 +101,27 @@ describe('MetricDetails', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText(mockMetricName)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('all-attributes')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricDescription)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('dashboards-and-alerts-popover'),
|
||||
screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('highlights')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('metadata')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "open in explorer" and "inspect" buttons', async () => {
|
||||
it('renders the "open in explorer" and "inspect" buttons', () => {
|
||||
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValueOnce({
|
||||
...mockUseGetMetricDetailsData,
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
...mockMetricData,
|
||||
metadata: {
|
||||
...mockMetricData.metadata,
|
||||
metric_type: MetricType.GAUGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
render(
|
||||
<MetricDetails
|
||||
onClose={mockOnClose}
|
||||
@@ -114,10 +135,93 @@ describe('MetricDetails', () => {
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('inspect-metric-button')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId('open-in-explorer-button'));
|
||||
fireEvent.click(screen.getByTestId('open-in-explorer-button'));
|
||||
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByTestId('inspect-metric-button'));
|
||||
fireEvent.click(screen.getByTestId('inspect-metric-button'));
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import {
|
||||
GetMetricAlerts200,
|
||||
GetMetricAttributes200,
|
||||
GetMetricDashboards200,
|
||||
GetMetricHighlights200,
|
||||
GetMetricMetadata200,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const MOCK_METRIC_NAME = 'test-metric';
|
||||
|
||||
export function getMockMetricHighlightsData(
|
||||
overrides?: Partial<GetMetricHighlights200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
dataPoints: 1000000,
|
||||
lastReceived: '2026-01-24T00:00:00Z',
|
||||
totalTimeSeries: 1000000,
|
||||
activeTimeSeries: 1000000,
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights>;
|
||||
}
|
||||
|
||||
export const MOCK_DASHBOARD_1 = {
|
||||
dashboardName: 'Dashboard 1',
|
||||
dashboardId: '1',
|
||||
widgetId: '1',
|
||||
widgetName: 'Widget 1',
|
||||
};
|
||||
export const MOCK_DASHBOARD_2 = {
|
||||
dashboardName: 'Dashboard 2',
|
||||
dashboardId: '2',
|
||||
widgetId: '2',
|
||||
widgetName: 'Widget 2',
|
||||
};
|
||||
export const MOCK_ALERT_1 = {
|
||||
alertName: 'Alert 1',
|
||||
alertId: '1',
|
||||
};
|
||||
export const MOCK_ALERT_2 = {
|
||||
alertName: 'Alert 2',
|
||||
alertId: '2',
|
||||
};
|
||||
|
||||
export function getMockDashboardsData(
|
||||
overrides?: Partial<GetMetricDashboards200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2],
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards>;
|
||||
}
|
||||
|
||||
export function getMockAlertsData(
|
||||
overrides?: Partial<GetMetricAlerts200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
alerts: [MOCK_ALERT_1, MOCK_ALERT_2],
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts>;
|
||||
}
|
||||
|
||||
export function getMockMetricAttributesData(
|
||||
overrides?: Partial<GetMetricAttributes200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
attributes: [
|
||||
{
|
||||
key: 'attribute1',
|
||||
values: ['value1', 'value2'],
|
||||
valueCount: 2,
|
||||
},
|
||||
{
|
||||
key: 'attribute2',
|
||||
values: ['value3'],
|
||||
valueCount: 1,
|
||||
},
|
||||
],
|
||||
totalKeys: 2,
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes>;
|
||||
}
|
||||
|
||||
export function getMockMetricMetadataData(
|
||||
overrides?: Partial<GetMetricMetadata200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
description: 'test_description',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
unit: 'test_unit',
|
||||
temporality: MetrictypesTemporalityDTO.delta,
|
||||
isMonotonic: false,
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata>;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
import {
|
||||
determineIsMonotonic,
|
||||
@@ -12,48 +10,35 @@ import {
|
||||
describe('MetricDetails utils', () => {
|
||||
describe('determineIsMonotonic', () => {
|
||||
it('should return true for histogram metrics', () => {
|
||||
expect(determineIsMonotonic(MetrictypesTypeDTO.histogram)).toBe(true);
|
||||
expect(determineIsMonotonic(MetricType.HISTOGRAM)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for exponential histogram metrics', () => {
|
||||
expect(determineIsMonotonic(MetrictypesTypeDTO.exponentialhistogram)).toBe(
|
||||
expect(determineIsMonotonic(MetricType.EXPONENTIAL_HISTOGRAM)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for gauge metrics', () => {
|
||||
expect(determineIsMonotonic(MetricType.GAUGE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for summary metrics', () => {
|
||||
expect(determineIsMonotonic(MetricType.SUMMARY)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for sum metrics with cumulative temporality', () => {
|
||||
expect(determineIsMonotonic(MetricType.SUM, Temporality.CUMULATIVE)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(
|
||||
determineIsMonotonic(
|
||||
MetrictypesTypeDTO.sum,
|
||||
MetrictypesTemporalityDTO.delta,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(determineIsMonotonic(MetricType.SUM, Temporality.DELTA)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false by default', () => {
|
||||
expect(
|
||||
determineIsMonotonic(
|
||||
'' as MetrictypesTypeDTO,
|
||||
'' as MetrictypesTemporalityDTO,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(determineIsMonotonic('' as MetricType, '' as Temporality)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,16 +115,13 @@ describe('MetricDetails utils', () => {
|
||||
const API_GATEWAY = 'api-gateway';
|
||||
|
||||
it('should create correct query for SUM metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
);
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetrictypesTypeDTO.sum,
|
||||
MetricType.SUM,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
|
||||
@@ -147,16 +129,13 @@ describe('MetricDetails utils', () => {
|
||||
});
|
||||
|
||||
it('should create correct query for GAUGE metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.gauge,
|
||||
);
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.GAUGE);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetrictypesTypeDTO.gauge,
|
||||
MetricType.GAUGE,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
|
||||
@@ -164,16 +143,13 @@ describe('MetricDetails utils', () => {
|
||||
});
|
||||
|
||||
it('should create correct query for SUMMARY metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.summary,
|
||||
);
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUMMARY);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetrictypesTypeDTO.summary,
|
||||
MetricType.SUMMARY,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -181,16 +157,13 @@ describe('MetricDetails utils', () => {
|
||||
});
|
||||
|
||||
it('should create correct query for HISTOGRAM metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.histogram,
|
||||
);
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.HISTOGRAM);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetrictypesTypeDTO.histogram,
|
||||
MetricType.HISTOGRAM,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -200,14 +173,14 @@ describe('MetricDetails utils', () => {
|
||||
it('should create correct query for EXPONENTIAL_HISTOGRAM metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.exponentialhistogram,
|
||||
MetricType.EXPONENTIAL_HISTOGRAM,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetrictypesTypeDTO.exponentialhistogram,
|
||||
MetricType.EXPONENTIAL_HISTOGRAM,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -230,7 +203,7 @@ describe('MetricDetails utils', () => {
|
||||
const filter = { key: 'service', value: API_GATEWAY };
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
MetricType.SUM,
|
||||
filter,
|
||||
);
|
||||
|
||||
@@ -248,7 +221,7 @@ describe('MetricDetails utils', () => {
|
||||
const groupBy = 'service';
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
MetricType.SUM,
|
||||
undefined,
|
||||
groupBy,
|
||||
);
|
||||
@@ -263,7 +236,7 @@ describe('MetricDetails utils', () => {
|
||||
const groupBy = 'endpoint';
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
MetricType.SUM,
|
||||
filter,
|
||||
groupBy,
|
||||
);
|
||||
@@ -277,10 +250,7 @@ describe('MetricDetails utils', () => {
|
||||
});
|
||||
|
||||
it('should not include filters or groupBy when not provided', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
);
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
|
||||
|
||||
expect(query.builder.queryData[0]?.filters?.items).toHaveLength(0);
|
||||
expect(query.builder.queryData[0]?.groupBy).toHaveLength(0);
|
||||
|
||||
@@ -1,55 +1,6 @@
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const METRIC_METADATA_KEYS = {
|
||||
description: 'Description',
|
||||
unit: 'Unit',
|
||||
type: 'Metric Type',
|
||||
metric_type: 'Metric Type',
|
||||
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.';
|
||||
|
||||
@@ -1,39 +1,34 @@
|
||||
import {
|
||||
MetricsexplorertypesMetricAlertDTO,
|
||||
MetricsexplorertypesMetricAttributeDTO,
|
||||
MetricsexplorertypesMetricDashboardDTO,
|
||||
MetricsexplorertypesMetricHighlightsResponseDTO,
|
||||
MetricsexplorertypesMetricMetadataDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
MetricDetails,
|
||||
MetricDetailsAlert,
|
||||
MetricDetailsAttribute,
|
||||
MetricDetailsDashboard,
|
||||
} from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
export interface MetricDetailsProps {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
metricName: string;
|
||||
metricName: string | null;
|
||||
isModalTimeSelection: boolean;
|
||||
openInspectModal?: (metricName: string) => void;
|
||||
}
|
||||
|
||||
export interface HighlightsProps {
|
||||
metricName: string;
|
||||
}
|
||||
export interface DashboardsAndAlertsPopoverProps {
|
||||
metricName: string;
|
||||
dashboards: MetricDetailsDashboard[] | null;
|
||||
alerts: MetricDetailsAlert[] | null;
|
||||
}
|
||||
|
||||
export interface MetadataProps {
|
||||
metricName: string;
|
||||
metadata: MetricMetadata | null;
|
||||
isErrorMetricMetadata: boolean;
|
||||
isLoadingMetricMetadata: boolean;
|
||||
refetchMetricMetadata: () => void;
|
||||
metadata: MetricDetails['metadata'] | undefined;
|
||||
refetchMetricDetails: () => void;
|
||||
}
|
||||
|
||||
export interface AllAttributesProps {
|
||||
attributes: MetricDetailsAttribute[];
|
||||
metricName: string;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
metricType: MetricType | undefined;
|
||||
}
|
||||
|
||||
export interface AllAttributesValueProps {
|
||||
@@ -41,38 +36,3 @@ export interface AllAttributesValueProps {
|
||||
filterValue: string[];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics';
|
||||
import {
|
||||
GetMetricMetadata200,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { MetricMetadata, MetricMetadataFormState } from './types';
|
||||
|
||||
export function formatTimestampToReadableDate(
|
||||
timestamp: number | string | undefined,
|
||||
): string {
|
||||
if (!timestamp) {
|
||||
return '-';
|
||||
}
|
||||
export function formatTimestampToReadableDate(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
@@ -50,10 +39,7 @@ export function formatTimestampToReadableDate(
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function formatNumberToCompactFormat(num: number | undefined): string {
|
||||
if (!num) {
|
||||
return '-';
|
||||
}
|
||||
export function formatNumberToCompactFormat(num: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
@@ -61,30 +47,27 @@ export function formatNumberToCompactFormat(num: number | undefined): string {
|
||||
}
|
||||
|
||||
export function determineIsMonotonic(
|
||||
metricType: MetrictypesTypeDTO,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
metricType: MetricType,
|
||||
temporality?: Temporality,
|
||||
): boolean {
|
||||
if (
|
||||
metricType === MetrictypesTypeDTO.histogram ||
|
||||
metricType === MetrictypesTypeDTO.exponentialhistogram
|
||||
metricType === MetricType.HISTOGRAM ||
|
||||
metricType === MetricType.EXPONENTIAL_HISTOGRAM
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
metricType === MetrictypesTypeDTO.gauge ||
|
||||
metricType === MetrictypesTypeDTO.summary
|
||||
) {
|
||||
if (metricType === MetricType.GAUGE || metricType === MetricType.SUMMARY) {
|
||||
return false;
|
||||
}
|
||||
if (metricType === MetrictypesTypeDTO.sum) {
|
||||
return temporality === MetrictypesTemporalityDTO.cumulative;
|
||||
if (metricType === MetricType.SUM) {
|
||||
return temporality === Temporality.CUMULATIVE;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getMetricDetailsQuery(
|
||||
metricName: string,
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
metricType: MetricType | undefined,
|
||||
filter?: { key: string; value: string },
|
||||
groupBy?: string,
|
||||
): Query {
|
||||
@@ -92,23 +75,23 @@ export function getMetricDetailsQuery(
|
||||
let spaceAggregation;
|
||||
let aggregateOperator;
|
||||
switch (metricType) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
case MetricType.SUM:
|
||||
timeAggregation = 'rate';
|
||||
spaceAggregation = 'sum';
|
||||
aggregateOperator = 'rate';
|
||||
break;
|
||||
case MetrictypesTypeDTO.gauge:
|
||||
case MetricType.GAUGE:
|
||||
timeAggregation = 'avg';
|
||||
spaceAggregation = 'avg';
|
||||
aggregateOperator = 'avg';
|
||||
break;
|
||||
case MetrictypesTypeDTO.summary:
|
||||
case MetricType.SUMMARY:
|
||||
timeAggregation = 'noop';
|
||||
spaceAggregation = 'sum';
|
||||
aggregateOperator = 'noop';
|
||||
break;
|
||||
case MetrictypesTypeDTO.histogram:
|
||||
case MetrictypesTypeDTO.exponentialhistogram:
|
||||
case MetricType.HISTOGRAM:
|
||||
case MetricType.EXPONENTIAL_HISTOGRAM:
|
||||
timeAggregation = 'noop';
|
||||
spaceAggregation = 'p90';
|
||||
aggregateOperator = 'noop';
|
||||
@@ -177,38 +160,3 @@ export function getMetricDetailsQuery(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function transformMetricMetadata(
|
||||
apiData: GetMetricMetadata200 | undefined,
|
||||
): MetricMetadata | null {
|
||||
if (!apiData || !apiData.data) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } = apiData.data;
|
||||
|
||||
return {
|
||||
type,
|
||||
description,
|
||||
unit,
|
||||
temporality,
|
||||
isMonotonic,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformUpdateMetricMetadataRequest(
|
||||
metricName: string,
|
||||
metricMetadata: MetricMetadataFormState,
|
||||
): UpdateMetricMetadataMutationBody {
|
||||
return {
|
||||
metricName: metricName,
|
||||
type: metricMetadata.type,
|
||||
description: metricMetadata.description,
|
||||
unit: metricMetadata.unit,
|
||||
temporality:
|
||||
metricMetadata.temporality ?? MetrictypesTemporalityDTO.unspecified,
|
||||
isMonotonic: determineIsMonotonic(
|
||||
metricMetadata.type,
|
||||
metricMetadata.temporality,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
@@ -9,24 +10,22 @@ import {
|
||||
Popover,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import { Filter } from 'api/v5/v5';
|
||||
import {
|
||||
convertExpressionToFilters,
|
||||
convertFiltersToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Search } from 'lucide-react';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SUMMARY_FILTERS_KEY } from './constants';
|
||||
|
||||
function MetricNameSearch({
|
||||
queryFilterExpression,
|
||||
onFilterChange,
|
||||
queryFilters,
|
||||
}: {
|
||||
queryFilterExpression: Filter;
|
||||
onFilterChange: (value: string) => void;
|
||||
queryFilters: TagFilter;
|
||||
}): JSX.Element {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
const [searchString, setSearchString] = useState<string>('');
|
||||
const [debouncedSearchString, setDebouncedSearchString] = useState<string>('');
|
||||
@@ -68,12 +67,9 @@ function MetricNameSearch({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedMetricName: string): void => {
|
||||
const queryFilters = convertExpressionToFilters(
|
||||
queryFilterExpression.expression,
|
||||
);
|
||||
const newFilters = {
|
||||
items: [
|
||||
...queryFilters,
|
||||
...queryFilters.items,
|
||||
{
|
||||
id: 'metric_name',
|
||||
op: 'CONTAINS',
|
||||
@@ -87,11 +83,13 @@ function MetricNameSearch({
|
||||
],
|
||||
op: 'and',
|
||||
};
|
||||
const newFilterExpression = convertFiltersToExpression(newFilters);
|
||||
onFilterChange(newFilterExpression.expression);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[queryFilterExpression, onFilterChange],
|
||||
[queryFilters.items, setSearchParams, searchParams],
|
||||
);
|
||||
|
||||
const metricNameFilterValues = useMemo(
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Typography } from 'antd';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import {
|
||||
BarChart,
|
||||
BarChart2,
|
||||
BarChartHorizontal,
|
||||
Diff,
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { METRIC_TYPE_LABEL_MAP } from './constants';
|
||||
|
||||
// TODO: @amlannandy Delete this component after API migration is complete
|
||||
function MetricTypeRenderer({ type }: { type: MetricType }): JSX.Element {
|
||||
const [icon, color] = useMemo(() => {
|
||||
switch (type) {
|
||||
case MetricType.SUM:
|
||||
return [
|
||||
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
|
||||
Color.BG_ROBIN_500,
|
||||
];
|
||||
case MetricType.GAUGE:
|
||||
return [
|
||||
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
|
||||
Color.BG_SAKURA_500,
|
||||
];
|
||||
case MetricType.HISTOGRAM:
|
||||
return [
|
||||
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
|
||||
Color.BG_SIENNA_500,
|
||||
];
|
||||
case MetricType.SUMMARY:
|
||||
return [
|
||||
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
|
||||
Color.BG_FOREST_500,
|
||||
];
|
||||
case MetricType.EXPONENTIAL_HISTOGRAM:
|
||||
return [
|
||||
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
|
||||
Color.BG_AQUA_500,
|
||||
];
|
||||
default:
|
||||
return [null, ''];
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const metricTypeRendererStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: `${color}33`,
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
}),
|
||||
[color],
|
||||
);
|
||||
|
||||
const metricTypeRendererTextStyle = useMemo(
|
||||
() => ({
|
||||
color,
|
||||
fontSize: 12,
|
||||
}),
|
||||
[color],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="metric-type-renderer" style={metricTypeRendererStyle}>
|
||||
{icon}
|
||||
<Typography.Text style={metricTypeRendererTextStyle}>
|
||||
{METRIC_TYPE_LABEL_MAP[type]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricTypeRenderer;
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { Button, Menu, Popover, Tooltip } from 'antd';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { Search } from 'lucide-react';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { METRIC_TYPE_VIEW_VALUES_MAP } from './constants';
|
||||
import {
|
||||
METRIC_TYPE_LABEL_MAP,
|
||||
METRIC_TYPE_VALUES_MAP,
|
||||
SUMMARY_FILTERS_KEY,
|
||||
} from './constants';
|
||||
|
||||
function MetricTypeSearch({
|
||||
queryFilters,
|
||||
onFilterChange,
|
||||
}: {
|
||||
queryFilters: TagFilter;
|
||||
onFilterChange: (expression: string) => void;
|
||||
}): JSX.Element {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const menuItems = useMemo(
|
||||
@@ -22,9 +26,9 @@ function MetricTypeSearch({
|
||||
key: 'all',
|
||||
value: 'All',
|
||||
},
|
||||
...Object.keys(METRIC_TYPE_VIEW_VALUES_MAP).map((key) => ({
|
||||
key: METRIC_TYPE_VIEW_VALUES_MAP[key as MetrictypesTypeDTO],
|
||||
value: METRIC_TYPE_VIEW_VALUES_MAP[key as MetrictypesTypeDTO],
|
||||
...Object.keys(METRIC_TYPE_LABEL_MAP).map((key) => ({
|
||||
key: METRIC_TYPE_VALUES_MAP[key as MetricType],
|
||||
value: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||
})),
|
||||
],
|
||||
[],
|
||||
@@ -32,17 +36,16 @@ function MetricTypeSearch({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedMetricType: string): void => {
|
||||
let newFilters;
|
||||
if (selectedMetricType !== 'all') {
|
||||
newFilters = {
|
||||
const newFilters = {
|
||||
items: [
|
||||
...queryFilters.items,
|
||||
{
|
||||
id: 'type',
|
||||
id: 'metric_type',
|
||||
op: '=',
|
||||
key: {
|
||||
id: 'type',
|
||||
key: 'type',
|
||||
id: 'metric_type',
|
||||
key: 'metric_type',
|
||||
type: 'tag',
|
||||
},
|
||||
value: selectedMetricType,
|
||||
@@ -50,17 +53,23 @@ function MetricTypeSearch({
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
} else {
|
||||
newFilters = {
|
||||
items: queryFilters.items.filter((item) => item.id !== 'type'),
|
||||
const newFilters = {
|
||||
items: queryFilters.items.filter((item) => item.id !== 'metric_type'),
|
||||
op: 'AND',
|
||||
};
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
}
|
||||
const newFilterExpression = convertFiltersToExpression(newFilters);
|
||||
onFilterChange(newFilterExpression.expression);
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[queryFilters.items, onFilterChange],
|
||||
[queryFilters.items, setSearchParams, searchParams],
|
||||
);
|
||||
|
||||
const menu = (
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Typography } from 'antd';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
BarChart,
|
||||
BarChart2,
|
||||
BarChartHorizontal,
|
||||
Diff,
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { METRIC_TYPE_VIEW_VALUES_MAP } from './constants';
|
||||
|
||||
export function MetricTypeViewRenderer({
|
||||
type,
|
||||
}: {
|
||||
type: MetrictypesTypeDTO;
|
||||
}): JSX.Element {
|
||||
const [icon, color] = useMemo(() => {
|
||||
switch (type) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
return [
|
||||
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
|
||||
Color.BG_ROBIN_500,
|
||||
];
|
||||
case MetrictypesTypeDTO.gauge:
|
||||
return [
|
||||
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
|
||||
Color.BG_SAKURA_500,
|
||||
];
|
||||
case MetrictypesTypeDTO.histogram:
|
||||
return [
|
||||
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
|
||||
Color.BG_SIENNA_500,
|
||||
];
|
||||
case MetrictypesTypeDTO.summary:
|
||||
return [
|
||||
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
|
||||
Color.BG_FOREST_500,
|
||||
];
|
||||
case MetrictypesTypeDTO.exponentialhistogram:
|
||||
return [
|
||||
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
|
||||
Color.BG_AQUA_500,
|
||||
];
|
||||
default:
|
||||
return [null, ''];
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const {
|
||||
metricTypeViewRendererStyle,
|
||||
metricTypeViewRendererTextStyle,
|
||||
} = useMemo(() => {
|
||||
return {
|
||||
metricTypeViewRendererStyle: {
|
||||
backgroundColor: `${color}33`,
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
},
|
||||
metricTypeViewRendererTextStyle: {
|
||||
color,
|
||||
fontSize: 12,
|
||||
},
|
||||
};
|
||||
}, [color]);
|
||||
|
||||
return (
|
||||
<div className="metric-type-renderer" style={metricTypeViewRendererStyle}>
|
||||
{icon}
|
||||
<Typography.Text style={metricTypeViewRendererTextStyle}>
|
||||
{METRIC_TYPE_VIEW_VALUES_MAP[type]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricTypeViewRenderer;
|
||||
@@ -1,58 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { Info } from 'lucide-react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { HardHat, Info } from 'lucide-react';
|
||||
|
||||
import { MetricsSearchProps } from './types';
|
||||
|
||||
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]);
|
||||
|
||||
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
|
||||
return (
|
||||
<div className="metrics-search-container">
|
||||
<div data-testid="qb-search-container" className="qb-search-container">
|
||||
<div className="qb-search-container">
|
||||
<Tooltip
|
||||
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
<QuerySearch
|
||||
onChange={handleOnChange}
|
||||
dataSource={DataSource.METRICS}
|
||||
queryData={{
|
||||
...query,
|
||||
filter: {
|
||||
...query?.filter,
|
||||
expression: currentQueryFilterExpression,
|
||||
},
|
||||
}}
|
||||
onRun={handleOnChange}
|
||||
showFilterSuggestionsWithoutMetric
|
||||
<QueryBuilderSearch
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
suffixIcon={<HardHat size={16} />}
|
||||
isMetricsExplorer
|
||||
/>
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={handleStageAndRunQuery}
|
||||
isLoadingQueries={isLoading}
|
||||
/>
|
||||
<div className="metrics-search-options">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { SorterResult } from 'antd/es/table/interface';
|
||||
import { Querybuildertypesv5OrderDirectionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { MetricsListItemRowData, MetricsTableProps } from './types';
|
||||
@@ -25,8 +24,7 @@ function MetricsTable({
|
||||
setOrderBy,
|
||||
totalCount,
|
||||
openMetricDetails,
|
||||
queryFilterExpression,
|
||||
onFilterChange,
|
||||
queryFilters,
|
||||
}: MetricsTableProps): JSX.Element {
|
||||
const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback(
|
||||
(
|
||||
@@ -38,20 +36,13 @@ function MetricsTable({
|
||||
): void => {
|
||||
if ('field' in sorter && sorter.order) {
|
||||
setOrderBy({
|
||||
key: {
|
||||
name: sorter.field as string,
|
||||
},
|
||||
direction:
|
||||
sorter.order === 'ascend'
|
||||
? Querybuildertypesv5OrderDirectionDTO.asc
|
||||
: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
columnName: sorter.field as string,
|
||||
order: sorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
});
|
||||
} else {
|
||||
setOrderBy({
|
||||
key: {
|
||||
name: 'samples',
|
||||
},
|
||||
direction: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
columnName: 'samples',
|
||||
order: 'desc',
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -60,17 +51,19 @@ function MetricsTable({
|
||||
|
||||
return (
|
||||
<div className="metrics-table-container">
|
||||
<div className="metrics-table-title" data-testid="metrics-table-title">
|
||||
<Typography.Title level={4} className="metrics-table-title">
|
||||
List View
|
||||
</Typography.Title>
|
||||
<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."
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isError && !isLoading && (
|
||||
<div className="metrics-table-title" data-testid="metrics-table-title">
|
||||
<Typography.Title level={4} className="metrics-table-title">
|
||||
List View
|
||||
</Typography.Title>
|
||||
<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."
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
loading={{
|
||||
spinning: isLoading,
|
||||
@@ -82,7 +75,7 @@ function MetricsTable({
|
||||
),
|
||||
}}
|
||||
dataSource={data}
|
||||
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
|
||||
columns={getMetricsTableColumns(queryFilters)}
|
||||
locale={{
|
||||
emptyText: isLoading ? null : (
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { Group } from '@visx/group';
|
||||
import { Treemap } from '@visx/hierarchy';
|
||||
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { HierarchyNode, stratify, treemapBinary } from 'd3-hierarchy';
|
||||
import { stratify, treemapBinary } from 'd3-hierarchy';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -13,24 +12,21 @@ import {
|
||||
TREEMAP_SQUARE_PADDING,
|
||||
TREEMAP_VIEW_OPTIONS,
|
||||
} from './constants';
|
||||
import {
|
||||
MetricsTreemapInternalProps,
|
||||
MetricsTreemapProps,
|
||||
TreemapTile,
|
||||
} from './types';
|
||||
import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
|
||||
import {
|
||||
getTreemapTileStyle,
|
||||
getTreemapTileTextStyle,
|
||||
transformTreemapData,
|
||||
} from './utils';
|
||||
|
||||
function MetricsTreemapInternal({
|
||||
function MetricsTreemap({
|
||||
viewType,
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
data,
|
||||
viewType,
|
||||
openMetricDetails,
|
||||
}: MetricsTreemapInternalProps): JSX.Element {
|
||||
setHeatmapView,
|
||||
}: MetricsTreemapProps): JSX.Element {
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const treemapWidth = useMemo(
|
||||
@@ -44,9 +40,9 @@ function MetricsTreemapInternal({
|
||||
|
||||
const treemapData = useMemo(() => {
|
||||
const extracedTreemapData =
|
||||
(viewType === MetricsexplorertypesTreemapModeDTO.timeseries
|
||||
? data?.timeseries
|
||||
: data?.samples) || [];
|
||||
(viewType === TreemapViewType.TIMESERIES
|
||||
? data?.data?.[TreemapViewType.TIMESERIES]
|
||||
: data?.data?.[TreemapViewType.SAMPLES]) || [];
|
||||
return transformTreemapData(extracedTreemapData, viewType);
|
||||
}, [data, viewType]);
|
||||
|
||||
@@ -58,126 +54,41 @@ function MetricsTreemapInternal({
|
||||
const xMax = treemapWidth - TREEMAP_MARGINS.LEFT - TREEMAP_MARGINS.RIGHT;
|
||||
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) {
|
||||
return (
|
||||
<div data-testid="metrics-treemap-loading-state">
|
||||
<Skeleton style={treemapStylesWithoutPadding} active />
|
||||
<Skeleton
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Empty
|
||||
description="Error fetching metrics. If the problem persists, please contact support."
|
||||
data-testid="metrics-treemap-error-state"
|
||||
style={treemapStylesWithPadding}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data?.[viewType]?.length) {
|
||||
if (
|
||||
!data ||
|
||||
!data.data ||
|
||||
(data?.status === 'success' && !data?.data?.[viewType])
|
||||
) {
|
||||
return (
|
||||
<Empty
|
||||
description="No metrics found"
|
||||
data-testid="metrics-treemap-empty-state"
|
||||
style={treemapStylesWithPadding}
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={treemapWidth} height={TREEMAP_HEIGHT} className="metrics-treemap">
|
||||
<rect
|
||||
width={treemapWidth}
|
||||
height={TREEMAP_HEIGHT}
|
||||
rx={14}
|
||||
fill="transparent"
|
||||
if (data?.status === 'error' || isError) {
|
||||
return (
|
||||
<Empty
|
||||
description="Error fetching metrics. If the problem persists, please contact support."
|
||||
data-testid="metrics-treemap-error-state"
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
|
||||
/>
|
||||
<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 (
|
||||
<div
|
||||
className="metrics-treemap-container"
|
||||
@@ -197,16 +108,72 @@ function MetricsTreemap({
|
||||
options={TREEMAP_VIEW_OPTIONS}
|
||||
value={viewType}
|
||||
onChange={setHeatmapView}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<MetricsTreemapInternal
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
data={data}
|
||||
viewType={viewType}
|
||||
openMetricDetails={openMetricDetails}
|
||||
/>
|
||||
<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={{
|
||||
...getTreemapTileStyle(node.data),
|
||||
...getTreemapTileTextStyle(),
|
||||
}}
|
||||
>
|
||||
{`${node.data.displayValue}%`}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</Treemap>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
.metrics-search-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.metrics-search-options {
|
||||
display: flex;
|
||||
|
||||
@@ -4,24 +4,11 @@ import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
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 { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetMetricsTreeMap } from 'hooks/metricsExplorer/useGetMetricsTreeMap';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -36,38 +23,32 @@ import {
|
||||
IS_INSPECT_MODAL_OPEN_KEY,
|
||||
IS_METRIC_DETAILS_OPEN_KEY,
|
||||
SELECTED_METRIC_NAME_KEY,
|
||||
SUMMARY_FILTERS_KEY,
|
||||
} from './constants';
|
||||
import MetricsSearch from './MetricsSearch';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import MetricsTreemap from './MetricsTreemap';
|
||||
import { convertNanoToMilliseconds, formatDataForMetricsTable } from './utils';
|
||||
import { OrderByPayload, TreemapViewType } from './types';
|
||||
import {
|
||||
convertNanoToMilliseconds,
|
||||
formatDataForMetricsTable,
|
||||
getMetricsListQuery,
|
||||
} from './utils';
|
||||
|
||||
import './Summary.styles.scss';
|
||||
|
||||
const DEFAULT_ORDER_BY: Querybuildertypesv5OrderByDTO = {
|
||||
key: {
|
||||
name: 'samples',
|
||||
},
|
||||
direction: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
const DEFAULT_ORDER_BY: OrderByPayload = {
|
||||
columnName: 'samples',
|
||||
order: 'desc',
|
||||
};
|
||||
|
||||
function Summary(): JSX.Element {
|
||||
const { pageSize, setPageSize } = usePageSize('metricsExplorer');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [orderBy, setOrderBy] = useState<Querybuildertypesv5OrderByDTO>(
|
||||
DEFAULT_ORDER_BY,
|
||||
const [orderBy, setOrderBy] = useState<OrderByPayload>(DEFAULT_ORDER_BY);
|
||||
const [heatmapView, setHeatmapView] = useState<TreemapViewType>(
|
||||
TreemapViewType.TIMESERIES,
|
||||
);
|
||||
const [
|
||||
heatmapView,
|
||||
setHeatmapView,
|
||||
] = useState<MetricsexplorertypesTreemapModeDTO>(
|
||||
MetricsexplorertypesTreemapModeDTO.timeseries,
|
||||
);
|
||||
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
|
||||
currentQuery,
|
||||
]);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
|
||||
@@ -84,10 +65,16 @@ function Summary(): JSX.Element {
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [
|
||||
currentQueryFilterExpression,
|
||||
setCurrentQueryFilterExpression,
|
||||
] = useState<string>(query?.filter?.expression || '');
|
||||
const queryFilters: TagFilter = useMemo(() => {
|
||||
const encodedFilters = searchParams.get(SUMMARY_FILTERS_KEY);
|
||||
if (encodedFilters) {
|
||||
return JSON.parse(encodedFilters);
|
||||
}
|
||||
return {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.TabChanged, {
|
||||
@@ -100,101 +87,105 @@ function Summary(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const queryFilterExpression = useMemo(() => {
|
||||
const filters = query.filters || { items: [], op: 'AND' };
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [query.filters]);
|
||||
// This is used to avoid the filters from being serialized with the id
|
||||
const queryFiltersWithoutId = useMemo(() => {
|
||||
const filtersWithoutId = {
|
||||
...queryFilters,
|
||||
items: queryFilters.items.map(({ id: _id, ...rest }) => rest),
|
||||
};
|
||||
return JSON.stringify(filtersWithoutId);
|
||||
}, [queryFilters]);
|
||||
|
||||
const metricsListQuery: MetricsexplorertypesStatsRequestDTO = useMemo(() => {
|
||||
const metricsListQuery = useMemo(() => {
|
||||
const baseQuery = getMetricsListQuery();
|
||||
return {
|
||||
start: convertNanoToMilliseconds(minTime),
|
||||
end: convertNanoToMilliseconds(maxTime),
|
||||
...baseQuery,
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
filters: queryFilters,
|
||||
start: convertNanoToMilliseconds(minTime),
|
||||
end: convertNanoToMilliseconds(maxTime),
|
||||
orderBy,
|
||||
filter: {
|
||||
expression: queryFilterExpression.expression,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
minTime,
|
||||
maxTime,
|
||||
orderBy,
|
||||
pageSize,
|
||||
currentPage,
|
||||
queryFilterExpression.expression,
|
||||
]);
|
||||
}, [queryFilters, minTime, maxTime, orderBy, pageSize, currentPage]);
|
||||
|
||||
const metricsTreemapQuery: MetricsexplorertypesTreemapRequestDTO = useMemo(
|
||||
const metricsTreemapQuery = useMemo(
|
||||
() => ({
|
||||
limit: 100,
|
||||
filters: queryFilters,
|
||||
treemap: heatmapView,
|
||||
start: convertNanoToMilliseconds(minTime),
|
||||
end: convertNanoToMilliseconds(maxTime),
|
||||
mode: heatmapView,
|
||||
filter: {
|
||||
expression: queryFilterExpression.expression,
|
||||
},
|
||||
}),
|
||||
[heatmapView, minTime, maxTime, queryFilterExpression.expression],
|
||||
[queryFilters, heatmapView, minTime, maxTime],
|
||||
);
|
||||
|
||||
const {
|
||||
data: metricsData,
|
||||
mutate: getMetricsStats,
|
||||
isLoading: isGetMetricsStatsLoading,
|
||||
isError: isGetMetricsStatsError,
|
||||
} = useGetMetricsStats();
|
||||
isLoading: isMetricsLoading,
|
||||
isFetching: isMetricsFetching,
|
||||
isError: isMetricsError,
|
||||
} = useGetMetricsList(metricsListQuery, {
|
||||
enabled: !!metricsListQuery && !isInspectModalOpen,
|
||||
queryKey: [
|
||||
'metricsList',
|
||||
queryFiltersWithoutId,
|
||||
orderBy,
|
||||
pageSize,
|
||||
currentPage,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
});
|
||||
|
||||
const isListViewError = useMemo(
|
||||
() => isMetricsError || !!(metricsData && metricsData.statusCode !== 200),
|
||||
[isMetricsError, metricsData],
|
||||
);
|
||||
|
||||
const {
|
||||
data: treeMapData,
|
||||
mutate: getMetricsTreemap,
|
||||
isLoading: isGetMetricsTreemapLoading,
|
||||
isError: isGetMetricsTreemapError,
|
||||
} = useGetMetricsTreemap();
|
||||
isLoading: isTreeMapLoading,
|
||||
isFetching: isTreeMapFetching,
|
||||
isError: isTreeMapError,
|
||||
} = useGetMetricsTreeMap(metricsTreemapQuery, {
|
||||
enabled: !!metricsTreemapQuery && !isInspectModalOpen,
|
||||
queryKey: [
|
||||
'metricsTreemap',
|
||||
queryFiltersWithoutId,
|
||||
heatmapView,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getMetricsStats({
|
||||
data: metricsListQuery,
|
||||
});
|
||||
}, [metricsListQuery, getMetricsStats]);
|
||||
|
||||
useEffect(() => {
|
||||
getMetricsTreemap({
|
||||
data: metricsTreemapQuery,
|
||||
});
|
||||
}, [metricsTreemapQuery, getMetricsTreemap]);
|
||||
const isProportionViewError = useMemo(
|
||||
() => isTreeMapError || treeMapData?.statusCode !== 200,
|
||||
[isTreeMapError, treeMapData],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(expression: string) => {
|
||||
const newFilters: TagFilter = {
|
||||
items: convertExpressionToFilters(expression),
|
||||
op: 'AND',
|
||||
};
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: newFilters,
|
||||
filter: {
|
||||
expression,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
(value: TagFilter) => {
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(value),
|
||||
});
|
||||
setCurrentQueryFilterExpression(expression);
|
||||
setCurrentPage(1);
|
||||
if (expression) {
|
||||
if (value.items.length > 0) {
|
||||
logEvent(MetricsExplorerEvents.FilterApplied, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
[setSearchParams, searchParams],
|
||||
);
|
||||
|
||||
const searchQuery = useMemo(
|
||||
() => ({
|
||||
...initialQueriesMap.metrics.builder.queryData[0],
|
||||
filters: queryFilters,
|
||||
}),
|
||||
[queryFilters],
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, pageSize: number): void => {
|
||||
@@ -211,7 +202,7 @@ function Summary(): JSX.Element {
|
||||
};
|
||||
|
||||
const formattedMetricsData = useMemo(
|
||||
() => formatDataForMetricsTable(metricsData?.data.metrics || []),
|
||||
() => formatDataForMetricsTable(metricsData?.payload?.data?.metrics || []),
|
||||
[metricsData],
|
||||
);
|
||||
|
||||
@@ -263,9 +254,7 @@ function Summary(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetHeatmapView = (
|
||||
view: MetricsexplorertypesTreemapModeDTO,
|
||||
): void => {
|
||||
const handleSetHeatmapView = (view: TreemapViewType): void => {
|
||||
setHeatmapView(view);
|
||||
logEvent(MetricsExplorerEvents.TreemapViewChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
@@ -273,67 +262,68 @@ function Summary(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetOrderBy = (orderBy: Querybuildertypesv5OrderByDTO): void => {
|
||||
const handleSetOrderBy = (orderBy: OrderByPayload): void => {
|
||||
setOrderBy(orderBy);
|
||||
logEvent(MetricsExplorerEvents.OrderByApplied, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.ColumnName]: orderBy.key?.name,
|
||||
[MetricsExplorerEventKeys.Order]: orderBy.direction,
|
||||
[MetricsExplorerEventKeys.ColumnName]: orderBy.columnName,
|
||||
[MetricsExplorerEventKeys.Order]: orderBy.order,
|
||||
});
|
||||
};
|
||||
|
||||
const isMetricsListDataEmpty =
|
||||
formattedMetricsData.length === 0 && !isGetMetricsStatsLoading;
|
||||
const isMetricsListDataEmpty = useMemo(
|
||||
() =>
|
||||
formattedMetricsData.length === 0 && !isMetricsLoading && !isMetricsFetching,
|
||||
[formattedMetricsData, isMetricsLoading, isMetricsFetching],
|
||||
);
|
||||
|
||||
const isMetricsTreeMapDataEmpty =
|
||||
!treeMapData?.data[heatmapView]?.length && !isGetMetricsTreemapLoading;
|
||||
|
||||
const showFullScreenLoading =
|
||||
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
|
||||
formattedMetricsData.length === 0 &&
|
||||
!treeMapData?.data[heatmapView]?.length;
|
||||
const isMetricsTreeMapDataEmpty = useMemo(
|
||||
() =>
|
||||
!treeMapData?.payload?.data[heatmapView]?.length &&
|
||||
!isTreeMapLoading &&
|
||||
!isTreeMapFetching,
|
||||
[
|
||||
treeMapData?.payload?.data,
|
||||
heatmapView,
|
||||
isTreeMapLoading,
|
||||
isTreeMapFetching,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-summary-tab">
|
||||
<MetricsSearch
|
||||
query={query}
|
||||
onChange={handleFilterChange}
|
||||
currentQueryFilterExpression={currentQueryFilterExpression}
|
||||
setCurrentQueryFilterExpression={setCurrentQueryFilterExpression}
|
||||
isLoading={isGetMetricsStatsLoading || isGetMetricsTreemapLoading}
|
||||
/>
|
||||
{showFullScreenLoading ? (
|
||||
<MetricsSearch query={searchQuery} onChange={handleFilterChange} />
|
||||
{isMetricsLoading || isTreeMapLoading ? (
|
||||
<MetricsLoading />
|
||||
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
|
||||
<NoLogs dataSource={DataSource.METRICS} />
|
||||
) : (
|
||||
<>
|
||||
<MetricsTreemap
|
||||
data={treeMapData?.data}
|
||||
isLoading={isGetMetricsTreemapLoading}
|
||||
isError={isGetMetricsTreemapError}
|
||||
data={treeMapData?.payload}
|
||||
isLoading={isTreeMapLoading || isTreeMapFetching}
|
||||
isError={isProportionViewError}
|
||||
viewType={heatmapView}
|
||||
openMetricDetails={openMetricDetails}
|
||||
setHeatmapView={handleSetHeatmapView}
|
||||
/>
|
||||
<MetricsTable
|
||||
isLoading={isGetMetricsStatsLoading}
|
||||
isError={isGetMetricsStatsError}
|
||||
isLoading={isMetricsLoading || isMetricsFetching}
|
||||
isError={isListViewError}
|
||||
data={formattedMetricsData}
|
||||
pageSize={pageSize}
|
||||
currentPage={currentPage}
|
||||
onPaginationChange={onPaginationChange}
|
||||
setOrderBy={handleSetOrderBy}
|
||||
totalCount={metricsData?.data.total || 0}
|
||||
totalCount={metricsData?.payload?.data?.total || 0}
|
||||
openMetricDetails={openMetricDetails}
|
||||
queryFilterExpression={queryFilterExpression}
|
||||
onFilterChange={handleFilterChange}
|
||||
queryFilters={queryFilters}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isMetricDetailsOpen && selectedMetricName && (
|
||||
{isMetricDetailsOpen && (
|
||||
<MetricDetails
|
||||
isOpen={isMetricDetailsOpen}
|
||||
onClose={closeMetricDetails}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
import MetricTypeRenderer from '../MetricTypeRenderer';
|
||||
|
||||
jest.mock('lucide-react', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
Diff: (): JSX.Element => <svg data-testid="diff-icon" />,
|
||||
Gauge: (): JSX.Element => <svg data-testid="gauge-icon" />,
|
||||
BarChart2: (): JSX.Element => <svg data-testid="bar-chart-2-icon" />,
|
||||
BarChartHorizontal: (): JSX.Element => (
|
||||
<svg data-testid="bar-chart-horizontal-icon" />
|
||||
),
|
||||
BarChart: (): JSX.Element => <svg data-testid="bar-chart-icon" />,
|
||||
};
|
||||
});
|
||||
|
||||
describe('MetricTypeRenderer', () => {
|
||||
it('should render correct icon and color for each metric type', () => {
|
||||
const types = [
|
||||
{
|
||||
type: MetricType.SUM,
|
||||
color: Color.BG_ROBIN_500,
|
||||
iconTestId: 'diff-icon',
|
||||
},
|
||||
{
|
||||
type: MetricType.GAUGE,
|
||||
color: Color.BG_SAKURA_500,
|
||||
iconTestId: 'gauge-icon',
|
||||
},
|
||||
{
|
||||
type: MetricType.HISTOGRAM,
|
||||
color: Color.BG_SIENNA_500,
|
||||
iconTestId: 'bar-chart-2-icon',
|
||||
},
|
||||
{
|
||||
type: MetricType.SUMMARY,
|
||||
color: Color.BG_FOREST_500,
|
||||
iconTestId: 'bar-chart-horizontal-icon',
|
||||
},
|
||||
{
|
||||
type: MetricType.EXPONENTIAL_HISTOGRAM,
|
||||
color: Color.BG_AQUA_500,
|
||||
iconTestId: 'bar-chart-icon',
|
||||
},
|
||||
];
|
||||
|
||||
types.forEach(({ type, color, iconTestId }) => {
|
||||
const { container } = render(<MetricTypeRenderer type={type} />);
|
||||
const rendererDiv = container.firstChild as HTMLElement;
|
||||
|
||||
expect(rendererDiv).toHaveStyle({
|
||||
backgroundColor: `${color}33`,
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(iconTestId)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Filter } from 'api/v5/v5';
|
||||
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
|
||||
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import store from 'store';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import MetricsTable from '../MetricsTable';
|
||||
import { MetricsListItemRowData } from '../types';
|
||||
@@ -30,8 +30,9 @@ const mockData: MetricsListItemRowData[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockQueryFilterExpression: Filter = {
|
||||
expression: '',
|
||||
const mockQueryFilters: TagFilter = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
jest.mock('react-router-dom-v5-compat', () => {
|
||||
@@ -81,8 +82,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilterExpression={mockQueryFilterExpression}
|
||||
onFilterChange={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -106,9 +106,8 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilterExpression={mockQueryFilterExpression}
|
||||
queryFilters={mockQueryFilters}
|
||||
isLoading
|
||||
onFilterChange={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -131,8 +130,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilterExpression={mockQueryFilterExpression}
|
||||
onFilterChange={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -160,8 +158,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilterExpression={mockQueryFilterExpression}
|
||||
onFilterChange={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -190,8 +187,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={mockOpenMetricDetails}
|
||||
queryFilterExpression={mockQueryFilterExpression}
|
||||
onFilterChange={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -216,8 +212,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={mockSetOrderBy}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilterExpression={mockQueryFilterExpression}
|
||||
onFilterChange={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -227,10 +222,8 @@ describe('MetricsTable', () => {
|
||||
fireEvent.click(samplesHeader);
|
||||
|
||||
expect(mockSetOrderBy).toHaveBeenCalledWith({
|
||||
key: {
|
||||
name: 'samples',
|
||||
},
|
||||
direction: 'asc',
|
||||
columnName: 'samples',
|
||||
order: 'asc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import store from 'store';
|
||||
|
||||
import MetricsTreemap from '../MetricsTreemap';
|
||||
import { TreemapViewType } from '../types';
|
||||
|
||||
jest.mock('d3-hierarchy', () => ({
|
||||
stratify: jest.fn().mockReturnValue({
|
||||
@@ -27,14 +27,14 @@ jest.mock('react-use', () => ({
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
metricName: 'Metric 1',
|
||||
metric_name: 'Metric 1',
|
||||
percentage: 0.5,
|
||||
totalValue: 15,
|
||||
total_value: 15,
|
||||
},
|
||||
{
|
||||
metricName: 'Metric 2',
|
||||
metric_name: 'Metric 2',
|
||||
percentage: 0.6,
|
||||
totalValue: 10,
|
||||
total_value: 10,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,11 +47,14 @@ describe('MetricsTreemap', () => {
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
data={{
|
||||
timeseries: [mockData[0]],
|
||||
samples: [mockData[1]],
|
||||
status: 'success',
|
||||
data: {
|
||||
timeseries: [mockData[0]],
|
||||
samples: [mockData[1]],
|
||||
},
|
||||
}}
|
||||
openMetricDetails={jest.fn()}
|
||||
viewType={MetricsexplorertypesTreemapModeDTO.samples}
|
||||
viewType={TreemapViewType.SAMPLES}
|
||||
setHeatmapView={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
@@ -69,11 +72,14 @@ describe('MetricsTreemap', () => {
|
||||
isLoading
|
||||
isError={false}
|
||||
data={{
|
||||
timeseries: [mockData[0]],
|
||||
samples: [mockData[1]],
|
||||
status: 'success',
|
||||
data: {
|
||||
timeseries: [mockData[0]],
|
||||
samples: [mockData[1]],
|
||||
},
|
||||
}}
|
||||
openMetricDetails={jest.fn()}
|
||||
viewType={MetricsexplorertypesTreemapModeDTO.samples}
|
||||
viewType={TreemapViewType.SAMPLES}
|
||||
setHeatmapView={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
@@ -93,11 +99,14 @@ describe('MetricsTreemap', () => {
|
||||
isLoading={false}
|
||||
isError
|
||||
data={{
|
||||
timeseries: [mockData[0]],
|
||||
samples: [mockData[1]],
|
||||
status: 'success',
|
||||
data: {
|
||||
timeseries: [mockData[0]],
|
||||
samples: [mockData[1]],
|
||||
},
|
||||
}}
|
||||
openMetricDetails={jest.fn()}
|
||||
viewType={MetricsexplorertypesTreemapModeDTO.samples}
|
||||
viewType={TreemapViewType.SAMPLES}
|
||||
setHeatmapView={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
@@ -119,9 +128,9 @@ describe('MetricsTreemap', () => {
|
||||
<MetricsTreemap
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
data={undefined}
|
||||
data={null}
|
||||
openMetricDetails={jest.fn()}
|
||||
viewType={MetricsexplorertypesTreemapModeDTO.samples}
|
||||
viewType={TreemapViewType.SAMPLES}
|
||||
setHeatmapView={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
|
||||
@@ -1,81 +1,109 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { render } from '@testing-library/react';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Filter } from 'api/v5/v5';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { TreemapViewType } from '../types';
|
||||
import { formatDataForMetricsTable, getMetricsTableColumns } from '../utils';
|
||||
|
||||
const mockQueryExpression: Filter = {
|
||||
expression: '',
|
||||
};
|
||||
const mockOnChange = jest.fn();
|
||||
import {
|
||||
formatDataForMetricsTable,
|
||||
getMetricsTableColumns,
|
||||
MetricTypeRenderer,
|
||||
} from '../utils';
|
||||
|
||||
describe('metricsTableColumns', () => {
|
||||
const mockQueryFilters: TagFilter = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
it('should have correct column definitions', () => {
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange),
|
||||
).toHaveLength(6);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)).toHaveLength(6);
|
||||
|
||||
// Metric Name column
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].dataIndex,
|
||||
).toBe('metric_name');
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].width,
|
||||
).toBe(400);
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].sorter,
|
||||
).toBe(false);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[0].dataIndex).toBe(
|
||||
'metric_name',
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[0].width).toBe(400);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[0].sorter).toBe(false);
|
||||
|
||||
// Description column
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].dataIndex,
|
||||
).toBe('description');
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].width,
|
||||
).toBe(400);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[1].dataIndex).toBe(
|
||||
'description',
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[1].width).toBe(400);
|
||||
|
||||
// Type column
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].dataIndex,
|
||||
).toBe('metric_type');
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].width,
|
||||
).toBe(150);
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].sorter,
|
||||
).toBe(false);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[2].dataIndex).toBe(
|
||||
'metric_type',
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[2].width).toBe(150);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[2].sorter).toBe(false);
|
||||
|
||||
// Unit column
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].dataIndex,
|
||||
).toBe('unit');
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].width,
|
||||
).toBe(150);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[3].dataIndex).toBe('unit');
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[3].width).toBe(150);
|
||||
|
||||
// Samples column
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].dataIndex,
|
||||
).toBe(TreemapViewType.SAMPLES);
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].width,
|
||||
).toBe(150);
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].sorter,
|
||||
).toBe(true);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[4].dataIndex).toBe(
|
||||
TreemapViewType.SAMPLES,
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[4].width).toBe(150);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[4].sorter).toBe(true);
|
||||
|
||||
// Time Series column
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].dataIndex,
|
||||
).toBe(TreemapViewType.TIMESERIES);
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].width,
|
||||
).toBe(150);
|
||||
expect(
|
||||
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].sorter,
|
||||
).toBe(true);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[5].dataIndex).toBe(
|
||||
TreemapViewType.TIMESERIES,
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[5].width).toBe(150);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[5].sorter).toBe(true);
|
||||
});
|
||||
|
||||
describe('MetricTypeRenderer', () => {
|
||||
it('should render correct icon and color for each metric type', () => {
|
||||
const types = [
|
||||
{
|
||||
type: MetricType.SUM,
|
||||
color: Color.BG_ROBIN_500,
|
||||
},
|
||||
{
|
||||
type: MetricType.GAUGE,
|
||||
color: Color.BG_SAKURA_500,
|
||||
},
|
||||
{
|
||||
type: MetricType.HISTOGRAM,
|
||||
color: Color.BG_SIENNA_500,
|
||||
},
|
||||
{
|
||||
type: MetricType.SUMMARY,
|
||||
color: Color.BG_FOREST_500,
|
||||
},
|
||||
{
|
||||
type: MetricType.EXPONENTIAL_HISTOGRAM,
|
||||
color: Color.BG_AQUA_500,
|
||||
},
|
||||
];
|
||||
|
||||
types.forEach(({ type, color }) => {
|
||||
const { container } = render(<MetricTypeRenderer type={type} />);
|
||||
const rendererDiv = container.firstChild as HTMLElement;
|
||||
|
||||
expect(rendererDiv).toHaveStyle({
|
||||
backgroundColor: `${color}33`,
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty icon and color for unknown metric type', () => {
|
||||
const { container } = render(
|
||||
<MetricTypeRenderer type={'UNKNOWN' as MetricType} />,
|
||||
);
|
||||
const rendererDiv = container.firstChild as HTMLElement;
|
||||
|
||||
expect(rendererDiv.querySelector('svg')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,12 +111,12 @@ describe('formatDataForMetricsTable', () => {
|
||||
it('should format metrics data correctly', () => {
|
||||
const mockData = [
|
||||
{
|
||||
metricName: 'test_metric',
|
||||
metric_name: 'test_metric',
|
||||
description: 'Test description',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
type: MetricType.GAUGE,
|
||||
unit: 'bytes',
|
||||
samples: 1000,
|
||||
timeseries: 2000,
|
||||
[TreemapViewType.SAMPLES]: 1000,
|
||||
[TreemapViewType.TIMESERIES]: 2000,
|
||||
lastReceived: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
@@ -135,12 +163,12 @@ describe('formatDataForMetricsTable', () => {
|
||||
it('should handle empty/null values', () => {
|
||||
const mockData = [
|
||||
{
|
||||
metricName: '',
|
||||
metric_name: '',
|
||||
description: '',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
type: MetricType.GAUGE,
|
||||
unit: '',
|
||||
samples: 0,
|
||||
timeseries: 0,
|
||||
[TreemapViewType.SAMPLES]: 0,
|
||||
[TreemapViewType.TIMESERIES]: 0,
|
||||
lastReceived: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
MetricsexplorertypesTreemapModeDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
import { TreemapViewType } from './types';
|
||||
|
||||
export const METRICS_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
export const TREEMAP_VIEW_OPTIONS: {
|
||||
value: MetricsexplorertypesTreemapModeDTO;
|
||||
value: TreemapViewType;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: MetricsexplorertypesTreemapModeDTO.timeseries, label: 'Time Series' },
|
||||
{ value: MetricsexplorertypesTreemapModeDTO.samples, label: 'Samples' },
|
||||
{ value: TreemapViewType.TIMESERIES, label: 'Time Series' },
|
||||
{ value: TreemapViewType.SAMPLES, label: 'Samples' },
|
||||
];
|
||||
|
||||
export const TREEMAP_HEIGHT = 200;
|
||||
@@ -19,7 +17,6 @@ export const TREEMAP_SQUARE_PADDING = 5;
|
||||
|
||||
export const TREEMAP_MARGINS = { TOP: 10, LEFT: 10, RIGHT: 10, BOTTOM: 10 };
|
||||
|
||||
// TODO: Remove this once API migration is complete
|
||||
export const METRIC_TYPE_LABEL_MAP = {
|
||||
[MetricType.SUM]: 'Sum',
|
||||
[MetricType.GAUGE]: 'Gauge',
|
||||
@@ -28,16 +25,7 @@ export const METRIC_TYPE_LABEL_MAP = {
|
||||
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
|
||||
};
|
||||
|
||||
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> = {
|
||||
export const METRIC_TYPE_VALUES_MAP = {
|
||||
[MetricType.SUM]: 'Sum',
|
||||
[MetricType.GAUGE]: 'Gauge',
|
||||
[MetricType.HISTOGRAM]: 'Histogram',
|
||||
@@ -45,14 +33,7 @@ export const METRIC_TYPE_VALUES_MAP: Record<MetricType, string> = {
|
||||
[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_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
|
||||
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
|
||||
export const SUMMARY_FILTERS_KEY = 'summaryFilters';
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React from 'react';
|
||||
import { MetricsTreeMapResponse } from 'api/metricsExplorer/getMetricsTreeMap';
|
||||
import {
|
||||
MetricsexplorertypesTreemapModeDTO,
|
||||
MetricsexplorertypesTreemapResponseDTO,
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Filter } from 'api/v5/v5';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface MetricsTableProps {
|
||||
isLoading: boolean;
|
||||
@@ -14,36 +12,24 @@ export interface MetricsTableProps {
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
onPaginationChange: (page: number, pageSize: number) => void;
|
||||
setOrderBy: (orderBy: Querybuildertypesv5OrderByDTO) => void;
|
||||
setOrderBy: (orderBy: OrderByPayload) => void;
|
||||
totalCount: number;
|
||||
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
|
||||
queryFilterExpression: Filter;
|
||||
onFilterChange: (expression: string) => void;
|
||||
queryFilters: TagFilter;
|
||||
}
|
||||
|
||||
export interface MetricsSearchProps {
|
||||
query: IBuilderQuery;
|
||||
onChange: (expression: string) => void;
|
||||
currentQueryFilterExpression: string;
|
||||
setCurrentQueryFilterExpression: (expression: string) => void;
|
||||
isLoading: boolean;
|
||||
onChange: (value: TagFilter) => void;
|
||||
}
|
||||
|
||||
export interface MetricsTreemapProps {
|
||||
data: MetricsexplorertypesTreemapResponseDTO | undefined;
|
||||
data: MetricsTreeMapResponse | null | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
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;
|
||||
viewType: TreemapViewType;
|
||||
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
|
||||
setHeatmapView: (value: TreemapViewType) => void;
|
||||
}
|
||||
|
||||
export interface OrderByPayload {
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import {
|
||||
MetricsexplorertypesStatDTO,
|
||||
MetricsexplorertypesTreemapEntryDTO,
|
||||
MetricsexplorertypesTreemapModeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { MetricsListPayload } from 'api/metricsExplorer/getMetricsList';
|
||||
import { Filter } from 'api/v5/v5';
|
||||
MetricsListItemData,
|
||||
MetricsListPayload,
|
||||
MetricType,
|
||||
} from 'api/metricsExplorer/getMetricsList';
|
||||
import {
|
||||
SamplesData,
|
||||
TimeseriesData,
|
||||
} from 'api/metricsExplorer/getMetricsTreeMap';
|
||||
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 MetricTypeViewRenderer from './MetricTypeViewRenderer';
|
||||
import { MetricsListItemRowData, TreemapTile } from './types';
|
||||
import MetricTypeSearch from './MetricTypeSearch';
|
||||
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
|
||||
|
||||
export const getMetricsTableColumns = (
|
||||
queryFilterExpression: Filter,
|
||||
onFilterChange: (expression: string) => void,
|
||||
queryFilters: TagFilter,
|
||||
): ColumnType<MetricsListItemRowData>[] => [
|
||||
{
|
||||
title: (
|
||||
<div className="metric-name-column-header">
|
||||
<span className="metric-name-column-header-text">METRIC</span>
|
||||
<MetricNameSearch
|
||||
queryFilterExpression={queryFilterExpression}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
<MetricNameSearch queryFilters={queryFilters} />
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'metric_name',
|
||||
@@ -47,10 +55,7 @@ export const getMetricsTableColumns = (
|
||||
title: (
|
||||
<div className="metric-type-column-header">
|
||||
<span className="metric-type-column-header-text">TYPE</span>
|
||||
{/* <MetricTypeSearch
|
||||
queryFilters={queryFilters}
|
||||
onFilterChange={onFilterChange}
|
||||
/> */}
|
||||
<MetricTypeSearch queryFilters={queryFilters} />
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'metric_type',
|
||||
@@ -64,13 +69,13 @@ export const getMetricsTableColumns = (
|
||||
},
|
||||
{
|
||||
title: 'SAMPLES',
|
||||
dataIndex: MetricsexplorertypesTreemapModeDTO.samples,
|
||||
dataIndex: TreemapViewType.SAMPLES,
|
||||
width: 150,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'TIME SERIES',
|
||||
dataIndex: MetricsexplorertypesTreemapModeDTO.timeseries,
|
||||
dataIndex: TreemapViewType.TIMESERIES,
|
||||
width: 150,
|
||||
sorter: true,
|
||||
},
|
||||
@@ -84,6 +89,60 @@ export const getMetricsListQuery = (): MetricsListPayload => ({
|
||||
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({
|
||||
value,
|
||||
children,
|
||||
@@ -101,9 +160,6 @@ export const formatNumberIntoHumanReadableFormat = (
|
||||
num: number,
|
||||
addPlusSign = true,
|
||||
): string => {
|
||||
if (!num) {
|
||||
return '-';
|
||||
}
|
||||
function format(num: number, divisor: number, suffix: string): string {
|
||||
const value = num / divisor;
|
||||
return value % 1 === 0
|
||||
@@ -126,13 +182,13 @@ export const formatNumberIntoHumanReadableFormat = (
|
||||
};
|
||||
|
||||
export const formatDataForMetricsTable = (
|
||||
data: MetricsexplorertypesStatDTO[],
|
||||
data: MetricsListItemData[],
|
||||
): MetricsListItemRowData[] =>
|
||||
data.map((metric) => ({
|
||||
key: metric.metricName,
|
||||
key: metric.metric_name,
|
||||
metric_name: (
|
||||
<ValidateRowValueWrapper value={metric.metricName}>
|
||||
<Tooltip title={metric.metricName}>{metric.metricName}</Tooltip>
|
||||
<ValidateRowValueWrapper value={metric.metric_name}>
|
||||
<Tooltip title={metric.metric_name}>{metric.metric_name}</Tooltip>
|
||||
</ValidateRowValueWrapper>
|
||||
),
|
||||
description: (
|
||||
@@ -142,54 +198,39 @@ export const formatDataForMetricsTable = (
|
||||
</Tooltip>
|
||||
</ValidateRowValueWrapper>
|
||||
),
|
||||
metric_type: <MetricTypeViewRenderer type={metric.type} />,
|
||||
metric_type: <MetricTypeRenderer type={metric.type} />,
|
||||
unit: (
|
||||
<ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}>
|
||||
{getUniversalNameFromMetricUnit(metric.unit)}
|
||||
</ValidateRowValueWrapper>
|
||||
),
|
||||
[MetricsexplorertypesTreemapModeDTO.samples]: (
|
||||
<ValidateRowValueWrapper
|
||||
value={metric[MetricsexplorertypesTreemapModeDTO.samples]}
|
||||
>
|
||||
<Tooltip
|
||||
title={metric[MetricsexplorertypesTreemapModeDTO.samples].toLocaleString()}
|
||||
>
|
||||
{formatNumberIntoHumanReadableFormat(
|
||||
metric[MetricsexplorertypesTreemapModeDTO.samples],
|
||||
)}
|
||||
[TreemapViewType.SAMPLES]: (
|
||||
<ValidateRowValueWrapper value={metric[TreemapViewType.SAMPLES]}>
|
||||
<Tooltip title={metric[TreemapViewType.SAMPLES].toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metric[TreemapViewType.SAMPLES])}
|
||||
</Tooltip>
|
||||
</ValidateRowValueWrapper>
|
||||
),
|
||||
[MetricsexplorertypesTreemapModeDTO.timeseries]: (
|
||||
<ValidateRowValueWrapper
|
||||
value={metric[MetricsexplorertypesTreemapModeDTO.timeseries]}
|
||||
>
|
||||
<Tooltip
|
||||
title={metric[
|
||||
MetricsexplorertypesTreemapModeDTO.timeseries
|
||||
].toLocaleString()}
|
||||
>
|
||||
{formatNumberIntoHumanReadableFormat(
|
||||
metric[MetricsexplorertypesTreemapModeDTO.timeseries],
|
||||
)}
|
||||
[TreemapViewType.TIMESERIES]: (
|
||||
<ValidateRowValueWrapper value={metric[TreemapViewType.TIMESERIES]}>
|
||||
<Tooltip title={metric[TreemapViewType.TIMESERIES].toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metric[TreemapViewType.TIMESERIES])}
|
||||
</Tooltip>
|
||||
</ValidateRowValueWrapper>
|
||||
),
|
||||
}));
|
||||
|
||||
export const transformTreemapData = (
|
||||
data: MetricsexplorertypesTreemapEntryDTO[],
|
||||
viewType: MetricsexplorertypesTreemapModeDTO,
|
||||
data: TimeseriesData[] | SamplesData[],
|
||||
viewType: TreemapViewType,
|
||||
): TreemapTile[] => {
|
||||
const totalSize = data.reduce(
|
||||
(acc: number, item: MetricsexplorertypesTreemapEntryDTO) =>
|
||||
acc + item.percentage,
|
||||
const totalSize = (data as (TimeseriesData | SamplesData)[]).reduce(
|
||||
(acc: number, item: TimeseriesData | SamplesData) => acc + item.percentage,
|
||||
0,
|
||||
);
|
||||
|
||||
const children = data.map((item) => ({
|
||||
id: item.metricName,
|
||||
id: item.metric_name,
|
||||
size: totalSize > 0 ? Number((item.percentage / totalSize).toFixed(2)) : 0,
|
||||
displayValue: Number(item.percentage).toFixed(2),
|
||||
parent: viewType,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { ColorPickerProps } from 'antd';
|
||||
import { Color } from 'antd/es/color-picker';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import LegendColors from './LegendColors';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
__esModule: true,
|
||||
useQueryBuilder: (): { currentQuery: unknown } => ({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
legend: '{service.name}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
return {
|
||||
...actual,
|
||||
ColorPicker: ({ onChange, children }: ColorPickerProps): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="legend-color-picker"
|
||||
onClick={(): void =>
|
||||
onChange!({ toHexString: (): string => '#ffffff' } as Color, '#ffffff')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe('LegendColors', () => {
|
||||
it('renders legend colors panel and items', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<LegendColors
|
||||
customLegendColors={{}}
|
||||
setCustomLegendColors={jest.fn()}
|
||||
queryResponse={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Legend Colors')).toBeInTheDocument();
|
||||
|
||||
// Expand the collapse to reveal legend items
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
name: /Legend Colors/i,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText('{service.name}')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls setCustomLegendColors when color is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setCustomLegendColors = jest.fn();
|
||||
|
||||
render(
|
||||
<LegendColors
|
||||
customLegendColors={{}}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Expand to render the mocked ColorPicker button
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
name: /Legend Colors/i,
|
||||
}),
|
||||
);
|
||||
|
||||
const colorTrigger = screen.getByTestId('legend-color-picker');
|
||||
|
||||
await user.click(colorTrigger);
|
||||
|
||||
expect(setCustomLegendColors).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throttles rapid color changes', async () => {
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const setCustomLegendColors = jest.fn();
|
||||
|
||||
render(
|
||||
<LegendColors
|
||||
customLegendColors={{}}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Expand panel to render the mocked ColorPicker button
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
name: /Legend Colors/i,
|
||||
}),
|
||||
);
|
||||
|
||||
const colorTrigger = screen.getByTestId('legend-color-picker');
|
||||
|
||||
// Fire multiple rapid changes
|
||||
await user.click(colorTrigger);
|
||||
await user.click(colorTrigger);
|
||||
await user.click(colorTrigger);
|
||||
await user.click(colorTrigger);
|
||||
|
||||
// Flush pending throttled calls
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Throttling should ensure we don't invoke the setter once per click
|
||||
expect(setCustomLegendColors).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { Palette } from 'lucide-react';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
@@ -96,24 +95,13 @@ function LegendColors({
|
||||
);
|
||||
};
|
||||
|
||||
// Handle color change (throttled to avoid excessive updates)
|
||||
const handleColorChange = useMemo(
|
||||
() =>
|
||||
throttle((label: string, color: string): void => {
|
||||
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]);
|
||||
// Handle color change
|
||||
const handleColorChange = (label: string, color: string): void => {
|
||||
setCustomLegendColors((prev) => ({
|
||||
...prev,
|
||||
[label]: color,
|
||||
}));
|
||||
};
|
||||
|
||||
// Reset to default color
|
||||
const resetToDefault = (label: string): void => {
|
||||
|
||||
@@ -169,10 +169,6 @@
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
.ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
height: unset;
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useDrag, useDrop, XYCoord } from 'react-dnd';
|
||||
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 { unitOptions } from 'container/NewWidget/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -207,18 +204,6 @@ function Threshold({
|
||||
return unit !== 'none' && convertUnit(value, unit, toUnitId) === null;
|
||||
}, [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 (
|
||||
<div
|
||||
ref={allowDragAndDrop ? ref : null}
|
||||
@@ -328,17 +313,19 @@ function Threshold({
|
||||
<ShowCaseValue value={value} className="unit-input" />
|
||||
)}
|
||||
{isEditMode ? (
|
||||
<YAxisUnitSelector
|
||||
value={unit}
|
||||
<Select
|
||||
defaultValue={unit}
|
||||
options={unitOptions(
|
||||
selectedGraph === PANEL_TYPES.TABLE
|
||||
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
|
||||
: yAxisUnit || '',
|
||||
)}
|
||||
onChange={handleUnitChange}
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
initialValue={unit}
|
||||
categoriesOverride={unitSelectCategories}
|
||||
containerClassName="unit-selection"
|
||||
showSearch
|
||||
className="unit-selection"
|
||||
/>
|
||||
) : (
|
||||
<ShowCaseValue value={unitLabel} className="unit-selection-prev" />
|
||||
<ShowCaseValue value={unit} className="unit-selection-prev" />
|
||||
)}
|
||||
</div>
|
||||
<div className="thresholds-color-selector">
|
||||
@@ -369,10 +356,7 @@ function Threshold({
|
||||
)}
|
||||
</div>
|
||||
{isInvalidUnitComparison && (
|
||||
<Typography.Text
|
||||
className="invalid-unit"
|
||||
data-testid="invalid-unit-comparison"
|
||||
>
|
||||
<Typography.Text className="invalid-unit">
|
||||
Threshold unit ({unit}) is not valid in comparison with the{' '}
|
||||
{selectedGraph === PANEL_TYPES.TABLE ? 'column' : 'y-axis'} unit (
|
||||
{selectedGraph === PANEL_TYPES.TABLE
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { Y_AXIS_UNIT_NAMES } from 'components/YAxisUnitSelector/constants';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
@@ -16,26 +14,12 @@ jest.mock('lib/query/createTableColumnsFromQuery', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the unitOptions function to return YAxisCategory-shaped data
|
||||
// Mock the unitOptions function
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
unitOptions: jest.fn(() => [
|
||||
{
|
||||
name: 'Mock Category',
|
||||
units: [
|
||||
{
|
||||
id: UniversalYAxisUnit.NONE,
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.NONE],
|
||||
},
|
||||
{
|
||||
id: UniversalYAxisUnit.PERCENT,
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PERCENT],
|
||||
},
|
||||
{
|
||||
id: UniversalYAxisUnit.MILLISECONDS,
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MILLISECONDS],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: '%', label: 'Percent (0 - 100)' },
|
||||
{ value: 'ms', label: 'Milliseconds (ms)' },
|
||||
]),
|
||||
}));
|
||||
|
||||
@@ -44,7 +28,7 @@ const defaultProps = {
|
||||
keyIndex: 0,
|
||||
thresholdOperator: '>' as const,
|
||||
thresholdValue: 50,
|
||||
thresholdUnit: UniversalYAxisUnit.NONE,
|
||||
thresholdUnit: 'none',
|
||||
thresholdColor: 'Red',
|
||||
thresholdFormat: 'Text' as const,
|
||||
isEditEnabled: true,
|
||||
@@ -54,11 +38,8 @@ const defaultProps = {
|
||||
{ value: 'memory_usage', label: 'Memory Usage' },
|
||||
],
|
||||
thresholdTableOptions: 'cpu_usage',
|
||||
columnUnits: {
|
||||
cpu_usage: UniversalYAxisUnit.PERCENT,
|
||||
memory_usage: UniversalYAxisUnit.BYTES,
|
||||
},
|
||||
yAxisUnit: UniversalYAxisUnit.PERCENT,
|
||||
columnUnits: { cpu_usage: 'percent', memory_usage: 'bytes' },
|
||||
yAxisUnit: '%',
|
||||
moveThreshold: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -87,27 +68,28 @@ describe('Threshold Component Unit Validation', () => {
|
||||
it('should show validation error when threshold unit is not "none" and units are incompatible', () => {
|
||||
// Act - Render component with incompatible units (ms vs percent)
|
||||
renderThreshold({
|
||||
thresholdUnit: UniversalYAxisUnit.MILLISECONDS,
|
||||
thresholdUnit: 'ms',
|
||||
thresholdValue: 50,
|
||||
});
|
||||
|
||||
const errorMessage = screen.getByTestId('invalid-unit-comparison');
|
||||
// Assert - Validation error should be displayed
|
||||
expect(errorMessage.textContent).toBe(
|
||||
`Threshold unit (${UniversalYAxisUnit.MILLISECONDS}) is not valid in comparison with the column unit (${UniversalYAxisUnit.PERCENT})`,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(ms\) is not valid in comparison with the column unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error when threshold unit matches column unit', () => {
|
||||
// Act - Render component with matching units
|
||||
renderThreshold({
|
||||
thresholdUnit: UniversalYAxisUnit.PERCENT,
|
||||
thresholdUnit: 'percent',
|
||||
thresholdValue: 50,
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByTestId('invalid-unit-comparison'),
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -115,16 +97,17 @@ describe('Threshold Component Unit Validation', () => {
|
||||
// Act - Render component for time series with incompatible units
|
||||
renderThreshold({
|
||||
selectedGraph: PANEL_TYPES.TIME_SERIES,
|
||||
thresholdUnit: UniversalYAxisUnit.MILLISECONDS,
|
||||
thresholdUnit: 'ms',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: UniversalYAxisUnit.PERCENT,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
const errorMessage = screen.getByTestId('invalid-unit-comparison');
|
||||
// Assert - Validation error should be displayed
|
||||
expect(errorMessage.textContent).toBe(
|
||||
`Threshold unit (${UniversalYAxisUnit.MILLISECONDS}) is not valid in comparison with the y-axis unit (${UniversalYAxisUnit.PERCENT})`,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(ms\) is not valid in comparison with the y-axis unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error for time series graph when threshold unit is "none"', () => {
|
||||
@@ -133,39 +116,43 @@ describe('Threshold Component Unit Validation', () => {
|
||||
selectedGraph: PANEL_TYPES.TIME_SERIES,
|
||||
thresholdUnit: 'none',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: UniversalYAxisUnit.PERCENT,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByTestId('invalid-unit-comparison'),
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error when threshold unit is compatible with column unit', () => {
|
||||
// Act - Render component with compatible units (both in same category - Time)
|
||||
renderThreshold({
|
||||
thresholdUnit: UniversalYAxisUnit.SECONDS,
|
||||
thresholdUnit: 's',
|
||||
thresholdValue: 100,
|
||||
columnUnits: { cpu_usage: UniversalYAxisUnit.MILLISECONDS },
|
||||
columnUnits: { cpu_usage: 'ms' },
|
||||
thresholdTableOptions: 'cpu_usage',
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByTestId('invalid-unit-comparison'),
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error when threshold unit is in different category than column unit', () => {
|
||||
// Act - Render component with units from different categories
|
||||
renderThreshold({
|
||||
thresholdUnit: UniversalYAxisUnit.BYTES,
|
||||
thresholdUnit: 'bytes',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: UniversalYAxisUnit.PERCENT,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
const errorMessage = screen.getByTestId('invalid-unit-comparison');
|
||||
// Assert - Validation error should be displayed
|
||||
expect(errorMessage.textContent).toBe(
|
||||
`Threshold unit (${UniversalYAxisUnit.BYTES}) is not valid in comparison with the column unit (${UniversalYAxisUnit.PERCENT})`,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(bytes\) is not valid in comparison with the column unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Layout } from 'react-grid-layout';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { YAxisCategoryNames } from 'components/YAxisUnitSelector/constants';
|
||||
import {
|
||||
UniversalYAxisUnit,
|
||||
YAxisCategory,
|
||||
YAxisSource,
|
||||
} from 'components/YAxisUnitSelector/types';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
@@ -609,7 +606,7 @@ export const PANEL_TYPE_TO_QUERY_TYPES: Record<PANEL_TYPES, EQueryType[]> = {
|
||||
*/
|
||||
export const getCategorySelectOptionByName = (
|
||||
name?: YAxisCategoryNames,
|
||||
): { name: string; id: UniversalYAxisUnit }[] => {
|
||||
): DefaultOptionType[] => {
|
||||
const categories = getYAxisCategories(YAxisSource.DASHBOARDS);
|
||||
if (!categories.length) {
|
||||
return [];
|
||||
@@ -618,8 +615,8 @@ export const getCategorySelectOptionByName = (
|
||||
categories
|
||||
.find((category) => category.name === name)
|
||||
?.units.map((unit) => ({
|
||||
name: unit.name,
|
||||
id: unit.id,
|
||||
label: unit.name,
|
||||
value: unit.id,
|
||||
})) || []
|
||||
);
|
||||
};
|
||||
@@ -631,19 +628,19 @@ export const getCategorySelectOptionByName = (
|
||||
* select options. If a valid category is found, it filters the supported categories
|
||||
* to return only the options for the matched category.
|
||||
*/
|
||||
export const unitOptions = (columnUnit: string): YAxisCategory[] => {
|
||||
export const unitOptions = (columnUnit: string): DefaultOptionType[] => {
|
||||
const category = getCategoryName(columnUnit);
|
||||
if (isEmpty(category)) {
|
||||
return categoryToSupport.map((category) => ({
|
||||
name: category,
|
||||
units: getCategorySelectOptionByName(category),
|
||||
label: category,
|
||||
options: getCategorySelectOptionByName(category),
|
||||
}));
|
||||
}
|
||||
return categoryToSupport
|
||||
.filter((supportedCategory) => supportedCategory === category)
|
||||
.map((filteredCategory) => ({
|
||||
name: filteredCategory,
|
||||
units: getCategorySelectOptionByName(filteredCategory),
|
||||
label: filteredCategory,
|
||||
options: getCategorySelectOptionByName(filteredCategory),
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Flamegraph Canvas POC Notes
|
||||
|
||||
## Overview
|
||||
This document tracks the proof-of-concept (POC) implementation of a canvas-based flamegraph rendering system, replacing the previous DOM-based approach.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed Features
|
||||
|
||||
1. **Canvas-based Rendering**
|
||||
- Replaced DOM elements (`react-virtuoso` and `div.span-item`) with a single HTML5 Canvas
|
||||
- Implemented `drawFlamegraph` function to render all spans as rectangles on canvas
|
||||
- Added device pixel ratio (DPR) support for crisp rendering
|
||||
|
||||
2. **Time-window-based Zoom**
|
||||
- Replaced pixel-based zoom/pan with time-window-based approach (`viewStartTs`, `viewEndTs`)
|
||||
- Prevents pixelation by redrawing from data with new time bounds
|
||||
- Zoom anchors to cursor position
|
||||
- Horizontal zoom works correctly (min: 1/100th of trace, max: full trace)
|
||||
|
||||
3. **Drag to Pan**
|
||||
- Implemented drag-to-pan functionality for navigating the canvas
|
||||
- Differentiates between click (span selection) and drag (panning) based on distance moved
|
||||
- Prevents unwanted window zoom
|
||||
|
||||
4. **Minimap with 2D Navigation**
|
||||
- Canvas-based minimap showing density histogram (time × levels)
|
||||
- 2D brush overlay for both horizontal (time) and vertical (levels) navigation
|
||||
- Draggable brush to pan both dimensions
|
||||
- Bidirectional synchronization between main canvas and minimap
|
||||
|
||||
5. **Timeline Synchronization**
|
||||
- `TimelineV2` component synchronized with visible time window
|
||||
- Updates correctly during zoom and pan operations
|
||||
|
||||
6. **Hit Testing**
|
||||
- Implemented span rectangle tracking for click detection
|
||||
- Tooltip on hover
|
||||
- Span selection via click
|
||||
|
||||
### ❌ Known Issues / Not Working
|
||||
|
||||
1. **Vertical Zoom - NOT WORKING**
|
||||
- **Status**: Attempted implementation but not functioning correctly
|
||||
- **Issue**: When horizontal zoom reaches maximum (full trace width), vertical zoom cannot continue to zoom out further
|
||||
- **Attempted Solution**: Added `rowHeightScale` state to control vertical row spacing, but the implementation does not work as expected
|
||||
- **Impact**: Users cannot fully zoom out vertically to see all levels when horizontal zoom is at maximum
|
||||
- **Next Steps**: Needs further investigation and alternative approach
|
||||
|
||||
2. **Timeline Scale Alignment - NOT WORKING PROPERLY**
|
||||
- **Status**: Issue identified but not fully resolved
|
||||
- **Issue**: The timeline scale does not align properly when dragging/panning the canvas. The timeline aligns correctly during zoom operations, but not during drag/pan operations.
|
||||
- **Impact**: Timeline may show incorrect time values while dragging the canvas
|
||||
- **Attempted Solution**: Used refs (`viewStartTsRef`, `viewEndTsRef`) to track current time window and incremental delta calculation, but issue persists
|
||||
- **Next Steps**: Needs further investigation to ensure timeline stays synchronized during all interaction types
|
||||
|
||||
### 🔄 Pending / Future Work
|
||||
|
||||
1. **Performance Optimization**
|
||||
- Consider adding an interaction layer (separate canvas on top) for better performance
|
||||
- Optimize rendering for large traces
|
||||
|
||||
2. **Code Quality**
|
||||
- Reduce cognitive complexity of `drawFlamegraph` function (currently 26, target: 15)
|
||||
- Reduce cognitive complexity of `drawMinimap` function (currently 30, target: 15)
|
||||
|
||||
3. **Additional Features**
|
||||
- Keyboard shortcuts for navigation
|
||||
- Better zoom controls
|
||||
- Export functionality
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Key Files Modified
|
||||
- `frontend/src/container/PaginatedTraceFlamegraph/TraceFlamegraphStates/Success/Success.tsx` - Main rendering component
|
||||
- `frontend/src/container/PaginatedTraceFlamegraph/TraceFlamegraphStates/Success/Success.styles.scss` - Styles for canvas and minimap
|
||||
- `frontend/src/components/TimelineV2/TimelineV2.tsx` - Timeline synchronization
|
||||
|
||||
### Key Concepts
|
||||
- **Time-window-based zoom**: Instead of scaling canvas bitmap, redraw from data with new time bounds
|
||||
- **Device Pixel Ratio**: Render at DPR resolution for crisp display on high-DPI screens
|
||||
- **2D Minimap**: Shows density heatmap across both time (horizontal) and levels (vertical) dimensions
|
||||
- **Brush Navigation**: Draggable rectangle overlay for panning both dimensions
|
||||
|
||||
## Notes
|
||||
- This is a POC implementation - code quality and optimization can be improved after validation
|
||||
- Some linting warnings (cognitive complexity) are acceptable for POC phase
|
||||
- All changes should be validated before production use
|
||||
@@ -1,3 +1,5 @@
|
||||
// @ts-nocheck
|
||||
/* eslint-disable */
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Progress, Skeleton, Tooltip, Typography } from 'antd';
|
||||
@@ -13,8 +15,13 @@ import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { TraceFlamegraphStates } from './constants';
|
||||
import Error from './TraceFlamegraphStates/Error/Error';
|
||||
import NoData from './TraceFlamegraphStates/NoData/NoData';
|
||||
import Success from './TraceFlamegraphStates/Success/Success';
|
||||
// import Success from './TraceFlamegraphStates/Success/SuccessV2';
|
||||
// import Success from './TraceFlamegraphStates/Success/SuccessV3_without_minimap_best';
|
||||
import Success from './TraceFlamegraphStates/Success/Success_zoom';
|
||||
|
||||
// import Success from './TraceFlamegraphStates/Success/Success_zoom_api';
|
||||
// import Success from './TraceFlamegraphStates/Success/SuccessCursor';
|
||||
// import Success from './TraceFlamegraphStates/Success/Success';
|
||||
import './PaginatedTraceFlamegraph.styles.scss';
|
||||
|
||||
interface ITraceFlamegraphProps {
|
||||
@@ -38,7 +45,6 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
@@ -46,6 +52,9 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
const { data, isFetching, error } = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
limit: 100001,
|
||||
// boundaryStartTsMilli: 0,
|
||||
// boundarEndTsMilli: 10000,
|
||||
});
|
||||
|
||||
// get the current state of trace flamegraph based on the API lifecycle
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
&.trace-flamegraph-canvas {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trace-flamegraph-virtuoso {
|
||||
overflow-x: hidden;
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// @ts-nocheck
|
||||
/* eslint-disable */
|
||||
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import {
|
||||
@@ -23,6 +26,12 @@ import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
// Constants for rendering
|
||||
const ROW_HEIGHT = 24; // 18px height + 6px padding
|
||||
const SPAN_BAR_HEIGHT = 12;
|
||||
const EVENT_DOT_SIZE = 6;
|
||||
const SPAN_BAR_Y_OFFSET = 3; //
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -35,6 +44,14 @@ interface ISuccessProps {
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
interface SpanRect {
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function Success(props: ISuccessProps): JSX.Element {
|
||||
const {
|
||||
@@ -48,6 +65,28 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const baseCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const interactionCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const minimapRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
console.log('spans.length', spans.length);
|
||||
// Calculate total canvas height. this is coming less
|
||||
const totalHeight = spans.length * ROW_HEIGHT;
|
||||
|
||||
// Build a flat array of span rectangles for hit testing.
|
||||
// consider per level buckets to improve hit testing
|
||||
const spanRects = useRef<SpanRect[]>([]);
|
||||
|
||||
// Time window state (instead of zoom/pan in pixel space)
|
||||
const [viewStartTs, setViewStartTs] = useState<number>(
|
||||
traceMetadata.startTime,
|
||||
);
|
||||
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
|
||||
|
||||
const [scrollTop, setScrollTop] = useState<number>(0);
|
||||
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string>('');
|
||||
const renderSpanLevel = useCallback(
|
||||
(_: number, spans: FlamegraphSpan[]): JSX.Element => (
|
||||
@@ -157,16 +196,305 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
});
|
||||
}, [firstSpanAtFetchLevel, spans]);
|
||||
|
||||
// Draw a single event dot
|
||||
const drawEventDot = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
isError: boolean,
|
||||
): void => {
|
||||
// could be optimized:
|
||||
// ctx.beginPath();
|
||||
// ctx.moveTo(x, y - size/2);
|
||||
// ctx.lineTo(x + size/2, y);
|
||||
// ctx.lineTo(x, y + size/2);
|
||||
// ctx.lineTo(x - size/2, y);
|
||||
// ctx.closePath();
|
||||
// ctx.fill();
|
||||
// ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4); // 45 degrees
|
||||
|
||||
if (isError) {
|
||||
ctx.fillStyle = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
ctx.strokeStyle = isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)';
|
||||
} else {
|
||||
ctx.fillStyle = isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)';
|
||||
ctx.strokeStyle = isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)';
|
||||
}
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillRect(
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
EVENT_DOT_SIZE,
|
||||
EVENT_DOT_SIZE,
|
||||
);
|
||||
ctx.strokeRect(
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
EVENT_DOT_SIZE,
|
||||
EVENT_DOT_SIZE,
|
||||
);
|
||||
ctx.restore();
|
||||
},
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
// Get CSS color value from color string or CSS variable
|
||||
// const getColorValue = useCallback((color: string): string => {
|
||||
// // if (color.startsWith('var(')) {
|
||||
// // // For CSS variables, we need to get computed value
|
||||
// // const tempDiv = document.createElement('div');
|
||||
// // tempDiv.style.color = color;
|
||||
// // document.body.appendChild(tempDiv);
|
||||
// // const computedColor = window.getComputedStyle(tempDiv).color;
|
||||
// // document.body.removeChild(tempDiv);
|
||||
// // return computedColor;
|
||||
// // }
|
||||
// return color;
|
||||
// }, []);
|
||||
|
||||
// Get span color based on service, error state, and selection
|
||||
// separate this when introducing interaction canvas
|
||||
const getSpanColor = useCallback(
|
||||
(span: FlamegraphSpan): string => {
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
}
|
||||
// else {
|
||||
// color = getColorValue(color);
|
||||
// }
|
||||
|
||||
// Apply selection/hover highlight
|
||||
//hover/selection highlight in getSpanColor forces base redraw. clipping necessary.
|
||||
if (selectedSpan?.spanId === span.spanId || hoveredSpanId === span.spanId) {
|
||||
const colorObj = Color(color);
|
||||
color = isDarkMode
|
||||
? colorObj.lighten(0.7).hex()
|
||||
: colorObj.darken(0.7).hex();
|
||||
}
|
||||
|
||||
return color;
|
||||
},
|
||||
[isDarkMode, selectedSpan, hoveredSpanId],
|
||||
);
|
||||
|
||||
// Draw a single span and its events
|
||||
const drawSpan = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
span: FlamegraphSpan,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
levelIndex: number,
|
||||
spanRectsArray: SpanRect[],
|
||||
): void => {
|
||||
const color = getSpanColor(span); // do not depend on hover/clicks
|
||||
const spanY = y + SPAN_BAR_Y_OFFSET;
|
||||
|
||||
// Draw span rectangle
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
// see if we can avoid roundRect as it is performance intensive
|
||||
ctx.roundRect(x, spanY, width, SPAN_BAR_HEIGHT, 6);
|
||||
ctx.fill();
|
||||
|
||||
// Store rect for hit testing
|
||||
// consider per level buckets to improve hit testing
|
||||
// So hover can:
|
||||
// compute level from y
|
||||
// search only within that row
|
||||
spanRectsArray.push({
|
||||
span,
|
||||
x,
|
||||
y: spanY,
|
||||
width,
|
||||
height: SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
});
|
||||
|
||||
// Draw events
|
||||
// think about optimizing this.
|
||||
// if span is too small to draw events, skip drawing events???
|
||||
span.event?.forEach((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
|
||||
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
|
||||
const eventX = x + (clampedOffset / 100) * width;
|
||||
const eventY = spanY + SPAN_BAR_HEIGHT / 2;
|
||||
// LOD guard: skip events if span too narrow
|
||||
// if (width < EVENT_DOT_SIZE) {
|
||||
// return;
|
||||
// }
|
||||
drawEventDot(ctx, eventX, eventY, event.isError);
|
||||
});
|
||||
},
|
||||
[getSpanColor, drawEventDot],
|
||||
);
|
||||
|
||||
const drawFlamegraph = useCallback(() => {
|
||||
const canvas = baseCanvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const timeSpan = viewEndTs - viewStartTs;
|
||||
if (timeSpan <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssWidth = canvas.width / dpr;
|
||||
|
||||
// ---- Vertical clipping window ----
|
||||
const viewportHeight = container.clientHeight;
|
||||
const overscan = 4;
|
||||
|
||||
const firstLevel = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - overscan);
|
||||
const visibleLevelCount =
|
||||
Math.ceil(viewportHeight / ROW_HEIGHT) + 2 * overscan;
|
||||
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
|
||||
|
||||
// ---- Clear only visible region (recommended) ----
|
||||
const clearTop = firstLevel * ROW_HEIGHT;
|
||||
const clearHeight = (lastLevel - firstLevel + 1) * ROW_HEIGHT;
|
||||
ctx.clearRect(0, clearTop, cssWidth, clearHeight);
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
|
||||
// ---- Draw only visible levels ----
|
||||
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
|
||||
const levelSpans = spans[levelIndex];
|
||||
if (!levelSpans) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const y = levelIndex * ROW_HEIGHT;
|
||||
|
||||
for (let i = 0; i < levelSpans.length; i++) {
|
||||
const span = levelSpans[i];
|
||||
|
||||
const spanStartMs = span.timestamp;
|
||||
const spanEndMs = span.timestamp + span.durationNano / 1e6;
|
||||
|
||||
// Time culling (already correct)
|
||||
if (spanEndMs < viewStartTs || spanStartMs > viewEndTs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const leftOffset = ((spanStartMs - viewStartTs) / timeSpan) * cssWidth;
|
||||
const rightEdge = ((spanEndMs - viewStartTs) / timeSpan) * cssWidth;
|
||||
let width = rightEdge - leftOffset;
|
||||
|
||||
// Clamp to visible x-range
|
||||
if (leftOffset < 0) {
|
||||
width += leftOffset;
|
||||
if (width <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (rightEdge > cssWidth) {
|
||||
width = cssWidth - Math.max(0, leftOffset);
|
||||
if (width <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: minimum 1px width
|
||||
if (width > 0 && width < 1) {
|
||||
width = 1;
|
||||
}
|
||||
|
||||
drawSpan(
|
||||
ctx,
|
||||
span,
|
||||
Math.max(0, leftOffset),
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
spanRects.current = spanRectsArray;
|
||||
}, [spans, viewStartTs, viewEndTs, scrollTop, drawSpan]);
|
||||
|
||||
// Handle canvas resize with device pixel ratio
|
||||
useEffect(() => {
|
||||
const canvas = baseCanvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCanvasSize = (): void => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Set CSS size
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${totalHeight}px`;
|
||||
|
||||
// Set actual pixel size (accounting for DPR)
|
||||
// Only update if size actually changed to prevent unnecessary redraws
|
||||
const newWidth = Math.floor(rect.width * dpr);
|
||||
const newHeight = Math.floor(totalHeight * dpr);
|
||||
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
// Redraw with current time window (preserves zoom/pan)
|
||||
drawFlamegraph();
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateCanvasSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Initial size
|
||||
updateCanvasSize();
|
||||
|
||||
// Handle DPR changes (e.g., moving window between screens)
|
||||
const handleDPRChange = (): void => {
|
||||
updateCanvasSize();
|
||||
};
|
||||
window
|
||||
.matchMedia('(resolution: 1dppx)')
|
||||
.addEventListener('change', handleDPRChange);
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
window
|
||||
.matchMedia('(resolution: 1dppx)')
|
||||
.removeEventListener('change', handleDPRChange);
|
||||
};
|
||||
}, [drawFlamegraph, totalHeight]);
|
||||
|
||||
// Re-draw when data changes
|
||||
useEffect(() => {
|
||||
drawFlamegraph();
|
||||
}, [drawFlamegraph]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="trace-flamegraph">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="trace-flamegraph-virtuoso"
|
||||
data={spans}
|
||||
itemContent={renderSpanLevel}
|
||||
rangeChanged={handleRangeChanged}
|
||||
/>
|
||||
<div ref={containerRef} className="trace-flamegraph trace-flamegraph-canvas">
|
||||
<canvas ref={baseCanvasRef}></canvas>
|
||||
</div>
|
||||
<TimelineV2
|
||||
startTimestamp={traceMetadata.startTime}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user