Compare commits

..

16 Commits

Author SHA1 Message Date
Prashant Shahi
bf22c9c29a Merge branch 'main' into chore/deprecated-config-args 2025-05-07 15:52:14 +05:30
Vibhu Pandey
8dc749b9dd fix(migration): fix cascading drops in sqlite (#7844)
* fix(foreign-key): fix cascading drops in sqlite

* fix(foreign-key): fix comments

* fix(foreign-key): fix function names

* fix(foreign-key): fix order of migration

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2025-05-07 08:18:13 +00:00
Prashant Shahi
44bfcbb969 chore(signoz): remove deprecated prometheus config
Signed-off-by: Prashant Shahi <prashant@signoz.io>
2025-05-07 13:19:34 +05:30
Prashant Shahi
82a111e5b1 chore(signoz): remove deprecated signoz arguments (#7849)
### Summary

- remove deprecated signoz arguments

---------

Signed-off-by: Prashant Shahi <prashant@signoz.io>
2025-05-07 07:15:37 +00:00
primus-bot[bot]
e2e6c65b4d chore(release): bump to v0.82.0 (#7847)
#### Summary
 - Release SigNoz v0.82.0
 - Bump SigNoz OTel Collector to v0.111.41

 Created by [Primus-Bot](https://github.com/apps/primus-bot)
2025-05-07 06:46:59 +00:00
Amlan Kumar Nandy
f01d21cbf2 feat: implement inspect feature for metrics explorer (#7549) 2025-05-07 05:18:56 +00:00
aniketio-ctrl
36886135d1 chore: disable writing to v2 tables and add signozclickhousemetrics in signozspanmetrics
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-05-07 03:26:01 +00:00
Vikrant Gupta
3648027576 fix(ruler): improve the user experience for rule id migration (#7841)
* fix(ruler): improve the user experience for rule id migration

* fix(ruler): improve the user experience for rule id migration
2025-05-06 22:37:59 +05:30
Shaheer Kochai
b80626f5e2 fix: add dark class to the elements when dark mode is enabled to support components library modes (#7607) 2025-05-06 15:44:26 +00:00
Shaheer Kochai
08579242eb fix: add hideSpanScopeSelector prop to QueryBuilderSearchV2 and hide from non qb consumers (#7716)
* feat: add hideSpanScopeSelector prop to QueryBuilderSearchV2 and hide from non qb consumers

* fix: update the tests to check rendering based on hideSpanScopeSelector

* feat: display span selector in exceptions page
2025-05-06 19:02:57 +04:30
aniketio-ctrl
6e0b50dd60 fix(7832): added filters in inspect metrics api (#7833)
* fix(7842): added filters in inspect metrics api

* fix(metrics-explorer): added check for 40 time series only
2025-05-06 07:06:12 +00:00
Aditya Singh
76ed58c481 Fix/logs issues main (#7758)
* fix: context log data fix in list view

* fix: fix query builder and quick filters in light mode

* chore: add desc

* chore: added test case

* fix: fix redirect url when not in logs view

* chore: minor fix

* chore: minor fix

* chore: minor test fix

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
2025-05-06 11:02:40 +05:30
Shivanshu Raj Shrivastava
f4d029bd12 fix: correctly populate response_status (#7822)
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-05 13:57:09 +05:30
Amlan Kumar Nandy
b66af786e6 fix: description tooltip coming up twice in metrics list table (#7823) 2025-05-05 05:58:56 +00:00
Vibhu Pandey
5ad68a3310 docs(contributing): add sql docs (#7819)
### Summary

add sql docs
2025-05-04 02:23:44 +05:30
Vikrant Gupta
0f0693f6eb fix(ruler): scan orgIDs in string slice instead of valuer struct (#7818) 2025-05-04 00:04:20 +05:30
70 changed files with 4836 additions and 178 deletions

View File

@@ -40,7 +40,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.111.40
image: signoz/signoz-schema-migrator:v0.111.41
container_name: schema-migrator-sync
command:
- sync
@@ -53,7 +53,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.111.40
image: signoz/signoz-schema-migrator:v0.111.41
container_name: schema-migrator-async
command:
- async

View File

@@ -75,10 +75,7 @@ go-run-enterprise: ## Runs the enterprise go backend server
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
go run -race \
$(GO_BUILD_CONTEXT_ENTERPRISE)/main.go \
--config ./conf/prometheus.yml \
--cluster cluster \
--use-logs-new-schema true \
--use-trace-new-schema true
--cluster cluster
.PHONY: go-test
go-test: ## Runs go unit tests
@@ -95,10 +92,7 @@ go-run-community: ## Runs the community go backend server
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
go run -race \
$(GO_BUILD_CONTEXT_COMMUNITY)/main.go \
--config ./conf/prometheus.yml \
--cluster cluster \
--use-logs-new-schema true \
--use-trace-new-schema true
--cluster cluster
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
go-build-community: ## Builds the go backend server for community

View File

@@ -1,25 +0,0 @@
# my global config
global:
scrape_interval: 5s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
# scrape_timeout is set to the global default (10s).
# Alertmanager configuration
alerting:
alertmanagers:
- static_configs:
- targets:
- 127.0.0.1:9093
# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
# - "first_rules.yml"
# - "second_rules.yml"
- 'alerts.yml'
# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs: []
remote_read:
- url: tcp://localhost:9000/signoz_metrics

View File

@@ -1,25 +0,0 @@
# my global config
global:
scrape_interval: 5s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
# scrape_timeout is set to the global default (10s).
# Alertmanager configuration
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093
# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files: []
# - "first_rules.yml"
# - "second_rules.yml"
# - 'alerts.yml'
# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs: []
remote_read:
- url: tcp://clickhouse:9000/signoz_metrics

View File

@@ -174,16 +174,11 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.81.0
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true
- --use-trace-new-schema=true
image: signoz/signoz:v0.82.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
volumes:
- ../common/signoz/prometheus.yml:/root/config/prometheus.yml
- ../common/dashboards:/root/config/dashboards
- ./clickhouse-setup/data/signoz/:/var/lib/signoz/
environment:
@@ -208,7 +203,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.111.40
image: signoz/signoz-otel-collector:v0.111.41
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -232,7 +227,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.111.40
image: signoz/signoz-schema-migrator:v0.111.41
deploy:
restart_policy:
condition: on-failure

View File

@@ -110,16 +110,11 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.81.0
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true
- --use-trace-new-schema=true
image: signoz/signoz:v0.82.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
volumes:
- ../common/signoz/prometheus.yml:/root/config/prometheus.yml
- ../common/dashboards:/root/config/dashboards
- sqlite:/var/lib/signoz/
environment:
@@ -143,7 +138,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.111.40
image: signoz/signoz-otel-collector:v0.111.41
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -167,7 +162,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.111.40
image: signoz/signoz-schema-migrator:v0.111.41
deploy:
restart_policy:
condition: on-failure

View File

@@ -26,7 +26,7 @@ processors:
detectors: [env, system]
timeout: 2s
signozspanmetrics/delta:
metrics_exporter: clickhousemetricswrite
metrics_exporter: clickhousemetricswrite, signozclickhousemetrics
metrics_flush_interval: 60s
latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ]
dimensions_cache_size: 100000
@@ -64,8 +64,10 @@ exporters:
endpoint: tcp://clickhouse:9000/signoz_metrics
resource_to_telemetry_conversion:
enabled: true
disable_v2: true
clickhousemetricswrite/prometheus:
endpoint: tcp://clickhouse:9000/signoz_metrics
disable_v2: true
signozclickhousemetrics:
dsn: tcp://clickhouse:9000/signoz_metrics
clickhouselogsexporter:

View File

@@ -177,17 +177,12 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.81.0}
image: signoz/signoz:${VERSION:-v0.82.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true
- --use-trace-new-schema=true
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
volumes:
- ../common/signoz/prometheus.yml:/root/config/prometheus.yml
- ../common/dashboards:/root/config/dashboards
- sqlite:/var/lib/signoz/
environment:
@@ -212,7 +207,7 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.40}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.41}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -238,7 +233,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.40}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41}
container_name: schema-migrator-sync
command:
- sync
@@ -249,7 +244,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.40}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41}
container_name: schema-migrator-async
command:
- async

View File

@@ -110,17 +110,12 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.81.0}
image: signoz/signoz:${VERSION:-v0.82.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true
- --use-trace-new-schema=true
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
volumes:
- ../common/signoz/prometheus.yml:/root/config/prometheus.yml
- ../common/dashboards:/root/config/dashboards
- sqlite:/var/lib/signoz/
environment:
@@ -144,7 +139,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.40}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.41}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -166,7 +161,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.40}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +173,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.40}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41}
container_name: schema-migrator-async
command:
- async

View File

@@ -26,7 +26,7 @@ processors:
detectors: [env, system]
timeout: 2s
signozspanmetrics/delta:
metrics_exporter: clickhousemetricswrite
metrics_exporter: clickhousemetricswrite, signozclickhousemetrics
metrics_flush_interval: 60s
latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ]
dimensions_cache_size: 100000
@@ -62,10 +62,12 @@ exporters:
use_new_schema: true
clickhousemetricswrite:
endpoint: tcp://clickhouse:9000/signoz_metrics
disable_v2: true
resource_to_telemetry_conversion:
enabled: true
clickhousemetricswrite/prometheus:
endpoint: tcp://clickhouse:9000/signoz_metrics
disable_v2: true
signozclickhousemetrics:
dsn: tcp://clickhouse:9000/signoz_metrics
clickhouselogsexporter:

View File

@@ -0,0 +1,94 @@
# SQL
SigNoz utilizes a relational database to store metadata including organization information, user data and other settings.
## How to use it?
The database interface is defined in [SQLStore](/pkg/sqlstore/sqlstore.go). SigNoz leverages the Bun ORM to interact with the underlying database. To access the database instance, use the `BunDBCtx` function. For operations that require transactions across multiple database operations, use the `RunInTxCtx` function. This function embeds a transaction in the context, which propagates through various functions in the callback.
```go
type Thing struct {
bun.BaseModel
ID types.Identifiable `bun:",embed"`
SomeColumn string `bun:"some_column"`
TimeAuditable types.TimeAuditable `bun:",embed"`
OrgID string `bun:"org_id"`
}
func GetThing(ctx context.Context, id string) (*Thing, error) {
thing := new(Thing)
err := sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(thing).
Where("id = ?", id).
Scan(ctx)
return thing, err
}
func CreateThing(ctx context.Context, thing *Thing) error {
return sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(thing).
Exec(ctx)
}
```
> 💡 **Note**: Always use line breaks while working with SQL queries to enhance code readability.
> 💡 **Note**: Always use the `new` function to create new instances of structs.
## What are hooks?
Hooks are user-defined functions that execute before and/or after specific database operations. These hooks are particularly useful for generating telemetry data such as logs, traces, and metrics, providing visibility into database interactions. Hooks are defined in the [SQLStoreHook](/pkg/sqlstore/sqlstore.go) interface.
## How is the schema designed?
SigNoz implements a star schema design with the organizations table as the central entity. All other tables link to the organizations table via foreign key constraints on the `org_id` column. This design ensures that every entity within the system is either directly or indirectly associated with an organization.
```mermaid
erDiagram
ORGANIZATIONS {
string id PK
timestamp created_at
timestamp updated_at
}
ENTITY_A {
string id PK
timestamp created_at
timestamp updated_at
string org_id FK
}
ENTITY_B {
string id PK
timestamp created_at
timestamp updated_at
string org_id FK
}
ORGANIZATIONS ||--o{ ENTITY_A : contains
ORGANIZATIONS ||--o{ ENTITY_B : contains
```
> 💡 **Note**: There are rare exceptions to the above star schema design. Consult with the maintainers before deviating from the above design.
All tables follow a consistent primary key pattern using a `id` column (referenced by the `types.Identifiable` struct) and include `created_at` and `updated_at` columns (referenced by the `types.TimeAuditable` struct) for audit purposes.
## How to write migrations?
For schema migrations, use the [SQLMigration](/pkg/sqlmigration/sqlmigration.go) interface and write the migration in the same package. When creating migrations, adhere to these guidelines:
- Do not implement **`ON CASCADE` foreign key constraints**. Deletion operations should be handled explicitly in application logic rather than delegated to the database.
- Do not **import types from the types package** in the `sqlmigration` package. Instead, define the required types within the migration package itself. This practice ensures migration stability as the core types evolve over time.
- Do not implement **`Down` migrations**. As the codebase matures, we may introduce this capability, but for now, the `Down` function should remain empty.
- Always write **idempotent** migrations. This means that if the migration is run multiple times, it should not cause an error.
- A migration which is **dependent on the underlying dialect** (sqlite, postgres, etc) should be written as part of the [SQLDialect](/pkg/sqlstore/sqlstore.go) interface. The implementation needs to go in the dialect specific package of the respective database.
## What should I remember?
- Use `BunDBCtx` and `RunInTxCtx` to access the database instance and execute transactions respectively.
- While designing new tables, ensure the consistency of `id`, `created_at`, `updated_at` and an `org_id` column with a foreign key constraint to the `organizations` table (unless the table serves as a transitive entity not directly associated with an organization but indirectly associated with one).
- Implement deletion logic in the application rather than relying on cascading deletes in the database.
- While writing migrations, adhere to the guidelines mentioned above.

View File

@@ -11,11 +11,9 @@ RUN apk update && \
COPY ./target/${OS}-${TARGETARCH}/signoz /root/signoz
COPY ./conf/prometheus.yml /root/config/prometheus.yml
COPY ./templates/email /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["./signoz"]
CMD ["-config", "/root/config/prometheus.yml"]

View File

@@ -12,11 +12,9 @@ RUN apk update && \
rm -rf /var/cache/apk/*
COPY ./target/${OS}-${ARCH}/signoz /root/signoz
COPY ./conf/prometheus.yml /root/config/prometheus.yml
COPY ./templates/email /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["./signoz"]
CMD ["-config", "/root/config/prometheus.yml"]

View File

@@ -106,3 +106,7 @@ func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, for
return err
}
func (dialect *dialect) ToggleForeignKeyConstraint(ctx context.Context, bun *bun.DB, enable bool) error {
return nil
}

View File

@@ -531,6 +531,7 @@ export const oldRoutes = [
'/traces-save-views',
'/settings/access-tokens',
'/messaging-queues',
'/alerts/edit',
];
export const oldNewRoutesMapping: Record<string, string> = {
@@ -541,6 +542,7 @@ export const oldNewRoutesMapping: Record<string, string> = {
'/traces-save-views': '/traces/saved-views',
'/settings/access-tokens': '/settings/api-keys',
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
};
export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [

View File

@@ -0,0 +1,54 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
export interface InspectMetricsRequest {
metricName: string;
start: number;
end: number;
filters: TagFilter;
}
export interface InspectMetricsResponse {
status: string;
data: {
series: InspectMetricsSeries[];
};
}
export interface InspectMetricsSeries {
title?: string;
strokeColor?: string;
labels: Record<string, string>;
labelsArray: Array<Record<string, string>>;
values: InspectMetricsTimestampValue[];
}
interface InspectMetricsTimestampValue {
timestamp: number;
value: string;
}
export const getInspectMetricsDetails = async (
request: InspectMetricsRequest,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<InspectMetricsResponse> | ErrorResponse> => {
try {
const response = await axios.post(`/metrics/inspect`, request, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -3,6 +3,7 @@
flex-direction: column;
height: 100%;
border-right: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
.header {
display: flex;
@@ -74,6 +75,7 @@
.quick-filters {
background-color: var(--bg-vanilla-100);
border-right: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-200);
.header {
border-bottom: 1px solid var(--bg-vanilla-300);

View File

@@ -1,6 +1,9 @@
import React from 'react';
import styled from 'styled-components';
export const SpanStyle = styled.span`
type SpanProps = React.HTMLAttributes<HTMLSpanElement>;
export const SpanStyle = styled.span<SpanProps>`
position: absolute;
right: -0.313rem;
bottom: 0;
@@ -12,7 +15,7 @@ export const SpanStyle = styled.span`
margin-right: 4px;
`;
export const DragSpanStyle = styled.span`
export const DragSpanStyle = styled.span<SpanProps>`
display: flex;
margin: -1rem;
padding: 1rem;

View File

@@ -51,6 +51,7 @@ export const REACT_QUERY_KEY = {
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
GET_INSPECT_METRICS_DETAILS: 'GET_INSPECT_METRICS_DETAILS',
// API Monitoring Query Keys
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',

View File

@@ -411,7 +411,7 @@ describe('API Monitoring Utils', () => {
const statusFilter = result.items.find(
(item) =>
item.key &&
item.key.key === SPAN_ATTRIBUTES.STATUS_CODE &&
item.key.key === SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE &&
item.value === statusCode,
);
expect(statusFilter).toBeDefined();

View File

@@ -13,7 +13,6 @@ export const VIEW_TYPES = {
// Span attribute keys - these are the source of truth for all attribute keys
export const SPAN_ATTRIBUTES = {
URL_PATH: 'http.url',
STATUS_CODE: 'status_code',
RESPONSE_STATUS_CODE: 'response_status_code',
SERVER_NAME: 'net.peer.name',
SERVER_PORT: 'net.peer.port',

View File

@@ -1438,12 +1438,12 @@ export const getTopErrorsCoRelationQueryFilters = (
{
id: 'f6891e27',
key: {
key: 'status_code',
dataType: DataTypes.Float64,
key: 'response_status_code',
dataType: DataTypes.String,
type: '',
isColumn: true,
isJSON: false,
id: 'status_code--float64----true',
id: 'response_status_code--string----true',
},
op: '=',
value: statusCode,

View File

@@ -376,9 +376,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
useEffect(() => {
if (isDarkMode) {
document.body.classList.remove('lightMode');
document.body.classList.add('dark');
document.body.classList.add('darkMode');
} else {
document.body.classList.add('lightMode');
document.body.classList.remove('dark');
document.body.classList.remove('darkMode');
}
}, [isDarkMode]);
@@ -588,7 +590,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
);
return (
<Layout className={cx(isDarkMode ? 'darkMode' : 'lightMode')}>
<Layout className={cx(isDarkMode ? 'darkMode dark' : 'lightMode')}>
<Helmet>
<title>{pageTitle}</title>
</Helmet>
@@ -638,7 +640,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</div>
)}
<Flex className={cx('app-layout', isDarkMode ? 'darkMode' : 'lightMode')}>
<Flex
className={cx('app-layout', isDarkMode ? 'darkMode dark' : 'lightMode')}
>
{isToDisplayLayout && !renderFullScreen && <SideNav />}
<div
className={cx('app-content', {

View File

@@ -5,6 +5,7 @@ import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import ShowButton from 'container/LogsContextList/ShowButton';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
@@ -14,7 +15,6 @@ import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/co
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -106,8 +106,6 @@ function ContextLogRenderer({
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const handleLogClick = useCallback(
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
@@ -117,11 +115,10 @@ function ContextLogRenderer({
encodeURIComponent(JSON.stringify(query)),
);
const link = `${pathname}?${urlQuery.toString()}`;
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(link, '_blank', 'noopener,noreferrer');
},
[pathname, query, urlQuery],
[query, urlQuery],
);
const getItemContent = useCallback(
@@ -143,7 +140,9 @@ function ContextLogRenderer({
linesPerRow={1}
fontSize={options.fontSize}
selectedFields={convertKeysToColumnFields(
options.selectColumns ?? defaultLogsSelectedColumns,
options.selectColumns?.length
? options.selectColumns
: defaultLogsSelectedColumns,
)}
/>
</Button>

View File

@@ -0,0 +1,145 @@
import {
act,
render,
RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import TimezoneProvider from 'providers/Timezone';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { VirtuosoMockContext } from 'react-virtuoso';
import store from 'store';
import ContextLogRenderer from '../ContextLogRenderer';
import {
mockLog,
mockQuery,
mockQueryRangeResponse,
mockTagFilter,
} from './mockData';
// Mock the useContextLogData hook
const mockHandleRunQuery = jest.fn();
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('container/OptionsMenu', () => ({
useOptionsMenu: (): any => ({
options: {
fontSize: 'medium',
selectColumns: [],
},
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
// Common wrapper component for tests
const renderContextLogRenderer = (): RenderResult => {
const defaultProps = {
isEdit: false,
query: mockQuery,
log: mockLog,
filters: mockTagFilter,
};
return render(
<MemoryRouter>
<TimezoneProvider>
<Provider store={store}>
<MockQueryClientProvider>
<QueryBuilderContext.Provider
value={
{
currentQuery: initialQueriesMap.traces,
handleRunQuery: mockHandleRunQuery,
} as any
}
>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 50 }}
>
<ContextLogRenderer
isEdit={defaultProps.isEdit}
query={defaultProps.query}
log={defaultProps.log}
filters={defaultProps.filters}
/>
</VirtuosoMockContext.Provider>
</QueryBuilderContext.Provider>
</MockQueryClientProvider>
</Provider>
</TimezoneProvider>
</MemoryRouter>,
);
};
describe('ContextLogRenderer', () => {
beforeEach(() => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/logs`, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ logs: [mockLog] })),
),
);
server.use(
rest.post(`${ENVIRONMENT.baseURL}/api/v3/query_range`, (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockQueryRangeResponse)),
),
);
});
it('renders without crashing', async () => {
await act(async () => {
renderContextLogRenderer();
});
await waitFor(() => {
expect(screen.getAllByText('Load more')).toHaveLength(2);
expect(screen.getByText(/Test log message/)).toBeInTheDocument();
});
});
it('loads new logs when clicking Load more button', async () => {
await act(async () => {
renderContextLogRenderer();
});
await waitFor(() => {
expect(screen.getAllByText('Load more')).toHaveLength(2);
expect(screen.getByText(/Test log message/)).toBeInTheDocument();
});
const loadMoreButtons = screen.getAllByText('Load more');
await act(async () => {
await userEvent.click(loadMoreButtons[1]);
});
await waitFor(() => {
expect(screen.getAllByText(/Failed to authenticate/)).toHaveLength(3);
});
});
});

View File

@@ -0,0 +1,146 @@
import { ILog } from 'types/api/logs/log';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
export const mockLog: ILog = {
id: 'test-log-id',
date: '2024-03-20T10:00:00Z',
timestamp: '2024-03-20T10:00:00Z',
body: 'Test log message',
attributesString: {},
attributesInt: {},
attributesFloat: {},
attributes_string: {},
severityText: 'info',
severityNumber: 0,
traceId: '',
spanID: '',
traceFlags: 0,
resources_string: {},
scope_string: {},
severity_text: 'info',
severity_number: 0,
};
export const mockQuery: Query = {
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
{
aggregateOperator: 'count',
disabled: false,
queryName: 'A',
groupBy: [],
orderBy: [],
limit: 100,
dataSource: DataSource.LOGS,
aggregateAttribute: {
key: 'body',
type: 'string',
dataType: DataTypes.String,
isColumn: true,
},
timeAggregation: 'sum',
functions: [],
having: [],
stepInterval: 60,
legend: '',
filters: {
items: [],
op: 'AND',
},
expression: 'A',
reduceTo: 'sum',
},
],
queryFormulas: [],
},
clickhouse_sql: [],
id: 'test-query-id',
promql: [],
};
const mockBaseAutocompleteData: BaseAutocompleteData = {
key: 'service',
type: 'string',
dataType: DataTypes.String,
isColumn: true,
};
export const mockTagFilter: TagFilter = {
items: [
{
id: 'test-filter-id',
key: mockBaseAutocompleteData,
op: '=',
value: 'test-service',
},
],
op: 'AND',
};
export const mockQueryRangeResponse = {
status: 'success',
data: {
resultType: '',
result: [
{
queryName: 'A',
list: [
{
timestamp: '2025-04-29T09:55:22.462039242Z',
data: {
attributes_bool: {},
attributes_number: {},
attributes_string: {
'log.file.path':
'/var/log/pods/generator_mongodb-0_755b8973-28c1-4698-a20f-22ee85c52c3f/mongodb/0.log',
'log.iostream': 'stdout',
logtag: 'F',
},
body:
'{"t":{"$date":"2025-04-29T09:55:22.461+00:00"},"s":"I", "c":"ACCESS", "id":5286307, "ctx":"conn231150","msg":"Failed to authenticate","attr":{"client":"10.32.2.33:58258","isSpeculative":false,"isClusterMember":false,"mechanism":"SCRAM-SHA-1","user":"$(MONGO_USER)","db":"admin","error":"UserNotFound: Could not find user \\"$(MONGO_USER)\\" for db \\"admin\\"","result":11,"metrics":{"conversation_duration":{"micros":473,"summary":{"0":{"step":1,"step_total":2,"duration_micros":446}}}},"extraInfo":{}}}',
id: '2wOlVEhbqYipTUgs3PRMFF1hqjJ',
resources_string: {
'cloud.account.id': 'signoz-staging',
'cloud.availability_zone': 'us-central1-c',
'cloud.platform': 'gcp_kubernetes_engine',
'cloud.provider': 'gcp',
'container.image.name': 'docker.io/bitnami/mongodb',
'container.image.tag': '7.0.14-debian-12-r0',
'deployment.environment': 'sample-flask',
'host.id': '6006012725680193244',
'host.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-d54x',
'k8s.cluster.name': 'mgmt',
'k8s.container.name': 'mongodb',
'k8s.container.restart_count': '0',
'k8s.namespace.name': 'generator',
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-d54x',
'k8s.node.uid': 'ef650183-226d-41c0-8295-aeec210b15dd',
'k8s.pod.name': 'mongodb-0',
'k8s.pod.start_time': '2025-04-26T04:47:44Z',
'k8s.pod.uid': '755b8973-28c1-4698-a20f-22ee85c52c3f',
'k8s.statefulset.name': 'mongodb',
'os.type': 'linux',
'service.name': 'mongodb',
},
scope_name: '',
scope_string: {},
scope_version: '',
severity_number: 0,
severity_text: '',
span_id: '',
trace_flags: 0,
trace_id: '',
},
},
],
},
],
},
};

View File

@@ -134,6 +134,8 @@ export const useContextLogData = ({
enabled: !!requestData,
onSuccess: handleSuccess,
},
undefined, // params
false, // isDependentOnQB
);
const handleShowNextLines = useCallback(() => {

View File

@@ -0,0 +1,350 @@
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Color } from '@signozhq/design-tokens';
import { Card, Tooltip, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import classNames from 'classnames';
import ResizeTable from 'components/ResizeTable/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { ArrowDownCircle, ArrowRightCircle, Focus } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW,
TIME_AGGREGATION_OPTIONS,
} from './constants';
import {
ExpandedViewProps,
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from './types';
import {
formatTimestampToFullDateTime,
getRawDataFromTimeSeries,
getSpaceAggregatedDataFromTimeSeries,
} from './utils';
function ExpandedView({
options,
spaceAggregationSeriesMap,
step,
metricInspectionOptions,
timeAggregatedSeriesMap,
}: ExpandedViewProps): JSX.Element {
const [
selectedTimeSeries,
setSelectedTimeSeries,
] = useState<InspectMetricsSeries | null>(null);
useEffect(() => {
if (step !== InspectionStep.COMPLETED) {
setSelectedTimeSeries(options?.timeSeries ?? null);
} else {
setSelectedTimeSeries(null);
}
}, [step, options?.timeSeries]);
const spaceAggregatedData = useMemo(() => {
if (
!options?.timeSeries ||
!options?.timestamp ||
step !== InspectionStep.COMPLETED
) {
return [];
}
return getSpaceAggregatedDataFromTimeSeries(
options?.timeSeries,
spaceAggregationSeriesMap,
options?.timestamp,
true,
);
}, [options?.timeSeries, options?.timestamp, spaceAggregationSeriesMap, step]);
const rawData = useMemo(() => {
if (!selectedTimeSeries || !options?.timestamp) {
return [];
}
return getRawDataFromTimeSeries(selectedTimeSeries, options?.timestamp, true);
}, [selectedTimeSeries, options?.timestamp]);
const absoluteValue = useMemo(
() =>
options?.timeSeries?.values.find(
(value) => value.timestamp >= options?.timestamp,
)?.value ?? options?.value,
[options],
);
const timeAggregatedData = useMemo(() => {
if (step !== InspectionStep.SPACE_AGGREGATION || !options?.timestamp) {
return [];
}
return (
timeAggregatedSeriesMap
.get(options?.timestamp)
?.filter(
(popoverData) =>
popoverData.title && popoverData.title === options.timeSeries?.title,
) ?? []
);
}, [
step,
options?.timestamp,
options?.timeSeries?.title,
timeAggregatedSeriesMap,
]);
const tableData = useMemo(() => {
if (!selectedTimeSeries) {
return [];
}
return Object.entries(selectedTimeSeries.labels).map(([key, value]) => ({
label: key,
value,
}));
}, [selectedTimeSeries]);
const columns: ColumnsType<DataType> = useMemo(
() => [
{
title: 'Label',
dataIndex: 'label',
key: 'label',
width: 50,
align: 'left',
className: 'labels-key',
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'labels-value',
},
],
[],
);
return (
<div className="expanded-view">
<div className="expanded-view-header">
<Typography.Title level={5}>
<Focus size={16} color={Color.BG_VANILLA_100} />
<div>POINT INSPECTOR</div>
</Typography.Title>
</div>
{/* Show only when space aggregation is completed */}
{step === InspectionStep.COMPLETED && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
<Typography.Text strong>
{`${absoluteValue} is the ${
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW[
metricInspectionOptions.spaceAggregationOption ??
SpaceAggregationOptions.SUM_BY
]
} of`}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{spaceAggregatedData?.map(({ value, title, timestamp }) => (
<Tooltip key={`${title}-${timestamp}-${value}`} title={value}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{value}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIME SERIES
</Typography.Text>
<div className="graph-popover-inner-row">
{spaceAggregatedData?.map(({ title, timeSeries }) => (
<Tooltip key={title} title={title}>
<div
data-testid="graph-popover-cell"
className={classNames('graph-popover-cell', 'timeseries-cell', {
selected: title === selectedTimeSeries?.title,
})}
onClick={(): void => {
setSelectedTimeSeries(timeSeries ?? null);
}}
>
{title}
{selectedTimeSeries?.title === title ? (
<ArrowDownCircle color={Color.BG_FOREST_300} size={12} />
) : (
<ArrowRightCircle size={12} />
)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Show only for space aggregated or raw data */}
{selectedTimeSeries && step !== InspectionStep.SPACE_AGGREGATION && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
{step !== InspectionStep.COMPLETED && (
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
)}
<Typography.Text strong>
{step === InspectionStep.COMPLETED
? `${
selectedTimeSeries?.values.find(
(value) => value?.timestamp >= (options?.timestamp || 0),
)?.value ?? options?.value
} is the ${
TIME_AGGREGATION_OPTIONS[
metricInspectionOptions.timeAggregationOption ??
TimeAggregationOptions.SUM
]
} of`
: selectedTimeSeries?.values.find(
(value) => value?.timestamp >= (options?.timestamp || 0),
)?.value ?? options?.value}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
RAW VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{rawData?.map(({ value: rawValue, timestamp, title }) => (
<Tooltip key={`${title}-${timestamp}-${rawValue}`} title={rawValue}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{rawValue}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIMESTAMPS
</Typography.Text>
<div className="graph-popover-inner-row">
{rawData?.map(({ timestamp }) => (
<Tooltip
key={timestamp}
title={formatTimestampToFullDateTime(timestamp ?? '', true)}
>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{formatTimestampToFullDateTime(timestamp ?? '', true)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Show raw values breakdown only for time aggregated data */}
{selectedTimeSeries && step === InspectionStep.SPACE_AGGREGATION && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
<Typography.Text strong>
{`${absoluteValue} is the ${
TIME_AGGREGATION_OPTIONS[
metricInspectionOptions.timeAggregationOption ??
TimeAggregationOptions.SUM
]
} of`}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
RAW VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{timeAggregatedData?.map(({ value, title, timestamp }) => (
<Tooltip key={`${title}-${timestamp}-${value}`} title={value}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{value}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIMESTAMPS
</Typography.Text>
<div className="graph-popover-inner-row">
{timeAggregatedData?.map(({ timestamp }) => (
<Tooltip
key={timestamp}
title={formatTimestampToFullDateTime(timestamp ?? '', true)}
>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{formatTimestampToFullDateTime(timestamp ?? '', true)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Labels */}
{selectedTimeSeries && (
<>
<Typography.Title
level={5}
>{`${selectedTimeSeries?.title} Labels`}</Typography.Title>
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
scroll={{ y: 600 }}
className="labels-table"
/>
</>
)}
</div>
);
}
export default ExpandedView;

View File

@@ -0,0 +1,71 @@
import { Button, Card, Typography } from 'antd';
import { ArrowRight } from 'lucide-react';
import { useMemo } from 'react';
import { GraphPopoverProps } from './types';
import { formatTimestampToFullDateTime } from './utils';
function GraphPopover({
options,
popoverRef,
openInExpandedView,
}: GraphPopoverProps): JSX.Element | null {
const { x, y, value, timestamp, timeSeries } = options || {
x: 0,
y: 0,
value: 0,
timestamp: 0,
timeSeries: null,
};
const closestTimestamp = useMemo(() => {
if (!timeSeries) {
return timestamp;
}
return timeSeries?.values.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.timestamp - timestamp);
const currDiff = Math.abs(curr.timestamp - timestamp);
return prevDiff < currDiff ? prev : curr;
}).timestamp;
}, [timeSeries, timestamp]);
const closestValue = useMemo(() => {
if (!timeSeries) {
return value;
}
const index = timeSeries.values.findIndex(
(value) => value.timestamp === closestTimestamp,
);
return index !== undefined && index >= 0
? timeSeries?.values[index].value
: null;
}, [timeSeries, closestTimestamp, value]);
return (
<div
style={{
top: y + 10,
left: x + 10,
}}
ref={popoverRef}
className="inspect-graph-popover"
>
<Card className="inspect-graph-popover-content" size="small">
<div className="inspect-graph-popover-row">
<Typography.Text type="secondary">
{formatTimestampToFullDateTime(closestTimestamp)}
</Typography.Text>
<Typography.Text>{Number(closestValue).toFixed(2)}</Typography.Text>
</div>
<div className="inspect-graph-popover-button-row">
<Button size="small" type="primary" onClick={openInExpandedView}>
<Typography.Text>View details</Typography.Text>
<ArrowRight size={10} />
</Button>
</div>
</Card>
</div>
);
}
export default GraphPopover;

View File

@@ -0,0 +1,256 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Skeleton, Switch, Typography } from 'antd';
import Uplot from 'components/Uplot';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import { METRIC_TYPE_TO_COLOR_MAP, METRIC_TYPE_TO_ICON_MAP } from './constants';
import GraphPopover from './GraphPopover';
import TableView from './TableView';
import { GraphPopoverOptions, GraphViewProps } from './types';
import { HoverPopover, onGraphClick, onGraphHover } from './utils';
function GraphView({
inspectMetricsTimeSeries,
formattedInspectMetricsTimeSeries,
metricUnit,
metricName,
metricType,
spaceAggregationSeriesMap,
inspectionStep,
setPopoverOptions,
popoverOptions,
setShowExpandedView,
setExpandedViewOptions,
metricInspectionOptions,
isInspectMetricsRefetching,
}: GraphViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const start = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
]);
const end = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [maxTime]);
const [showGraphPopover, setShowGraphPopover] = useState(false);
const [showHoverPopover, setShowHoverPopover] = useState(false);
const [
hoverPopoverOptions,
setHoverPopoverOptions,
] = useState<GraphPopoverOptions | null>(null);
const [viewType, setViewType] = useState<'graph' | 'table'>('graph');
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent): void {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
graphRef.current &&
!graphRef.current.contains(event.target as Node)
) {
setShowGraphPopover(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return (): void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [popoverRef, graphRef]);
const options: uPlot.Options = useMemo(
() => ({
width: dimensions.width,
height: 500,
legend: {
show: false,
},
axes: [
{
stroke: isDarkMode ? Color.TEXT_VANILLA_400 : Color.BG_SLATE_400,
grid: {
show: false,
},
values: (_, vals): string[] =>
vals.map((v) => {
const d = new Date(v);
const date = `${String(d.getDate()).padStart(2, '0')}/${String(
d.getMonth() + 1,
).padStart(2, '0')}`;
const time = `${String(d.getHours()).padStart(2, '0')}:${String(
d.getMinutes(),
).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
return `${date}\n${time}`; // two-line label
}),
},
{
label: metricUnit || '',
stroke: isDarkMode ? Color.TEXT_VANILLA_400 : Color.BG_SLATE_400,
grid: {
show: true,
stroke: isDarkMode ? Color.BG_SLATE_500 : Color.BG_SLATE_200,
},
values: (_, vals): string[] =>
vals.map((v) => formatNumberIntoHumanReadableFormat(v, false)),
},
],
series: [
{ label: 'Time' }, // This config is required as a placeholder for x-axis,
...formattedInspectMetricsTimeSeries.slice(1).map((_, index) => ({
drawStyle: 'line',
lineInterpolation: 'spline',
show: true,
label: String.fromCharCode(65 + (index % 26)),
stroke: inspectMetricsTimeSeries[index]?.strokeColor,
width: 2,
spanGaps: true,
points: {
size: 5,
show: false,
stroke: inspectMetricsTimeSeries[index]?.strokeColor,
},
scales: {
x: {
min: start,
max: end,
},
},
})),
],
hooks: {
ready: [
(u: uPlot): void => {
u.over.addEventListener('click', (e) => {
onGraphClick(
e,
u,
popoverRef,
setPopoverOptions,
inspectMetricsTimeSeries,
showGraphPopover,
setShowGraphPopover,
formattedInspectMetricsTimeSeries,
);
});
u.over.addEventListener('mousemove', (e) => {
onGraphHover(
e,
u,
setHoverPopoverOptions,
inspectMetricsTimeSeries,
formattedInspectMetricsTimeSeries,
);
});
u.over.addEventListener('mouseenter', () => {
setShowHoverPopover(true);
});
u.over.addEventListener('mouseleave', () => {
setShowHoverPopover(false);
});
},
],
},
}),
[
dimensions.width,
isDarkMode,
metricUnit,
formattedInspectMetricsTimeSeries,
inspectMetricsTimeSeries,
start,
end,
setPopoverOptions,
showGraphPopover,
],
);
const MetricTypeIcon = metricType ? METRIC_TYPE_TO_ICON_MAP[metricType] : null;
return (
<div className="inspect-metrics-graph-view" ref={graphRef}>
<div className="inspect-metrics-graph-view-header">
<Button.Group>
<Button
className="metric-name-button-label"
size="middle"
icon={
MetricTypeIcon && metricType ? (
<MetricTypeIcon
size={14}
color={METRIC_TYPE_TO_COLOR_MAP[metricType]}
/>
) : null
}
disabled
>
{metricName}
</Button>
<Button className="time-series-button-label" size="middle" disabled>
{/* First time series in that of timestamps. Hence -1 */}
{`${formattedInspectMetricsTimeSeries.length - 1} time series`}
</Button>
</Button.Group>
<div className="view-toggle-button">
<Switch
checked={viewType === 'graph'}
onChange={(checked): void => setViewType(checked ? 'graph' : 'table')}
/>
<Typography.Text>
{viewType === 'graph' ? 'Graph View' : 'Table View'}
</Typography.Text>
</div>
</div>
<div className="graph-view-container">
{viewType === 'graph' &&
(isInspectMetricsRefetching ? (
<Skeleton active />
) : (
<Uplot data={formattedInspectMetricsTimeSeries} options={options} />
))}
{viewType === 'table' && (
<TableView
inspectionStep={inspectionStep}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
setShowExpandedView={setShowExpandedView}
setExpandedViewOptions={setExpandedViewOptions}
metricInspectionOptions={metricInspectionOptions}
isInspectMetricsRefetching={isInspectMetricsRefetching}
/>
)}
</div>
{showGraphPopover && (
<GraphPopover
options={popoverOptions}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
popoverRef={popoverRef}
step={inspectionStep}
openInExpandedView={(): void => {
setShowGraphPopover(false);
setShowExpandedView(true);
setExpandedViewOptions(popoverOptions);
}}
/>
)}
{showHoverPopover && !showGraphPopover && hoverPopoverOptions && (
<HoverPopover
options={hoverPopoverOptions}
step={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
/>
)}
</div>
);
}
export default GraphView;

View File

@@ -1,4 +1,14 @@
.inspect-metrics-modal {
display: flex;
gap: 16px;
.inspect-metrics-fallback {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.inspect-metrics-title {
display: flex;
align-items: center;
@@ -13,4 +23,567 @@
color: var(--text-vanilla-500);
}
}
.inspect-metrics-content {
display: flex;
flex-direction: row;
justify-content: space-between;
.inspect-metrics-content-first-col {
display: flex;
flex-direction: column;
flex: 2;
gap: 16px;
padding-right: 24px;
border-right: 1px solid var(--bg-slate-400);
width: 60%;
.inspect-metrics-graph-view {
display: flex;
flex-direction: column;
gap: 32px;
.inspect-metrics-graph-view-header {
display: flex;
align-items: center;
justify-content: space-between;
.ant-btn-group {
.time-series-button-label,
.metric-name-button-label {
display: flex;
align-items: center;
justify-content: center;
cursor: default;
span {
color: var(--text-vanilla-100);
}
}
}
.view-toggle-button {
display: flex;
gap: 8px;
align-items: center;
}
}
.graph-view-container {
min-height: 520px;
.inspect-metrics-table-view {
max-width: 100%;
.ant-spin-nested-loading {
.ant-spin-container {
.ant-table {
height: 450px;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
}
}
}
.table-view-title-header,
.table-view-values-header {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
.ant-card {
cursor: pointer;
width: 100px;
max-width: 100px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.ant-card-body {
padding: 6px 8px;
}
&:hover {
opacity: 0.8;
}
}
}
}
}
}
.inspect-metrics-query-builder {
display: flex;
flex-direction: column;
gap: 4px;
.inspect-metrics-query-builder-header {
.query-builder-button-label {
display: flex;
align-items: center;
justify-content: center;
cursor: default;
span {
color: var(--text-vanilla-100);
}
}
}
.inspect-metrics-query-builder-content {
.ant-card-body {
display: flex;
flex-direction: column;
gap: 16px;
.selected-step {
color: var(--bg-sakura-500);
.ant-typography {
color: var(--bg-sakura-500);
}
}
.inspect-metrics-input-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
.ant-typography {
min-width: 130px;
}
.ant-select {
flex-grow: 1;
}
.no-arrows-input input[type='number']::-webkit-inner-spin-button,
.no-arrows-input input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Hide number input arrows (Firefox) */
.no-arrows-input input[type='number'] {
appearance: none;
-moz-appearance: textfield;
}
}
.metric-time-aggregation {
display: flex;
flex-direction: column;
gap: 16px;
.metric-time-aggregation-header {
display: flex;
gap: 8px;
}
.metric-time-aggregation-content {
display: flex;
gap: 24px;
width: 100%;
.inspect-metrics-input-group {
width: 50%;
}
}
}
.metric-space-aggregation {
display: flex;
flex-direction: column;
gap: 16px;
.metric-space-aggregation-header {
display: flex;
gap: 8px;
}
.metric-space-aggregation-content {
display: flex;
gap: 8px;
width: 100%;
.metric-space-aggregation-content-left {
width: 130px;
}
}
}
}
}
.metric-filters {
.query-builder-search-container {
width: 100%;
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
color: var(--text-vanilla-100);
border-color: var(--bg-slate-400);
}
}
}
}
}
}
.inspect-metrics-content-second-col {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
.home-checklist-container {
padding-left: 40px;
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 32px;
border-bottom: 1px solid var(--bg-slate-400);
.home-checklist-title {
display: flex;
flex-direction: column;
gap: 8px;
}
.completed-checklist-container {
margin-left: 20px;
}
.completed-message-container {
display: flex;
flex-direction: column;
gap: 16px;
height: 100px;
.ant-btn {
width: fit-content;
}
}
}
.expanded-view {
display: flex;
flex-direction: column;
gap: 16px;
padding-left: 40px;
}
}
}
}
.inspect-graph-popover {
position: fixed;
z-index: 1000;
.inspect-graph-popover-content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 350px;
.inspect-graph-popover-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.inspect-graph-popover-button-row {
display: flex;
align-items: center;
justify-content: flex-end;
.ant-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 16px;
}
}
}
}
.graph-popover {
position: fixed;
z-index: 1000;
.graph-popover-card {
width: 550px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
.ant-card-body {
width: fit-content;
}
.graph-popover-row {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
.graph-popover-row-label {
width: 100px;
}
.graph-popover-inner-row {
display: flex;
align-items: center;
gap: 8px;
.ant-typography {
width: 400px;
margin-top: 4px;
align-items: center;
display: flex;
gap: 8px;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
text-overflow: ellipsis;
}
}
}
.graph-popover-header-text {
color: var(--text-vanilla-400);
}
.graph-popover-row-label {
color: var(--bg-slate-50);
width: 10%;
}
.graph-popover-cell {
padding: 4px 8px;
background-color: #1f1f1f;
border-radius: 4px;
color: #fff;
min-width: 60px;
max-width: 60px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.footer-row {
margin-top: 12px;
display: flex;
gap: 8px;
align-items: center;
.footer-text {
white-space: nowrap;
}
.footer-divider {
flex: 1;
border-top: 1px dashed #ccc;
margin: 0 8px;
}
}
}
}
.expanded-view {
.expanded-view-header {
.ant-typography {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
}
.graph-popover {
z-index: 2;
position: initial;
.graph-popover-card {
width: 100%;
.timeseries-cell {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
opacity: 60%;
}
}
.selected {
opacity: 90%;
}
.graph-popover-section {
width: 500px;
overflow-x: scroll;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
text-overflow: ellipsis;
.graph-popover-row {
.graph-popover-row-label {
min-width: 100px;
}
.graph-popover-inner-row {
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
}
.labels-table {
border: 1px solid var(--bg-slate-400);
.labels-key {
color: var(--bg-vanilla-400);
background-color: var(--bg-slate-500);
font-family: 'Geist Mono';
}
.labels-value {
background-color: var(--bg-slate-500);
opacity: 80%;
font-family: 'Geist Mono';
.field-renderer-container {
.label {
color: var(--bg-slate-400);
}
}
}
}
}
.hover-popover-card {
position: fixed;
z-index: 500;
max-width: 700px;
display: flex;
flex-direction: column;
gap: 8px;
.hover-popover-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
}
.lightMode {
.inspect-metrics-modal {
.inspect-metrics-title {
.inspect-metrics-button {
color: var(--text-ink-400);
}
}
.inspect-metrics-content {
.inspect-metrics-content-first-col {
.inspect-metrics-graph-view {
.inspect-metrics-graph-view-header {
.ant-btn-group {
.time-series-button-label,
.metric-name-button-label {
span {
color: var(--text-ink-100);
}
}
}
}
}
.inspect-metrics-query-builder {
.inspect-metrics-query-builder-header {
.query-builder-button-label {
span {
color: var(--text-ink-100);
}
}
}
.metric-filters {
.query-builder-search-v2 {
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
border: 0.5px solid var(--bg-slate-300) !important;
}
}
}
}
}
}
}
}
.graph-popover {
.graph-popover-card {
.graph-popover-header-text {
color: var(--text-ink-400);
}
.graph-popover-row-label {
color: var(--bg-slate-50);
}
.graph-popover-cell {
background-color: var(--bg-vanilla-300);
color: var(--text-ink-100);
}
.footer-row {
.footer-divider {
border-top: 1px dashed var(--bg-slate-300);
}
}
}
}
.expanded-view {
.labels-table {
border: 1px solid var(--bg-vanilla-400);
.labels-key {
color: var(--bg-slate-400);
background-color: var(--bg-vanilla-400);
}
.labels-value {
background-color: var(--bg-vanilla-400);
.field-renderer-container {
.label {
color: var(--bg-vanilla-400);
}
}
}
}
}
}

View File

@@ -2,15 +2,236 @@ import './Inspect.styles.scss';
import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens';
import { Button, Drawer, Typography } from 'antd';
import { Button, Drawer, Empty, Skeleton, Typography } from 'antd';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { InspectProps } from './types';
import ExpandedView from './ExpandedView';
import GraphView from './GraphView';
import QueryBuilder from './QueryBuilder';
import Stepper from './Stepper';
import { GraphPopoverOptions, InspectProps } from './types';
import { useInspectMetrics } from './useInspectMetrics';
function Inspect({ metricName, isOpen, onClose }: InspectProps): JSX.Element {
function Inspect({
metricName: defaultMetricName,
isOpen,
onClose,
}: InspectProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [metricName, setMetricName] = useState<string | null>(defaultMetricName);
const [
popoverOptions,
setPopoverOptions,
] = useState<GraphPopoverOptions | null>(null);
const [
expandedViewOptions,
setExpandedViewOptions,
] = useState<GraphPopoverOptions | null>(null);
const [showExpandedView, setShowExpandedView] = useState(false);
const { data: metricDetailsData } = useGetMetricDetails(metricName ?? '', {
enabled: !!metricName,
});
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
},
],
},
}),
[currentQuery],
);
const searchQuery = updatedCurrentQuery?.builder?.queryData[0] || null;
useEffect(() => {
handleChangeQueryData('filters', {
op: 'AND',
items: [],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const {
inspectMetricsTimeSeries,
inspectMetricsStatusCode,
isInspectMetricsLoading,
isInspectMetricsError,
formattedInspectMetricsTimeSeries,
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
isInspectMetricsRefetching,
spaceAggregatedSeriesMap: spaceAggregationSeriesMap,
aggregatedTimeSeries,
timeAggregatedSeriesMap,
reset,
} = useInspectMetrics(metricName);
const selectedMetricType = useMemo(
() => metricDetailsData?.payload?.data?.metadata?.metric_type,
[metricDetailsData],
);
const selectedMetricUnit = useMemo(
() => metricDetailsData?.payload?.data?.metadata?.unit,
[metricDetailsData],
);
const resetInspection = useCallback(() => {
setShowExpandedView(false);
setPopoverOptions(null);
setExpandedViewOptions(null);
reset();
}, [reset]);
// Reset inspection when the selected metric changes
useEffect(() => {
resetInspection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricName]);
// Hide expanded view whenever inspection step changes
useEffect(() => {
setShowExpandedView(false);
setExpandedViewOptions(null);
}, [inspectionStep]);
const content = useMemo(() => {
if (isInspectMetricsLoading && !isInspectMetricsRefetching) {
return (
<div
data-testid="inspect-metrics-loading"
className="inspect-metrics-fallback"
>
<Skeleton active />
</div>
);
}
if (isInspectMetricsError || inspectMetricsStatusCode !== 200) {
const errorMessage =
inspectMetricsStatusCode === 400
? 'The time range is too large. Please modify it to be within 30 minutes.'
: 'Error loading inspect metrics.';
return (
<div
data-testid="inspect-metrics-error"
className="inspect-metrics-fallback"
>
<Empty description={errorMessage} />
</div>
);
}
if (!inspectMetricsTimeSeries.length) {
return (
<div
data-testid="inspect-metrics-empty"
className="inspect-metrics-fallback"
>
<Empty description="No time series found for this metric to inspect." />
</div>
);
}
return (
<div className="inspect-metrics-content">
<div className="inspect-metrics-content-first-col">
<GraphView
inspectMetricsTimeSeries={aggregatedTimeSeries}
formattedInspectMetricsTimeSeries={formattedInspectMetricsTimeSeries}
resetInspection={resetInspection}
metricName={metricName}
metricUnit={selectedMetricUnit}
metricType={selectedMetricType}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
inspectionStep={inspectionStep}
setPopoverOptions={setPopoverOptions}
setShowExpandedView={setShowExpandedView}
showExpandedView={showExpandedView}
setExpandedViewOptions={setExpandedViewOptions}
popoverOptions={popoverOptions}
metricInspectionOptions={metricInspectionOptions}
isInspectMetricsRefetching={isInspectMetricsRefetching}
/>
<QueryBuilder
metricName={metricName}
metricType={selectedMetricType}
setMetricName={setMetricName}
spaceAggregationLabels={spaceAggregationLabels}
metricInspectionOptions={metricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
inspectionStep={inspectionStep}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
searchQuery={searchQuery}
/>
</div>
<div className="inspect-metrics-content-second-col">
<Stepper
inspectionStep={inspectionStep}
resetInspection={resetInspection}
/>
{showExpandedView && (
<ExpandedView
options={expandedViewOptions}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
step={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
timeAggregatedSeriesMap={timeAggregatedSeriesMap}
/>
)}
</div>
</div>
);
}, [
isInspectMetricsLoading,
isInspectMetricsRefetching,
isInspectMetricsError,
inspectMetricsStatusCode,
inspectMetricsTimeSeries,
aggregatedTimeSeries,
formattedInspectMetricsTimeSeries,
resetInspection,
metricName,
selectedMetricUnit,
selectedMetricType,
spaceAggregationSeriesMap,
inspectionStep,
showExpandedView,
popoverOptions,
metricInspectionOptions,
spaceAggregationLabels,
dispatchMetricInspectionOptions,
searchQuery,
expandedViewOptions,
timeAggregatedSeriesMap,
]);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
@@ -38,8 +259,7 @@ function Inspect({ metricName, isOpen, onClose }: InspectProps): JSX.Element {
className="inspect-metrics-modal"
destroyOnClose
>
<div>Inspect</div>
<div>{metricName}</div>
{content}
</Drawer>
</Sentry.ErrorBoundary>
);

View File

@@ -0,0 +1,60 @@
import { Button, Card } from 'antd';
import { Atom } from 'lucide-react';
import { QueryBuilderProps } from './types';
import {
MetricFilters,
MetricNameSearch,
MetricSpaceAggregation,
MetricTimeAggregation,
} from './utils';
function QueryBuilder({
metricName,
setMetricName,
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
inspectMetricsTimeSeries,
searchQuery,
metricType,
}: QueryBuilderProps): JSX.Element {
return (
<div className="inspect-metrics-query-builder">
<div className="inspect-metrics-query-builder-header">
<Button
className="query-builder-button-label"
size="middle"
icon={<Atom size={14} />}
disabled
>
Query Builder
</Button>
</div>
<Card className="inspect-metrics-query-builder-content">
<MetricNameSearch metricName={metricName} setMetricName={setMetricName} />
<MetricFilters
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
searchQuery={searchQuery}
metricName={metricName}
metricType={metricType || null}
/>
<MetricTimeAggregation
inspectionStep={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
/>
<MetricSpaceAggregation
inspectionStep={inspectionStep}
spaceAggregationLabels={spaceAggregationLabels}
metricInspectionOptions={metricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
/>
</Card>
</div>
);
}
export default QueryBuilder;

View File

@@ -0,0 +1,92 @@
import '../../Home/HomeChecklist/HomeChecklist.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Typography } from 'antd';
import classNames from 'classnames';
import { ArrowUpRightFromSquare, RefreshCcw } from 'lucide-react';
import { SPACE_AGGREGATION_LINK, TEMPORAL_AGGREGATION_LINK } from './constants';
import { InspectionStep, StepperProps } from './types';
function Stepper({
inspectionStep,
resetInspection,
}: StepperProps): JSX.Element {
return (
<div className="home-checklist-container">
<div className="home-checklist-title">
<Typography.Text>
👋 Hello, welcome to the Metrics Inspector
</Typography.Text>
<Typography.Text>Lets get you started...</Typography.Text>
</div>
<div className="completed-checklist-container whats-next-checklist-container">
<div
className={classNames({
'completed-checklist-item':
inspectionStep > InspectionStep.TIME_AGGREGATION,
'whats-next-checklist-item':
inspectionStep <= InspectionStep.TIME_AGGREGATION,
})}
>
<div
className={classNames({
'completed-checklist-item-title':
inspectionStep > InspectionStep.TIME_AGGREGATION,
'whats-next-checklist-item-title':
inspectionStep <= InspectionStep.TIME_AGGREGATION,
})}
>
First, align the data by selecting a{' '}
<Typography.Link href={TEMPORAL_AGGREGATION_LINK} target="_blank">
Temporal Aggregation{' '}
<ArrowUpRightFromSquare color={Color.BG_ROBIN_500} size={10} />
</Typography.Link>
</div>
</div>
<div
className={classNames({
'completed-checklist-item':
inspectionStep > InspectionStep.SPACE_AGGREGATION,
'whats-next-checklist-item':
inspectionStep <= InspectionStep.SPACE_AGGREGATION,
})}
>
<div
className={classNames({
'completed-checklist-item-title':
inspectionStep > InspectionStep.SPACE_AGGREGATION,
'whats-next-checklist-item-title':
inspectionStep <= InspectionStep.SPACE_AGGREGATION,
})}
>
Add a{' '}
<Typography.Link href={SPACE_AGGREGATION_LINK} target="_blank">
Spatial Aggregation{' '}
<ArrowUpRightFromSquare color={Color.BG_ROBIN_500} size={10} />
</Typography.Link>
</div>
</div>
</div>
<div className="completed-message-container">
{inspectionStep === InspectionStep.COMPLETED && (
<>
<Typography.Text>
🎉 Ta-da! You have completed your metric query tutorial.
</Typography.Text>
<Typography.Text>
You can inspect a new metric or reset the query builder.
</Typography.Text>
<Button icon={<RefreshCcw size={12} />} onClick={resetInspection}>
Reset query
</Button>
</>
)}
</div>
</div>
);
}
export default Stepper;

View File

@@ -0,0 +1,136 @@
import { Card, Flex, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { useCallback, useMemo } from 'react';
import { TableViewProps } from './types';
import { formatTimestampToFullDateTime } from './utils';
function TableView({
inspectMetricsTimeSeries,
setShowExpandedView,
setExpandedViewOptions,
isInspectMetricsRefetching,
metricInspectionOptions,
}: TableViewProps): JSX.Element {
const isSpaceAggregatedWithoutLabel = useMemo(
() =>
!!metricInspectionOptions.spaceAggregationOption &&
metricInspectionOptions.spaceAggregationLabels.length === 0,
[metricInspectionOptions],
);
const labelKeys = useMemo(() => {
if (isSpaceAggregatedWithoutLabel) {
return [];
}
if (inspectMetricsTimeSeries.length > 0) {
return Object.keys(inspectMetricsTimeSeries[0].labels);
}
return [];
}, [inspectMetricsTimeSeries, isSpaceAggregatedWithoutLabel]);
const getDynamicColumnStyle = (strokeColor?: string): React.CSSProperties => {
const style: React.CSSProperties = {
maxWidth: '200px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
if (strokeColor) {
style.color = strokeColor;
}
return style;
};
const columns = useMemo(
() => [
...labelKeys.map((label) => ({
title: label,
dataIndex: label,
align: 'left',
render: (text: string): JSX.Element => (
<div style={getDynamicColumnStyle()}>{text}</div>
),
})),
{
title: 'Values',
dataIndex: 'values',
align: 'left',
sticky: 'right',
},
],
[labelKeys],
);
const openExpandedView = useCallback(
(series: InspectMetricsSeries, value: string, timestamp: number): void => {
setShowExpandedView(true);
setExpandedViewOptions({
x: timestamp,
y: Number(value),
value: Number(value),
timestamp,
timeSeries: series,
});
},
[setShowExpandedView, setExpandedViewOptions],
);
const dataSource = useMemo(
() =>
inspectMetricsTimeSeries.map((series, index) => {
const labelData = labelKeys.reduce((acc, label) => {
acc[label] = (
<div style={getDynamicColumnStyle(series.strokeColor)}>
{series.labels[label]}
</div>
);
return acc;
}, {} as Record<string, JSX.Element>);
return {
key: index,
...labelData,
values: (
<div className="table-view-values-header">
<Flex gap={8}>
{series.values.map((value) => {
const formattedValue = `(${formatTimestampToFullDateTime(
value.timestamp,
true,
)}, ${value.value})`;
return (
<Card
key={formattedValue}
onClick={(): void =>
openExpandedView(series, value.value, value.timestamp)
}
>
<Typography.Text>{formattedValue}</Typography.Text>
</Card>
);
})}
</Flex>
</div>
),
};
}),
[inspectMetricsTimeSeries, labelKeys, openExpandedView],
);
return (
<Table
className="inspect-metrics-table-view"
dataSource={dataSource}
columns={
columns as ColumnsType<{
values: JSX.Element;
key: number;
}>
}
scroll={{ x: '100%' }}
loading={isInspectMetricsRefetching}
/>
);
}
export default TableView;

View File

@@ -0,0 +1,166 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import {
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW,
TIME_AGGREGATION_OPTIONS,
} from '../constants';
import ExpandedView from '../ExpandedView';
import {
GraphPopoverData,
InspectionStep,
MetricInspectionOptions,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
describe('ExpandedView', () => {
const mockTimeSeries: InspectMetricsSeries = {
values: [
{ timestamp: 1672531200000, value: '42.123' },
{ timestamp: 1672531260000, value: '43.456' },
{ timestamp: 1672531320000, value: '44.789' },
{ timestamp: 1672531380000, value: '45.012' },
],
labels: {
host_id: 'test-id',
},
labelsArray: [],
title: 'TS1',
};
const mockOptions = {
x: 100,
y: 100,
value: 42.123,
timestamp: 1672531200000,
timeSeries: mockTimeSeries,
};
const mockSpaceAggregationSeriesMap = new Map<string, InspectMetricsSeries[]>([
['host_id:test-id', [mockTimeSeries]],
]);
const mockTimeAggregatedSeriesMap = new Map<number, GraphPopoverData[]>([
[
1672531200000,
[
{
value: '42.123',
type: 'instance',
timestamp: 1672531200000,
title: 'TS1',
},
{
value: '43.456',
type: 'instance',
timestamp: 1672531260000,
title: 'TS1',
},
],
],
]);
const mockMetricInspectionOptions: MetricInspectionOptions = {
timeAggregationOption: TimeAggregationOptions.MAX,
timeAggregationInterval: 60,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
filters: {
items: [],
op: 'AND',
},
};
it('renders entire time series for a raw data inspection', () => {
render(
<ExpandedView
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.TIME_AGGREGATION}
metricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
const graphPopoverCells = screen.getAllByTestId('graph-popover-cell');
expect(graphPopoverCells).toHaveLength(mockTimeSeries.values.length * 2);
expect(screen.getAllByText('42.123')).toHaveLength(2);
});
it('renders correct split data for a time aggregation inspection', () => {
const TIME_AGGREGATION_INTERVAL = 120;
render(
<ExpandedView
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.SPACE_AGGREGATION}
metricInspectionOptions={{
...mockMetricInspectionOptions,
timeAggregationInterval: TIME_AGGREGATION_INTERVAL,
}}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
// time series by default has values at 60 seconds
// by doing time aggregation at 120 seconds, we should have 2 values
const graphPopoverCells = screen.getAllByTestId('graph-popover-cell');
expect(graphPopoverCells).toHaveLength((TIME_AGGREGATION_INTERVAL / 60) * 2);
expect(
screen.getByText(
`42.123 is the ${
TIME_AGGREGATION_OPTIONS[
mockMetricInspectionOptions.timeAggregationOption as TimeAggregationOptions
]
} of`,
),
);
expect(screen.getByText('42.123')).toBeInTheDocument();
expect(screen.getByText('43.456')).toBeInTheDocument();
});
it('renders all child time series for a space aggregation inspection', () => {
render(
<ExpandedView
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.COMPLETED}
metricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
const graphPopoverCells = screen.getAllByTestId('graph-popover-cell');
expect(graphPopoverCells).toHaveLength(
mockSpaceAggregationSeriesMap.size * 2,
);
expect(
screen.getByText(
`42.123 is the ${
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW[
mockMetricInspectionOptions.spaceAggregationOption as SpaceAggregationOptions
]
} of`,
),
).toBeInTheDocument();
expect(screen.getByText('TS1')).toBeInTheDocument();
});
it('renders all labels for the selected time series', () => {
render(
<ExpandedView
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.TIME_AGGREGATION}
metricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
expect(
screen.getByText(`${mockTimeSeries.title} Labels`),
).toBeInTheDocument();
expect(screen.getByText('host_id')).toBeInTheDocument();
expect(screen.getByText('test-id')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,82 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import GraphPopover from '../GraphPopover';
import { GraphPopoverOptions, InspectionStep } from '../types';
describe('GraphPopover', () => {
const mockOptions: GraphPopoverOptions = {
x: 100,
y: 100,
value: 42.123,
timestamp: 1672531200000,
timeSeries: {
values: [
{ timestamp: 1672531200000, value: '42.123' },
{ timestamp: 1672531260000, value: '43.456' },
],
labels: {},
labelsArray: [],
},
};
const mockSpaceAggregationSeriesMap: Map<
string,
InspectMetricsSeries[]
> = new Map();
const mockOpenInExpandedView = jest.fn();
const mockStep = InspectionStep.TIME_AGGREGATION;
it('renders with correct values', () => {
render(
<GraphPopover
options={mockOptions}
popoverRef={{ current: null }}
openInExpandedView={mockOpenInExpandedView}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={mockStep}
/>,
);
// Check value is rendered with 2 decimal places
expect(screen.getByText('42.12')).toBeInTheDocument();
});
it('opens the expanded view when button is clicked', () => {
render(
<GraphPopover
options={mockOptions}
popoverRef={{ current: null }}
openInExpandedView={mockOpenInExpandedView}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={mockStep}
/>,
);
const button = screen.getByText('View details');
fireEvent.click(button);
expect(mockOpenInExpandedView).toHaveBeenCalledTimes(1);
});
it('finds closest timestamp and value from timeSeries', () => {
const optionsWithOffset: GraphPopoverOptions = {
...mockOptions,
timestamp: 1672531230000,
value: 42.24,
};
render(
<GraphPopover
options={optionsWithOffset}
popoverRef={{ current: null }}
openInExpandedView={mockOpenInExpandedView}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={mockStep}
/>,
);
// Should show the closest value
expect(screen.getByText('43.46')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,113 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { Provider } from 'react-redux';
import store from 'store';
import { AlignedData } from 'uplot';
import GraphView from '../GraphView';
import {
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
jest.mock('uplot', () =>
jest.fn().mockImplementation(() => ({
destroy: jest.fn(),
})),
);
const mockResizeObserver = jest.fn();
mockResizeObserver.mockImplementation(() => ({
observe: (): void => undefined,
unobserve: (): void => undefined,
disconnect: (): void => undefined,
}));
window.ResizeObserver = mockResizeObserver;
describe('GraphView', () => {
const mockTimeSeries: InspectMetricsSeries[] = [
{
strokeColor: '#000',
title: 'Series 1',
values: [
{ timestamp: 1234567890000, value: '10' },
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [{ label: 'label1', value: 'value1' }],
},
];
const defaultProps = {
inspectMetricsTimeSeries: mockTimeSeries,
formattedInspectMetricsTimeSeries: [
[1, 2],
[1, 2],
] as AlignedData,
metricUnit: '',
metricName: 'test_metric',
metricType: MetricType.GAUGE,
spaceAggregationSeriesMap: new Map(),
inspectionStep: InspectionStep.COMPLETED,
setPopoverOptions: jest.fn(),
popoverOptions: null,
setShowExpandedView: jest.fn(),
setExpandedViewOptions: jest.fn(),
resetInspection: jest.fn(),
showExpandedView: false,
metricInspectionOptions: {
timeAggregationInterval: 60,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
timeAggregationOption: TimeAggregationOptions.MAX,
filters: {
items: [],
op: 'AND',
},
},
isInspectMetricsRefetching: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders graph view by default', () => {
render(
<Provider store={store}>
<GraphView {...defaultProps} />
</Provider>,
);
expect(screen.getByRole('switch')).toBeInTheDocument();
expect(screen.getByText('Graph View')).toBeInTheDocument();
});
it('switches between graph and table view', async () => {
render(
<Provider store={store}>
<GraphView {...defaultProps} />
</Provider>,
);
const switchButton = screen.getByRole('switch');
expect(screen.getByText('Graph View')).toBeInTheDocument();
await userEvent.click(switchButton);
expect(screen.getByText('Table View')).toBeInTheDocument();
});
it('renders metric name and number of series', () => {
render(
<Provider store={store}>
<GraphView {...defaultProps} />
</Provider>,
);
expect(screen.getByText('test_metric')).toBeInTheDocument();
expect(screen.getByText('1 time series')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,198 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useInspectMetricsHooks from 'hooks/metricsExplorer/useGetInspectMetricsDetails';
import * as useGetMetricDetailsHooks from 'hooks/metricsExplorer/useGetMetricDetails';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import store from 'store';
import ROUTES from '../../../../constants/routes';
import Inspect from '../Inspect';
import { InspectionStep } from '../types';
const queryClient = new QueryClient();
const mockTimeSeries: InspectMetricsSeries[] = [
{
strokeColor: '#000',
title: 'Series 1',
values: [
{ timestamp: 1234567890000, value: '10' },
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [{ label: 'label1', value: 'value1' }],
},
];
jest.spyOn(useGetMetricDetailsHooks, 'useGetMetricDetails').mockReturnValue({
data: {
metricDetails: {
metricName: 'test_metric',
metricType: MetricType.GAUGE,
},
},
} as any);
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: mockTimeSeries,
},
status: 'success',
},
},
isLoading: false,
} as any);
jest.mock('uplot', () =>
jest.fn().mockImplementation(() => ({
destroy: jest.fn(),
})),
);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
}),
}));
const mockResizeObserver = jest.fn();
mockResizeObserver.mockImplementation(() => ({
observe: (): void => undefined,
unobserve: (): void => undefined,
disconnect: (): void => undefined,
}));
window.ResizeObserver = mockResizeObserver;
describe('Inspect', () => {
const defaultProps = {
inspectMetricsTimeSeries: mockTimeSeries,
formattedInspectMetricsTimeSeries: [],
metricUnit: '',
metricName: 'test_metric',
metricType: MetricType.GAUGE,
spaceAggregationSeriesMap: new Map(),
inspectionStep: InspectionStep.COMPLETED,
resetInspection: jest.fn(),
isOpen: true,
onClose: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders all components', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByText('test_metric')).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeInTheDocument(); // Graph/Table view switch
expect(screen.getByText('Query Builder')).toBeInTheDocument();
});
it('renders loading state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: true,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('inspect-metrics-loading')).toBeInTheDocument();
});
it('renders empty state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: false,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('inspect-metrics-empty')).toBeInTheDocument();
});
it('renders error state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: false,
isError: true,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('inspect-metrics-error')).toBeInTheDocument();
});
it('renders error state with 400 status code', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
statusCode: 400,
},
isError: false,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('inspect-metrics-error')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,110 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import store from 'store';
import ROUTES from '../../../../constants/routes';
import QueryBuilder from '../QueryBuilder';
import {
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
}),
}));
const queryClient = new QueryClient();
describe('QueryBuilder', () => {
const defaultProps = {
metricName: 'test_metric',
setMetricName: jest.fn(),
spaceAggregationLabels: ['label1', 'label2'],
metricInspectionOptions: {
timeAggregationInterval: 60,
timeAggregationOption: TimeAggregationOptions.AVG,
spaceAggregationLabels: [],
spaceAggregationOption: SpaceAggregationOptions.AVG_BY,
filters: {
items: [],
op: 'and',
},
},
dispatchMetricInspectionOptions: jest.fn(),
metricType: MetricType.SUM,
inspectionStep: InspectionStep.TIME_AGGREGATION,
inspectMetricsTimeSeries: [],
searchQuery: {
filters: {
items: [],
op: 'and',
},
} as any,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders query builder header', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByText('Query Builder')).toBeInTheDocument();
});
it('renders metric name search component', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('metric-name-search')).toBeInTheDocument();
});
it('renders metric filters component', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('metric-filters')).toBeInTheDocument();
});
it('renders time aggregation component', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('metric-time-aggregation')).toBeInTheDocument();
});
it('renders space aggregation component', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('metric-space-aggregation')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Stepper from '../Stepper';
import { InspectionStep } from '../types';
describe('Stepper', () => {
const mockResetInspection = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders welcome message', () => {
render(
<Stepper
inspectionStep={InspectionStep.TIME_AGGREGATION}
resetInspection={mockResetInspection}
/>,
);
expect(
screen.getByText('👋 Hello, welcome to the Metrics Inspector'),
).toBeInTheDocument();
});
it('shows temporal aggregation step as active when on first step', () => {
render(
<Stepper
inspectionStep={InspectionStep.TIME_AGGREGATION}
resetInspection={mockResetInspection}
/>,
);
const temporalStep = screen.getByText(/First, align the data by selecting a/);
expect(temporalStep.parentElement).toHaveClass('whats-next-checklist-item');
});
it('shows temporal aggregation step as completed when on later steps', () => {
render(
<Stepper
inspectionStep={InspectionStep.SPACE_AGGREGATION}
resetInspection={mockResetInspection}
/>,
);
const temporalStep = screen.getByText(/First, align the data by selecting a/);
expect(temporalStep.parentElement).toHaveClass('completed-checklist-item');
});
it('calls resetInspection when reset button is clicked', async () => {
render(
<Stepper
inspectionStep={InspectionStep.COMPLETED}
resetInspection={mockResetInspection}
/>,
);
const resetButton = screen.getByRole('button');
await userEvent.click(resetButton);
expect(mockResetInspection).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,100 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import TableView from '../TableView';
import {
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
import { formatTimestampToFullDateTime } from '../utils';
describe('TableView', () => {
const mockTimeSeries: InspectMetricsSeries[] = [
{
strokeColor: '#000',
title: 'Series 1',
values: [
{ timestamp: 1234567890000, value: '10' },
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [
{
label: 'label1',
value: 'value1',
},
],
},
{
strokeColor: '#fff',
title: 'Series 2',
values: [
{ timestamp: 1234567890000, value: '30' },
{ timestamp: 1234567891000, value: '40' },
],
labels: { label2: 'value2' },
labelsArray: [
{
label: 'label2',
value: 'value2',
},
],
},
];
const defaultProps = {
inspectionStep: InspectionStep.COMPLETED,
inspectMetricsTimeSeries: mockTimeSeries,
setShowExpandedView: jest.fn(),
setExpandedViewOptions: jest.fn(),
metricInspectionOptions: {
timeAggregationInterval: 60,
timeAggregationOption: TimeAggregationOptions.MAX,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
filters: {
items: [],
op: 'AND',
},
},
isInspectMetricsRefetching: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders table with correct columns', () => {
render(<TableView {...defaultProps} />);
expect(screen.getByText('label1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
expect(screen.getByText('Values')).toBeInTheDocument();
});
it('renders time series titles correctly when inspection is completed', () => {
render(<TableView {...defaultProps} />);
expect(screen.getByText('label1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
});
it('renders time series values in correct format', () => {
render(<TableView {...defaultProps} />);
const formattedValues = mockTimeSeries.map(
(series) =>
series.values.map(
(v) => `(${formatTimestampToFullDateTime(v.timestamp, true)}, ${v.value})`,
)[0],
);
formattedValues.forEach((value) => {
expect(screen.getByText(value, { exact: false })).toBeInTheDocument();
});
});
it('applies correct styling to time series titles', () => {
render(<TableView {...defaultProps} />);
const titles = screen.getByText('value1');
expect(titles).toHaveStyle({ color: mockTimeSeries[0].strokeColor });
});
});

View File

@@ -1 +1,91 @@
import { Color } from '@signozhq/design-tokens';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
LucideProps,
} from 'lucide-react';
import { ForwardRefExoticComponent, RefAttributes } from 'react';
import {
MetricInspectionOptions,
SpaceAggregationOptions,
TimeAggregationOptions,
} from './types';
export const INSPECT_FEATURE_FLAG_KEY = 'metrics-explorer-inspect-feature-flag';
export const METRIC_TYPE_TO_COLOR_MAP: Record<MetricType, string> = {
[MetricType.GAUGE]: Color.BG_SAKURA_500,
[MetricType.HISTOGRAM]: Color.BG_SIENNA_500,
[MetricType.SUM]: Color.BG_ROBIN_500,
[MetricType.SUMMARY]: Color.BG_FOREST_500,
[MetricType.EXPONENTIAL_HISTOGRAM]: Color.BG_AQUA_500,
};
export const METRIC_TYPE_TO_ICON_MAP: Record<
MetricType,
ForwardRefExoticComponent<
Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>
>
> = {
[MetricType.GAUGE]: Gauge,
[MetricType.HISTOGRAM]: BarChart2,
[MetricType.SUM]: Diff,
[MetricType.SUMMARY]: BarChartHorizontal,
[MetricType.EXPONENTIAL_HISTOGRAM]: BarChart,
};
export const TIME_AGGREGATION_OPTIONS: Record<
TimeAggregationOptions,
string
> = {
[TimeAggregationOptions.LATEST]: 'Latest',
[TimeAggregationOptions.SUM]: 'Sum',
[TimeAggregationOptions.AVG]: 'Avg',
[TimeAggregationOptions.MIN]: 'Min',
[TimeAggregationOptions.MAX]: 'Max',
[TimeAggregationOptions.COUNT]: 'Count',
};
export const SPACE_AGGREGATION_OPTIONS: Record<
SpaceAggregationOptions,
string
> = {
[SpaceAggregationOptions.SUM_BY]: 'Sum by',
[SpaceAggregationOptions.MIN_BY]: 'Min by',
[SpaceAggregationOptions.MAX_BY]: 'Max by',
[SpaceAggregationOptions.AVG_BY]: 'Avg by',
};
export const SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW: Record<
SpaceAggregationOptions,
string
> = {
[SpaceAggregationOptions.SUM_BY]: 'Sum',
[SpaceAggregationOptions.MIN_BY]: 'Min',
[SpaceAggregationOptions.MAX_BY]: 'Max',
[SpaceAggregationOptions.AVG_BY]: 'Avg',
};
export const INITIAL_INSPECT_METRICS_OPTIONS: MetricInspectionOptions = {
timeAggregationOption: undefined,
timeAggregationInterval: undefined,
spaceAggregationOption: undefined,
spaceAggregationLabels: [],
filters: {
items: [],
op: 'AND',
},
};
export const TEMPORAL_AGGREGATION_LINK =
'https://signoz.io/docs/metrics-management/types-and-aggregation/#step-2-temporal-aggregation';
export const SPACE_AGGREGATION_LINK =
'https://signoz.io/docs/metrics-management/types-and-aggregation/#step-3-spatial-aggregation';
export const GRAPH_CLICK_PIXEL_TOLERANCE = 10;

View File

@@ -1,5 +1,176 @@
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { AlignedData } from 'uplot';
export type InspectProps = {
metricName: string | null;
isOpen: boolean;
onClose: () => void;
};
export interface UseInspectMetricsReturnData {
inspectMetricsTimeSeries: InspectMetricsSeries[];
inspectMetricsStatusCode: number;
isInspectMetricsLoading: boolean;
isInspectMetricsError: boolean;
formattedInspectMetricsTimeSeries: AlignedData;
spaceAggregationLabels: string[];
metricInspectionOptions: MetricInspectionOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
isInspectMetricsRefetching: boolean;
spaceAggregatedSeriesMap: Map<string, InspectMetricsSeries[]>;
aggregatedTimeSeries: InspectMetricsSeries[];
timeAggregatedSeriesMap: Map<number, GraphPopoverData[]>;
reset: () => void;
}
export interface GraphViewProps {
inspectMetricsTimeSeries: InspectMetricsSeries[];
metricUnit: string | undefined;
metricName: string | null;
metricType?: MetricType | undefined;
formattedInspectMetricsTimeSeries: AlignedData;
resetInspection: () => void;
spaceAggregationSeriesMap: Map<string, InspectMetricsSeries[]>;
inspectionStep: InspectionStep;
setPopoverOptions: (options: GraphPopoverOptions | null) => void;
popoverOptions: GraphPopoverOptions | null;
showExpandedView: boolean;
setShowExpandedView: (showExpandedView: boolean) => void;
setExpandedViewOptions: (options: GraphPopoverOptions | null) => void;
metricInspectionOptions: MetricInspectionOptions;
isInspectMetricsRefetching: boolean;
}
export interface QueryBuilderProps {
metricName: string | null;
setMetricName: (metricName: string) => void;
metricType: MetricType | undefined;
spaceAggregationLabels: string[];
metricInspectionOptions: MetricInspectionOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
inspectMetricsTimeSeries: InspectMetricsSeries[];
searchQuery: IBuilderQuery;
}
export interface MetricNameSearchProps {
metricName: string | null;
setMetricName: (metricName: string) => void;
}
export interface MetricFiltersProps {
searchQuery: IBuilderQuery;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
metricName: string | null;
metricType: MetricType | null;
}
export interface MetricTimeAggregationProps {
metricInspectionOptions: MetricInspectionOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
inspectMetricsTimeSeries: InspectMetricsSeries[];
}
export interface MetricSpaceAggregationProps {
spaceAggregationLabels: string[];
metricInspectionOptions: MetricInspectionOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
}
export enum TimeAggregationOptions {
LATEST = 'latest',
SUM = 'sum',
AVG = 'avg',
MIN = 'min',
MAX = 'max',
COUNT = 'count',
}
export enum SpaceAggregationOptions {
SUM_BY = 'sum_by',
MIN_BY = 'min_by',
MAX_BY = 'max_by',
AVG_BY = 'avg_by',
}
export interface MetricInspectionOptions {
timeAggregationOption: TimeAggregationOptions | undefined;
timeAggregationInterval: number | undefined;
spaceAggregationOption: SpaceAggregationOptions | undefined;
spaceAggregationLabels: string[];
filters: TagFilter;
}
export type MetricInspectionAction =
| { type: 'SET_TIME_AGGREGATION_OPTION'; payload: TimeAggregationOptions }
| { type: 'SET_TIME_AGGREGATION_INTERVAL'; payload: number }
| { type: 'SET_SPACE_AGGREGATION_OPTION'; payload: SpaceAggregationOptions }
| { type: 'SET_SPACE_AGGREGATION_LABELS'; payload: string[] }
| { type: 'SET_FILTERS'; payload: TagFilter }
| { type: 'RESET_INSPECTION' };
export enum InspectionStep {
TIME_AGGREGATION = 1,
SPACE_AGGREGATION = 2,
COMPLETED = 3,
}
export interface StepperProps {
inspectionStep: InspectionStep;
resetInspection: () => void;
}
export interface GraphPopoverOptions {
x: number;
y: number;
value: number;
timestamp: number;
timeSeries: InspectMetricsSeries | undefined;
}
export interface GraphPopoverProps {
spaceAggregationSeriesMap: Map<string, InspectMetricsSeries[]>;
options: GraphPopoverOptions | null;
popoverRef: React.RefObject<HTMLDivElement>;
step: InspectionStep;
openInExpandedView: () => void;
}
export interface GraphPopoverData {
timestamp?: number;
value: string;
title?: string;
type: 'instance' | 'aggregated';
timeSeries?: InspectMetricsSeries;
}
export interface ExpandedViewProps {
options: GraphPopoverOptions | null;
spaceAggregationSeriesMap: Map<string, InspectMetricsSeries[]>;
step: InspectionStep;
metricInspectionOptions: MetricInspectionOptions;
timeAggregatedSeriesMap: Map<number, GraphPopoverData[]>;
}
export interface TableViewProps {
inspectionStep: InspectionStep;
inspectMetricsTimeSeries: InspectMetricsSeries[];
setShowExpandedView: (showExpandedView: boolean) => void;
setExpandedViewOptions: (options: GraphPopoverOptions | null) => void;
metricInspectionOptions: MetricInspectionOptions;
isInspectMetricsRefetching: boolean;
}
export interface TableViewDataItem {
title: JSX.Element;
values: JSX.Element;
key: number;
}

View File

@@ -0,0 +1,226 @@
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { themeColors } from 'constants/theme';
import { useGetInspectMetricsDetails } from 'hooks/metricsExplorer/useGetInspectMetricsDetails';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { INITIAL_INSPECT_METRICS_OPTIONS } from './constants';
import {
GraphPopoverData,
InspectionStep,
MetricInspectionAction,
MetricInspectionOptions,
UseInspectMetricsReturnData,
} from './types';
import {
applySpaceAggregation,
applyTimeAggregation,
getAllTimestampsOfMetrics,
} from './utils';
const metricInspectionReducer = (
state: MetricInspectionOptions,
action: MetricInspectionAction,
): MetricInspectionOptions => {
switch (action.type) {
case 'SET_TIME_AGGREGATION_OPTION':
return {
...state,
timeAggregationOption: action.payload,
};
case 'SET_TIME_AGGREGATION_INTERVAL':
return {
...state,
timeAggregationInterval: action.payload,
};
case 'SET_SPACE_AGGREGATION_OPTION':
return {
...state,
spaceAggregationOption: action.payload,
};
case 'SET_SPACE_AGGREGATION_LABELS':
return {
...state,
spaceAggregationLabels: action.payload,
};
case 'SET_FILTERS':
return {
...state,
filters: action.payload,
};
case 'RESET_INSPECTION':
return { ...INITIAL_INSPECT_METRICS_OPTIONS };
default:
return state;
}
};
export function useInspectMetrics(
metricName: string | null,
): UseInspectMetricsReturnData {
// Inspect Metrics API Call and data formatting
const { start, end } = useMemo(() => {
const now = Date.now();
return {
start: now - 30 * 60 * 1000, // 30 minutes ago
end: now, // now
};
}, []);
// Inspect metrics data selection
const [metricInspectionOptions, dispatchMetricInspectionOptions] = useReducer(
metricInspectionReducer,
INITIAL_INSPECT_METRICS_OPTIONS,
);
const {
data: inspectMetricsData,
isLoading: isInspectMetricsLoading,
isError: isInspectMetricsError,
isRefetching: isInspectMetricsRefetching,
} = useGetInspectMetricsDetails(
{
metricName: metricName ?? '',
start,
end,
filters: metricInspectionOptions.filters,
},
{
enabled: !!metricName,
keepPreviousData: true,
},
);
const isDarkMode = useIsDarkMode();
const inspectMetricsTimeSeries = useMemo(() => {
const series = inspectMetricsData?.payload?.data?.series ?? [];
return series.map((series, index) => {
const title = `TS${index + 1}`;
const strokeColor = generateColor(
title,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
return {
...series,
values: [...series.values].sort((a, b) => a.timestamp - b.timestamp),
title,
strokeColor,
};
});
}, [inspectMetricsData, isDarkMode]);
const inspectMetricsStatusCode = useMemo(
() => inspectMetricsData?.statusCode || 200,
[inspectMetricsData],
);
// Evaluate inspection step
const inspectionStep = useMemo(() => {
if (metricInspectionOptions.spaceAggregationOption) {
return InspectionStep.COMPLETED;
}
if (
metricInspectionOptions.timeAggregationOption &&
metricInspectionOptions.timeAggregationInterval
) {
return InspectionStep.SPACE_AGGREGATION;
}
return InspectionStep.TIME_AGGREGATION;
}, [metricInspectionOptions]);
const [spaceAggregatedSeriesMap, setSpaceAggregatedSeriesMap] = useState<
Map<string, InspectMetricsSeries[]>
>(new Map());
const [timeAggregatedSeriesMap, setTimeAggregatedSeriesMap] = useState<
Map<number, GraphPopoverData[]>
>(new Map());
const [aggregatedTimeSeries, setAggregatedTimeSeries] = useState<
InspectMetricsSeries[]
>(inspectMetricsTimeSeries);
useEffect(() => {
setAggregatedTimeSeries(inspectMetricsTimeSeries);
}, [inspectMetricsTimeSeries]);
const formattedInspectMetricsTimeSeries = useMemo(() => {
let timeSeries: InspectMetricsSeries[] = [...inspectMetricsTimeSeries];
// Apply time aggregation once required options are set
if (
inspectionStep >= InspectionStep.SPACE_AGGREGATION &&
metricInspectionOptions.timeAggregationOption &&
metricInspectionOptions.timeAggregationInterval
) {
const {
timeAggregatedSeries,
timeAggregatedSeriesMap,
} = applyTimeAggregation(inspectMetricsTimeSeries, metricInspectionOptions);
timeSeries = timeAggregatedSeries;
setTimeAggregatedSeriesMap(timeAggregatedSeriesMap);
setAggregatedTimeSeries(timeSeries);
}
// Apply space aggregation
if (inspectionStep === InspectionStep.COMPLETED) {
const { aggregatedSeries, spaceAggregatedSeriesMap } = applySpaceAggregation(
timeSeries,
metricInspectionOptions,
);
timeSeries = aggregatedSeries;
setSpaceAggregatedSeriesMap(spaceAggregatedSeriesMap);
setAggregatedTimeSeries(aggregatedSeries);
}
const timestamps = getAllTimestampsOfMetrics(timeSeries);
const timeseriesArray = timeSeries.map((series) => {
const valuesMap = new Map<number, number>();
series.values.forEach(({ timestamp, value }) => {
valuesMap.set(timestamp, parseFloat(value));
});
return timestamps.map((timestamp) => valuesMap.get(timestamp) ?? NaN);
});
const rawData = [timestamps, ...timeseriesArray];
return rawData.map((series) => new Float64Array(series));
}, [inspectMetricsTimeSeries, inspectionStep, metricInspectionOptions]);
const spaceAggregationLabels = useMemo(() => {
const labels = new Set<string>();
inspectMetricsData?.payload?.data.series.forEach((series) => {
Object.keys(series.labels).forEach((label) => {
labels.add(label);
});
});
return Array.from(labels);
}, [inspectMetricsData]);
const reset = useCallback(() => {
dispatchMetricInspectionOptions({
type: 'RESET_INSPECTION',
});
setSpaceAggregatedSeriesMap(new Map());
setTimeAggregatedSeriesMap(new Map());
setAggregatedTimeSeries(inspectMetricsTimeSeries);
}, [dispatchMetricInspectionOptions, inspectMetricsTimeSeries]);
return {
inspectMetricsTimeSeries,
inspectMetricsStatusCode,
isInspectMetricsLoading,
isInspectMetricsError,
formattedInspectMetricsTimeSeries,
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
isInspectMetricsRefetching,
spaceAggregatedSeriesMap,
aggregatedTimeSeries,
timeAggregatedSeriesMap,
reset,
};
}

View File

@@ -1,11 +1,827 @@
import { INSPECT_FEATURE_FLAG_KEY } from './constants';
/* eslint-disable no-nested-ternary */
import { Card, Input, Select, Typography } from 'antd';
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';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { HardHat } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
SPACE_AGGREGATION_OPTIONS,
TIME_AGGREGATION_OPTIONS,
} from './constants';
import {
GraphPopoverData,
GraphPopoverOptions,
InspectionStep,
MetricFiltersProps,
MetricInspectionOptions,
MetricNameSearchProps,
MetricSpaceAggregationProps,
MetricTimeAggregationProps,
SpaceAggregationOptions,
TimeAggregationOptions,
} from './types';
/**
* Check if the inspect feature flag is enabled
* 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(): boolean {
const featureFlag = localStorage.getItem(INSPECT_FEATURE_FLAG_KEY);
return featureFlag === 'true';
export function isInspectEnabled(metricType: MetricType | undefined): boolean {
return metricType === MetricType.GAUGE;
}
export function getAllTimestampsOfMetrics(
inspectMetricsTimeSeries: InspectMetricsSeries[],
): number[] {
return Array.from(
new Set(
inspectMetricsTimeSeries
.flatMap((series) => series.values.map((value) => value.timestamp))
.sort((a, b) => a - b),
),
);
}
export function getDefaultTimeAggregationInterval(
timeSeries: InspectMetricsSeries | undefined,
): number {
if (!timeSeries) {
return 60;
}
const reportingInterval =
timeSeries.values.length > 1
? Math.abs(timeSeries.values[1].timestamp - timeSeries.values[0].timestamp) /
1000
: 0;
return Math.max(60, reportingInterval);
}
export function MetricNameSearch({
metricName,
setMetricName,
}: MetricNameSearchProps): JSX.Element {
const [searchText, setSearchText] = useState(metricName);
const handleSetMetricName = (value: BaseAutocompleteData): void => {
setMetricName(value.key);
};
const handleChange = (value: BaseAutocompleteData): void => {
setSearchText(value.key);
};
return (
<div
data-testid="metric-name-search"
className="inspect-metrics-input-group metric-name-search"
>
<Typography.Text>From</Typography.Text>
<AggregatorFilter
defaultValue={searchText ?? ''}
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
onSelect={handleSetMetricName}
onChange={handleChange}
/>
</div>
);
}
export function MetricFilters({
dispatchMetricInspectionOptions,
searchQuery,
metricName,
metricType,
}: MetricFiltersProps): JSX.Element {
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: searchQuery,
entityVersion: '',
});
const aggregateAttribute = useMemo(
() => ({
key: metricName ?? '',
dataType: DataTypes.String,
type: metricType,
isColumn: true,
isJSON: false,
id: `${metricName}--${DataTypes.String}--${metricType}--true`,
}),
[metricName, metricType],
);
return (
<div
data-testid="metric-filters"
className="inspect-metrics-input-group metric-filters"
>
<Typography.Text>Where</Typography.Text>
<QueryBuilderSearch
query={{
...searchQuery,
aggregateAttribute,
}}
onChange={(value): void => {
handleChangeQueryData('filters', value);
dispatchMetricInspectionOptions({
type: 'SET_FILTERS',
payload: value,
});
}}
suffixIcon={<HardHat size={16} />}
disableNavigationShortcuts
/>
</div>
);
}
export function MetricTimeAggregation({
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
inspectMetricsTimeSeries,
}: MetricTimeAggregationProps): JSX.Element {
return (
<div
data-testid="metric-time-aggregation"
className="metric-time-aggregation"
>
<div
className={classNames('metric-time-aggregation-header', {
'selected-step': inspectionStep === InspectionStep.TIME_AGGREGATION,
})}
>
<Typography.Text>AGGREGATE BY TIME</Typography.Text>
</div>
<div className="metric-time-aggregation-content">
<div className="inspect-metrics-input-group">
<Typography.Text>Align with</Typography.Text>
<Select
value={metricInspectionOptions.timeAggregationOption}
onChange={(value): void => {
dispatchMetricInspectionOptions({
type: 'SET_TIME_AGGREGATION_OPTION',
payload: value,
});
// set the time aggregation interval to the default value if it is not set
if (!metricInspectionOptions.timeAggregationInterval) {
dispatchMetricInspectionOptions({
type: 'SET_TIME_AGGREGATION_INTERVAL',
payload: getDefaultTimeAggregationInterval(
inspectMetricsTimeSeries[0],
),
});
}
}}
style={{ width: 130 }}
placeholder="Select option"
>
{Object.entries(TIME_AGGREGATION_OPTIONS).map(([key, value]) => (
<Select.Option key={key} value={key}>
{value}
</Select.Option>
))}
</Select>
</div>
<div className="inspect-metrics-input-group">
<Typography.Text>aggregated every</Typography.Text>
<Input
type="number"
className="no-arrows-input"
value={metricInspectionOptions.timeAggregationInterval}
placeholder="Select interval..."
suffix="seconds"
onChange={(e): void => {
dispatchMetricInspectionOptions({
type: 'SET_TIME_AGGREGATION_INTERVAL',
payload: parseInt(e.target.value, 10),
});
}}
onWheel={(e): void => (e.target as HTMLInputElement).blur()}
/>
</div>
</div>
</div>
);
}
export function MetricSpaceAggregation({
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
}: MetricSpaceAggregationProps): JSX.Element {
return (
<div
data-testid="metric-space-aggregation"
className="metric-space-aggregation"
>
<div
className={classNames('metric-space-aggregation-header', {
'selected-step': inspectionStep === InspectionStep.SPACE_AGGREGATION,
})}
>
<Typography.Text>AGGREGATE BY LABELS</Typography.Text>
</div>
<div className="metric-space-aggregation-content">
<div className="metric-space-aggregation-content-left">
<Select
value={metricInspectionOptions.spaceAggregationOption}
placeholder="Select option"
onChange={(value): void => {
dispatchMetricInspectionOptions({
type: 'SET_SPACE_AGGREGATION_OPTION',
payload: value,
});
}}
style={{ width: 130 }}
disabled={inspectionStep === InspectionStep.TIME_AGGREGATION}
>
{/* eslint-disable-next-line sonarjs/no-identical-functions */}
{Object.entries(SPACE_AGGREGATION_OPTIONS).map(([key, value]) => (
<Select.Option key={key} value={key}>
{value}
</Select.Option>
))}
</Select>
</div>
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="Search for attributes..."
value={metricInspectionOptions.spaceAggregationLabels}
onChange={(value): void => {
dispatchMetricInspectionOptions({
type: 'SET_SPACE_AGGREGATION_LABELS',
payload: value,
});
}}
disabled={inspectionStep === InspectionStep.TIME_AGGREGATION}
>
{spaceAggregationLabels.map((label) => (
<Select.Option key={label} value={label}>
{label}
</Select.Option>
))}
</Select>
</div>
</div>
);
}
export function applyFilters(
inspectMetricsTimeSeries: InspectMetricsSeries[],
filters: TagFilter,
): InspectMetricsSeries[] {
return inspectMetricsTimeSeries.filter((series) =>
filters.items.every((filter) => {
if ((filter.key?.key || '') in series.labels) {
const value = series.labels[filter.key?.key ?? ''];
switch (filter.op) {
case '=':
return value === filter.value;
case '!=':
return value !== filter.value;
case 'in':
return (filter.value as string[]).includes(value as string);
case 'nin':
return !(filter.value as string[]).includes(value as string);
case 'like':
return value.includes(filter.value as string);
case 'nlike':
return !value.includes(filter.value as string);
case 'contains':
return value.includes(filter.value as string);
case 'ncontains':
return !value.includes(filter.value as string);
default:
return true;
}
}
return false;
}),
);
}
export function applyTimeAggregation(
inspectMetricsTimeSeries: InspectMetricsSeries[],
metricInspectionOptions: MetricInspectionOptions,
): {
timeAggregatedSeries: InspectMetricsSeries[];
timeAggregatedSeriesMap: Map<number, GraphPopoverData[]>;
} {
const {
timeAggregationOption,
timeAggregationInterval,
} = metricInspectionOptions;
if (!timeAggregationInterval) {
return {
timeAggregatedSeries: inspectMetricsTimeSeries,
timeAggregatedSeriesMap: new Map(),
};
}
// Group timestamps into intervals and aggregate values for each series independently
const timeAggregatedSeriesMap: Map<number, GraphPopoverData[]> = new Map();
const timeAggregatedSeries: InspectMetricsSeries[] = inspectMetricsTimeSeries.map(
(series) => {
const groupedTimestamps = new Map<number, number[]>();
series.values.forEach(({ timestamp, value }) => {
const intervalBucket =
Math.floor(timestamp / (timeAggregationInterval * 1000)) *
(timeAggregationInterval * 1000);
if (!groupedTimestamps.has(intervalBucket)) {
groupedTimestamps.set(intervalBucket, []);
}
if (!timeAggregatedSeriesMap.has(intervalBucket)) {
timeAggregatedSeriesMap.set(intervalBucket, []);
}
groupedTimestamps.get(intervalBucket)?.push(parseFloat(value));
timeAggregatedSeriesMap.get(intervalBucket)?.push({
timestamp,
value,
type: 'instance',
title: series.title,
timeSeries: series,
});
});
const aggregatedValues = Array.from(groupedTimestamps.entries()).map(
([intervalStart, values]) => {
let aggregatedValue: number;
switch (timeAggregationOption) {
case TimeAggregationOptions.LATEST:
aggregatedValue = values[values.length - 1];
break;
case TimeAggregationOptions.SUM:
aggregatedValue = values.reduce((sum, val) => sum + val, 0);
break;
case TimeAggregationOptions.AVG:
aggregatedValue =
values.reduce((sum, val) => sum + val, 0) / values.length;
break;
case TimeAggregationOptions.MIN:
aggregatedValue = Math.min(...values);
break;
case TimeAggregationOptions.MAX:
aggregatedValue = Math.max(...values);
break;
case TimeAggregationOptions.COUNT:
aggregatedValue = values.length;
break;
default:
aggregatedValue = values[values.length - 1];
}
return {
timestamp: intervalStart,
value: aggregatedValue.toString(),
};
},
);
return {
...series,
values: aggregatedValues,
};
},
);
return { timeAggregatedSeries, timeAggregatedSeriesMap };
}
export function applySpaceAggregation(
inspectMetricsTimeSeries: InspectMetricsSeries[],
metricInspectionOptions: MetricInspectionOptions,
): {
aggregatedSeries: InspectMetricsSeries[];
spaceAggregatedSeriesMap: Map<string, InspectMetricsSeries[]>;
} {
// Group series by selected space aggregation labels
const groupedSeries = new Map<string, InspectMetricsSeries[]>();
inspectMetricsTimeSeries.forEach((series) => {
// Create composite key from selected labels
const key = metricInspectionOptions.spaceAggregationLabels
.map((label) => `${label}:${series.labels[label]}`)
.join(',');
if (!groupedSeries.has(key)) {
groupedSeries.set(key, []);
}
groupedSeries.get(key)?.push(series);
});
// Aggregate each group based on space aggregation option
const aggregatedSeries: InspectMetricsSeries[] = [];
groupedSeries.forEach((seriesGroup, key) => {
// Get the first series to use as template for labels and timestamps
const templateSeries = seriesGroup[0];
// Create a map of timestamp to array of values across all series in group
const timestampValuesMap = new Map<number, number[]>();
// Collect values for each timestamp across all series
seriesGroup.forEach((series) => {
series.values.forEach(({ timestamp, value }) => {
if (!timestampValuesMap.has(timestamp)) {
timestampValuesMap.set(timestamp, []);
}
timestampValuesMap.get(timestamp)?.push(parseFloat(value));
});
});
// Aggregate values based on selected space aggregation option
const aggregatedValues = Array.from(timestampValuesMap.entries()).map(
([timestamp, values]) => {
let aggregatedValue: number;
switch (metricInspectionOptions.spaceAggregationOption) {
case SpaceAggregationOptions.SUM_BY:
aggregatedValue = values.reduce((sum, val) => sum + val, 0);
break;
case SpaceAggregationOptions.AVG_BY:
aggregatedValue =
values.reduce((sum, val) => sum + val, 0) / values.length;
break;
case SpaceAggregationOptions.MIN_BY:
aggregatedValue = Math.min(...values);
break;
case SpaceAggregationOptions.MAX_BY:
aggregatedValue = Math.max(...values);
break;
default:
// eslint-disable-next-line prefer-destructuring
aggregatedValue = values[0];
}
return {
timestamp,
value: (aggregatedValue || 0).toString(),
};
},
);
// Create aggregated series with original labels
aggregatedSeries.push({
...templateSeries,
values: aggregatedValues.sort((a, b) => a.timestamp - b.timestamp),
title: key.split(',').join(' '),
});
});
return {
aggregatedSeries,
spaceAggregatedSeriesMap: groupedSeries,
};
}
export function getSeriesIndexFromPixel(
e: MouseEvent,
u: uPlot,
formattedInspectMetricsTimeSeries: uPlot.AlignedData,
): number {
const bbox = u.over.getBoundingClientRect(); // plot area only
const left = e.clientX - bbox.left;
const top = e.clientY - bbox.top;
const timestampIndex = u.posToIdx(left);
let seriesIndex = -1;
let closestPixelDiff = Infinity;
for (let i = 1; i < formattedInspectMetricsTimeSeries.length; i++) {
const series = formattedInspectMetricsTimeSeries[i];
const seriesValue = series[timestampIndex];
if (
seriesValue !== undefined &&
seriesValue !== null &&
!Number.isNaN(seriesValue)
) {
const seriesYPx = u.valToPos(seriesValue, 'y');
const pixelDiff = Math.abs(seriesYPx - top);
if (pixelDiff < closestPixelDiff) {
closestPixelDiff = pixelDiff;
seriesIndex = i;
}
}
}
return seriesIndex;
}
export function onGraphClick(
e: MouseEvent,
u: uPlot,
popoverRef: React.RefObject<HTMLDivElement>,
setPopoverOptions: (options: GraphPopoverOptions | null) => void,
inspectMetricsTimeSeries: InspectMetricsSeries[],
showPopover: boolean,
setShowPopover: (showPopover: boolean) => void,
formattedInspectMetricsTimeSeries: uPlot.AlignedData,
): void {
if (popoverRef.current && popoverRef.current.contains(e.target as Node)) {
// Clicked inside the popover, don't close
return;
}
// If popover is already open, close it
if (showPopover) {
setShowPopover(false);
return;
}
// Get which series the user clicked on
// If no series is clicked, return
const seriesIndex = getSeriesIndexFromPixel(
e,
u,
formattedInspectMetricsTimeSeries,
);
if (seriesIndex <= 0) return;
const series = inspectMetricsTimeSeries[seriesIndex - 1];
const { left } = u.over.getBoundingClientRect();
const x = e.clientX - left;
const xVal = u.posToVal(x, 'x'); // Get actual x-axis value
const closestPoint = series?.values.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.timestamp - xVal);
const currDiff = Math.abs(curr.timestamp - xVal);
return prevDiff < currDiff ? prev : curr;
});
setPopoverOptions({
x: e.clientX,
y: e.clientY,
value: parseFloat(closestPoint?.value ?? '0'),
timestamp: closestPoint?.timestamp,
timeSeries: series,
});
setShowPopover(true);
}
export function getRawDataFromTimeSeries(
timeSeries: InspectMetricsSeries,
timestamp: number,
showAll = false,
): GraphPopoverData[] {
if (showAll) {
return timeSeries.values.map((value) => ({
timestamp: value.timestamp,
type: 'instance',
value: value.value,
title: timeSeries.title,
}));
}
const timestampIndex = timeSeries.values.findIndex(
(value) => value.timestamp >= timestamp,
);
const timestamps = [];
if (timestampIndex !== undefined) {
for (
let i = Math.max(0, timestampIndex - 2);
i <= Math.min((timeSeries?.values?.length ?? 0) - 1, timestampIndex + 2);
i++
) {
timestamps.push(timeSeries?.values?.[i]);
}
}
return timestamps.map((timestamp) => ({
timestamp: timestamp.timestamp,
type: 'instance',
value: timestamp.value,
title: timeSeries.title,
}));
}
export function getSpaceAggregatedDataFromTimeSeries(
timeSeries: InspectMetricsSeries,
spaceAggregatedSeriesMap: Map<string, InspectMetricsSeries[]>,
timestamp: number,
showAll = false,
): GraphPopoverData[] {
if (spaceAggregatedSeriesMap.size === 0) {
return [];
}
const appliedLabels =
Array.from(spaceAggregatedSeriesMap.keys())[0]
?.split(',')
.map((label) => label.split(':')[0]) || [];
let matchingSeries: InspectMetricsSeries[] = [];
spaceAggregatedSeriesMap.forEach((series) => {
let isMatching = true;
appliedLabels.forEach((label) => {
if (timeSeries.labels[label] !== series[0].labels[label]) {
isMatching = false;
}
});
if (isMatching) {
matchingSeries = series;
}
});
return matchingSeries
.slice(0, showAll ? matchingSeries.length : 5)
.map((series) => {
const timestampIndex = series.values.findIndex(
(value) => value.timestamp >= timestamp,
);
const value = series.values[timestampIndex]?.value;
return {
timeseries: Object.entries(series.labels)
.map(([key, value]) => `${key}:${value}`)
.join(','),
type: 'aggregated',
value: value ?? '-',
title: series.title,
timeSeries: series,
};
});
}
export const formatTimestampToFullDateTime = (
timestamp: string | number,
returnOnlyTime = false,
): string => {
const date = new Date(Number(timestamp));
const datePart = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const timePart = date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
if (returnOnlyTime) {
return timePart;
}
return `${datePart}${timePart}`;
};
export function getTimeSeriesLabel(
timeSeries: InspectMetricsSeries | null,
textColor: string | undefined,
): JSX.Element {
return (
<>
{Object.entries(timeSeries?.labels ?? {}).map(([key, value]) => (
<span key={key}>
<Typography.Text style={{ color: textColor, fontWeight: 600 }}>
{key}
</Typography.Text>
: {value}{' '}
</span>
))}
</>
);
}
export function HoverPopover({
options,
step,
metricInspectionOptions,
}: {
options: GraphPopoverOptions;
step: InspectionStep;
metricInspectionOptions: MetricInspectionOptions;
}): JSX.Element {
const closestTimestamp = useMemo(() => {
if (!options.timeSeries) {
return options.timestamp;
}
return options.timeSeries?.values.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.timestamp - options.timestamp);
const currDiff = Math.abs(curr.timestamp - options.timestamp);
return prevDiff < currDiff ? prev : curr;
}).timestamp;
}, [options.timeSeries, options.timestamp]);
const closestValue = useMemo(() => {
if (!options.timeSeries) {
return options.value;
}
const index = options.timeSeries.values.findIndex(
(value) => value.timestamp === closestTimestamp,
);
return index !== undefined && index >= 0
? options.timeSeries?.values[index].value
: null;
}, [options.timeSeries, closestTimestamp, options.value]);
const title = useMemo(() => {
if (
step === InspectionStep.COMPLETED &&
metricInspectionOptions.spaceAggregationLabels.length === 0
) {
return undefined;
}
if (step === InspectionStep.COMPLETED && options.timeSeries?.title) {
return options.timeSeries.title;
}
if (!options.timeSeries) {
return undefined;
}
return getTimeSeriesLabel(
options.timeSeries,
options.timeSeries?.strokeColor,
);
}, [step, options.timeSeries, metricInspectionOptions]);
return (
<Card
className="hover-popover-card"
style={{
top: options.y + 10,
left: options.x + 10,
}}
>
<div className="hover-popover-row">
<Typography.Text>
{formatTimestampToFullDateTime(closestTimestamp ?? 0)}
</Typography.Text>
<Typography.Text>{Number(closestValue).toFixed(2)}</Typography.Text>
</div>
{options.timeSeries && (
<Typography.Text
style={{
color: options.timeSeries?.strokeColor,
fontWeight: 200,
}}
>
{title}
</Typography.Text>
)}
</Card>
);
}
export function onGraphHover(
e: MouseEvent,
u: uPlot,
setPopoverOptions: (options: GraphPopoverOptions | null) => void,
inspectMetricsTimeSeries: InspectMetricsSeries[],
formattedInspectMetricsTimeSeries: uPlot.AlignedData,
): void {
const { left, top } = u.over.getBoundingClientRect();
const x = e.clientX - left;
const y = e.clientY - top;
const xVal = u.posToVal(x, 'x'); // Get actual x-axis value
const yVal = u.posToVal(y, 'y'); // Get actual y-axis value value (metric value)
// Get which series the user clicked on
const seriesIndex = getSeriesIndexFromPixel(
e,
u,
formattedInspectMetricsTimeSeries,
);
if (seriesIndex === -1) {
setPopoverOptions({
x: e.clientX,
y: e.clientY,
value: yVal,
timestamp: xVal,
timeSeries: undefined,
});
return;
}
const series = inspectMetricsTimeSeries[seriesIndex - 1];
setPopoverOptions({
x: e.clientX,
y: e.clientY,
value: yVal,
timestamp: xVal,
timeSeries: series,
});
}

View File

@@ -53,7 +53,10 @@ function MetricDetails({
return formatTimestampToReadableDate(metric.lastReceived);
}, [metric]);
const showInspectFeature = useMemo(() => isInspectEnabled(), []);
const showInspectFeature = useMemo(
() => isInspectEnabled(metric?.metadata?.metric_type),
[metric],
);
const isMetricDetailsLoading = isLoading || isFetching;

View File

@@ -120,7 +120,7 @@ function Summary(): JSX.Element {
isFetching: isMetricsFetching,
isError: isMetricsError,
} = useGetMetricsList(metricsListQuery, {
enabled: !!metricsListQuery,
enabled: !!metricsListQuery && !isInspectModalOpen,
});
const {
@@ -129,7 +129,7 @@ function Summary(): JSX.Element {
isFetching: isTreeMapFetching,
isError: isTreeMapError,
} = useGetMetricsTreeMap(metricsTreemapQuery, {
enabled: !!metricsTreemapQuery,
enabled: !!metricsTreemapQuery && !isInspectModalOpen,
});
const handleFilterChange = useCallback(
@@ -188,6 +188,10 @@ function Summary(): JSX.Element {
};
const closeInspectModal = (): void => {
handleChangeQueryData('filters', {
items: [],
op: 'AND',
});
setIsInspectModalOpen(false);
setSelectedMetricName(null);
};

View File

@@ -0,0 +1,188 @@
import { Color } from '@signozhq/design-tokens';
import { render } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { TreemapViewType } from '../types';
import {
formatDataForMetricsTable,
metricsTableColumns,
MetricTypeRenderer,
} from '../utils';
describe('metricsTableColumns', () => {
it('should have correct column definitions', () => {
expect(metricsTableColumns).toHaveLength(6);
// Metric Name column
expect(metricsTableColumns[0].dataIndex).toBe('metric_name');
expect(metricsTableColumns[0].width).toBe(400);
expect(metricsTableColumns[0].sorter).toBe(false);
// Description column
expect(metricsTableColumns[1].dataIndex).toBe('description');
expect(metricsTableColumns[1].width).toBe(400);
// Type column
expect(metricsTableColumns[2].dataIndex).toBe('metric_type');
expect(metricsTableColumns[2].width).toBe(150);
expect(metricsTableColumns[2].sorter).toBe(false);
// Unit column
expect(metricsTableColumns[3].dataIndex).toBe('unit');
expect(metricsTableColumns[3].width).toBe(150);
// Samples column
expect(metricsTableColumns[4].dataIndex).toBe(TreemapViewType.SAMPLES);
expect(metricsTableColumns[4].width).toBe(150);
expect(metricsTableColumns[4].sorter).toBe(true);
// Time Series column
expect(metricsTableColumns[5].dataIndex).toBe(TreemapViewType.TIMESERIES);
expect(metricsTableColumns[5].width).toBe(150);
expect(metricsTableColumns[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();
});
});
});
describe('formatDataForMetricsTable', () => {
it('should format metrics data correctly', () => {
const mockData = [
{
metric_name: 'test_metric',
description: 'Test description',
type: MetricType.GAUGE,
unit: 'bytes',
[TreemapViewType.SAMPLES]: 1000,
[TreemapViewType.TIMESERIES]: 2000,
lastReceived: '2023-01-01T00:00:00Z',
},
];
const result = formatDataForMetricsTable(mockData);
expect(result).toHaveLength(1);
expect(result[0].key).toBe('test_metric');
// Verify metric name rendering
const metricNameElement = result[0].metric_name as JSX.Element;
const { container: metricNameWrapper } = render(metricNameElement);
expect(metricNameWrapper.textContent).toBe('test_metric');
// Verify description rendering
const descriptionElement = result[0].description as JSX.Element;
const { container: descriptionWrapper } = render(descriptionElement);
expect(descriptionWrapper.textContent).toBe('Test description');
expect(descriptionWrapper.querySelector('.description-tooltip')).toBeTruthy();
// Verify metric type rendering
const metricTypeElement = result[0].metric_type as JSX.Element;
const { container: metricTypeWrapper } = render(metricTypeElement);
expect(metricTypeWrapper.querySelector('.metric-type-renderer')).toBeTruthy();
// Verify unit rendering
const unitElement = result[0].unit as JSX.Element;
const { container: unitWrapper } = render(unitElement);
expect(unitWrapper.textContent).toBe('bytes');
// Verify samples rendering
const samplesElement = result[0][TreemapViewType.SAMPLES] as JSX.Element;
const { container: samplesWrapper } = render(samplesElement);
expect(samplesWrapper.textContent).toBe('1K+');
// Verify timeseries rendering
const timeseriesElement = result[0][
TreemapViewType.TIMESERIES
] as JSX.Element;
const { container: timeseriesWrapper } = render(timeseriesElement);
expect(timeseriesWrapper.textContent).toBe('2K+');
});
it('should handle empty/null values', () => {
const mockData = [
{
metric_name: 'test-metric',
description: 'test-description',
type: MetricType.GAUGE,
unit: 'ms',
[TreemapViewType.SAMPLES]: 0,
[TreemapViewType.TIMESERIES]: 0,
lastReceived: '2023-01-01T00:00:00Z',
},
];
const result = formatDataForMetricsTable(mockData);
// Verify empty metric name rendering
const metricNameElement = result[0].metric_name as JSX.Element;
const { container: metricNameWrapper } = render(metricNameElement);
expect(metricNameWrapper.textContent).toBe('test-metric');
// Verify null description rendering
const descriptionElement = result[0].description as JSX.Element;
const { container: descriptionWrapper } = render(descriptionElement);
expect(descriptionWrapper.textContent).toBe('test-description');
// Verify null unit rendering
const unitElement = result[0].unit as JSX.Element;
const { container: unitWrapper } = render(unitElement);
expect(unitWrapper.textContent).toBe('ms');
// Verify zero samples rendering
const samplesElement = result[0][TreemapViewType.SAMPLES] as JSX.Element;
const { container: samplesWrapper } = render(samplesElement);
expect(samplesWrapper.textContent).toBe('-');
// Verify zero timeseries rendering
const timeseriesElement = result[0][
TreemapViewType.TIMESERIES
] as JSX.Element;
const { container: timeseriesWrapper } = render(timeseriesElement);
expect(timeseriesWrapper.textContent).toBe('-');
});
});

View File

@@ -44,9 +44,7 @@ export const metricsTableColumns: ColumnType<MetricsListItemRowData>[] = [
dataIndex: 'description',
width: 400,
render: (value: string): React.ReactNode => (
<Tooltip title={value}>
<div className="metric-description-column-value">{value}</div>
</Tooltip>
<div className="metric-description-column-value">{value}</div>
),
},
{
@@ -154,12 +152,17 @@ function ValidateRowValueWrapper({
return <div>{children}</div>;
}
export const formatNumberIntoHumanReadableFormat = (num: number): string => {
export const formatNumberIntoHumanReadableFormat = (
num: number,
addPlusSign = true,
): string => {
function format(num: number, divisor: number, suffix: string): string {
const value = num / divisor;
return value % 1 === 0
? `${value}${suffix}+`
: `${value.toFixed(1).replace(/\.0$/, '')}${suffix}+`;
? `${value}${suffix}${addPlusSign ? '+' : ''}`
: `${value.toFixed(1).replace(/\.0$/, '')}${suffix}${
addPlusSign ? '+' : ''
}`;
}
if (num >= 1_000_000_000) {
@@ -186,7 +189,9 @@ export const formatDataForMetricsTable = (
),
description: (
<ValidateRowValueWrapper value={metric.description}>
<Tooltip title={metric.description}>{metric.description}</Tooltip>
<Tooltip className="description-tooltip" title={metric.description}>
{metric.description}
</Tooltip>
</ValidateRowValueWrapper>
),
metric_type: <MetricTypeRenderer type={metric.type} />,

View File

@@ -2,7 +2,9 @@ import styled from 'styled-components';
interface Props {
isDarkMode: boolean;
children?: React.ReactNode;
}
export const StyledLabel = styled.div<Props>`
padding: 0 0.6875rem;
min-height: 2rem;

View File

@@ -458,6 +458,7 @@ export const Query = memo(function Query({
query={query}
onChange={handleChangeTagFilters}
whereClauseConfig={filterConfigs?.filters}
hideSpanScopeSelector={query.dataSource !== DataSource.TRACES}
/>
) : (
<QueryBuilderSearch

View File

@@ -5,4 +5,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export type AgregatorFilterProps = Pick<AutoCompleteProps, 'disabled'> & {
query: IBuilderQuery;
onChange: (value: BaseAutocompleteData) => void;
defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void;
};

View File

@@ -35,6 +35,8 @@ export const AggregatorFilter = memo(function AggregatorFilter({
query,
disabled,
onChange,
defaultValue,
onSelect,
}: AgregatorFilterProps): JSX.Element {
const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
@@ -183,6 +185,27 @@ export const AggregatorFilter = memo(function AggregatorFilter({
[getAttributesData, handleChangeCustomValue, onChange],
);
const handleSelect = useCallback(
(_: string, option: ExtendedSelectOption | ExtendedSelectOption[]): void => {
const currentOption = option as ExtendedSelectOption;
const aggregateAttributes = getAttributesData();
if (currentOption.key) {
const attribute = aggregateAttributes.find(
(item) => item.id === currentOption.key,
);
if (attribute && onSelect) {
onSelect(attribute);
}
}
setSearchText('');
},
[getAttributesData, onSelect],
);
const value = removePrefix(
transformStringWithPrefix({
str: query.aggregateAttribute.key,
@@ -203,10 +226,11 @@ export const AggregatorFilter = memo(function AggregatorFilter({
onSearch={handleSearchText}
notFoundContent={isFetching ? <Spin size="small" /> : null}
options={optionsData}
value={value}
value={defaultValue || value}
onBlur={handleBlur}
onChange={handleChange}
disabled={disabled}
onSelect={handleSelect}
/>
);
});

View File

@@ -285,6 +285,12 @@
.lightMode {
.query-builder-search-v2 {
.ant-select-selector {
border-color: var(--bg-vanilla-300) !important;
background-color: var(--bg-vanilla-100) !important;
color: var(--bg-ink-200);
}
.content {
.operator-for {
.operator-for-text {

View File

@@ -92,6 +92,9 @@ interface QueryBuilderSearchV2Props {
suffixIcon?: React.ReactNode;
hardcodedAttributeKeys?: BaseAutocompleteData[];
operatorConfigKey?: OperatorConfigKeys;
hideSpanScopeSelector?: boolean;
// Determines whether to call onChange when a tag is closed
triggerOnChangeOnClose?: boolean;
}
export interface Option {
@@ -126,6 +129,8 @@ function QueryBuilderSearchV2(
whereClauseConfig,
hardcodedAttributeKeys,
operatorConfigKey,
hideSpanScopeSelector,
triggerOnChangeOnClose,
} = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -900,6 +905,9 @@ function QueryBuilderSearchV2(
onClose();
setSearchValue('');
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
if (triggerOnChangeOnClose) {
onChange(query.filters);
}
};
const tagEditHandler = (value: string): void => {
@@ -936,11 +944,6 @@ function QueryBuilderSearchV2(
);
};
const isTracesDataSource = useMemo(
() => query.dataSource === DataSource.TRACES,
[query.dataSource],
);
return (
<div className="query-builder-search-v2">
<Select
@@ -1025,7 +1028,7 @@ function QueryBuilderSearchV2(
);
})}
</Select>
{isTracesDataSource && <SpanScopeSelector queryName={query.queryName} />}
{!hideSpanScopeSelector && <SpanScopeSelector queryName={query.queryName} />}
</div>
);
}
@@ -1037,6 +1040,8 @@ QueryBuilderSearchV2.defaultProps = {
whereClauseConfig: {},
hardcodedAttributeKeys: undefined,
operatorConfigKey: undefined,
hideSpanScopeSelector: true,
triggerOnChangeOnClose: false,
};
export default QueryBuilderSearchV2;

View File

@@ -26,7 +26,7 @@ const queryClient = new QueryClient({
});
describe('Span scope selector', () => {
it('should render span scope selector when data source is TRACES', () => {
it('should render span scope selector when hideSpanScopeSelector is false', () => {
const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<QueryBuilderSearchV2
@@ -34,6 +34,7 @@ describe('Span scope selector', () => {
...initialQueryBuilderFormValues,
dataSource: DataSource.TRACES,
}}
hideSpanScopeSelector={false}
onChange={jest.fn()}
/>
</QueryClientProvider>,
@@ -42,7 +43,7 @@ describe('Span scope selector', () => {
expect(getByTestId('span-scope-selector')).toBeInTheDocument();
});
it('should not render span scope selector for non-TRACES data sources', () => {
it('should not render span scope selector by default (i.e. when hideSpanScopeSelector is true)', () => {
const { queryByTestId } = render(
<QueryClientProvider client={queryClient}>
<QueryBuilderSearchV2

View File

@@ -52,6 +52,7 @@ function ResourceAttributesFilter(): JSX.Element | null {
query={query}
onChange={handleChangeTagFilters}
operatorConfigKey={OperatorConfigKeys.EXCEPTIONS}
hideSpanScopeSelector={false}
/>
</div>
);

View File

@@ -0,0 +1,55 @@
import {
getInspectMetricsDetails,
InspectMetricsRequest,
InspectMetricsResponse,
} from 'api/metricsExplorer/getInspectMetricsDetails';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetInspectMetricsDetails = (
requestData: InspectMetricsRequest,
options?: UseQueryOptions<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>;
export const useGetInspectMetricsDetails: UseGetInspectMetricsDetails = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [
REACT_QUERY_KEY.GET_INSPECT_METRICS_DETAILS,
requestData.metricName,
requestData.start,
requestData.end,
requestData.filters,
];
}, [options?.queryKey, requestData]);
return useQuery<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) =>
getInspectMetricsDetails(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -6,7 +6,11 @@ import { Filters } from 'components/AlertDetailsFilters/Filters';
import NotFound from 'components/NotFound';
import RouteTab from 'components/RouteTab';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -70,6 +74,9 @@ BreadCrumbItem.defaultProps = {
function AlertDetails(): JSX.Element {
const { pathname } = useLocation();
const { routes } = useRouteTabUtils();
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const { notifications } = useNotifications();
const {
isLoading,
@@ -85,6 +92,27 @@ function AlertDetails(): JSX.Element {
document.title = alertTitle || document.title;
}, [alertDetailsResponse?.payload?.data.alert, isRefetching]);
useEffect(() => {
if (alertDetailsResponse?.payload?.data?.id) {
const ruleUUID = alertDetailsResponse.payload.data.id;
if (ruleId !== ruleUUID) {
urlQuery.set(QueryParams.ruleId, ruleUUID);
const generatedUrl = `${window.location.pathname}?${urlQuery}`;
notifications.info({
message:
"We're transitioning alert rule IDs from integers to UUIDs.Both old and new alert links will continue to work after this change - existing notifications using integer IDs will remain functional while new alerts will use the UUID format. Please use the updated link in the URL for future references",
});
safeNavigate(generatedUrl);
}
}
}, [
alertDetailsResponse?.payload?.data.id,
notifications,
ruleId,
safeNavigate,
urlQuery,
]);
if (
isError ||
!isValidRuleId ||

View File

@@ -4,13 +4,12 @@
.all-errors-quick-filter-section {
width: 0%;
flex-shrink: 0;
color: var(--bg-vanilla-100);
}
.all-errors-right-section {
padding: 0 10px;
}
.ant-tabs {
margin: 0 8px;
}

View File

@@ -11,11 +11,9 @@ RUN apk update && \
COPY ./target/${OS}-${TARGETARCH}/signoz-community /root/signoz
COPY ./conf/prometheus.yml /root/config/prometheus.yml
COPY ./templates/email /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["./signoz"]
CMD ["-config", "/root/config/prometheus.yml"]

View File

@@ -12,11 +12,9 @@ RUN apk update && \
rm -rf /var/cache/apk/*
COPY ./target/${OS}-${ARCH}/signoz-community /root/signoz-community
COPY ./conf/prometheus.yml /root/config/prometheus.yml
COPY ./templates/email /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz-community
ENTRYPOINT ["./signoz-community"]
CMD ["-config", "/root/config/prometheus.yml"]

View File

@@ -5113,7 +5113,14 @@ WHERE metric_name = ?;`, signozMetricDBName, signozTSTableNameV41Week)
return timeSeriesCount, nil
}
func (r *ClickHouseReader) GetAttributesForMetricName(ctx context.Context, metricName string, start, end *int64) (*[]metrics_explorer.Attribute, *model.ApiError) {
func (r *ClickHouseReader) GetAttributesForMetricName(ctx context.Context, metricName string, start, end *int64, filters *v3.FilterSet) (*[]metrics_explorer.Attribute, *model.ApiError) {
whereClause := ""
if filters != nil {
conditions, _ := utils.BuildFilterConditions(filters, "t")
if conditions != nil {
whereClause = "AND " + strings.Join(conditions, " AND ")
}
}
const baseQueryTemplate = `
SELECT
kv.1 AS key,
@@ -5121,7 +5128,7 @@ SELECT
length(groupUniqArray(10000)(kv.2)) AS valueCount
FROM %s.%s
ARRAY JOIN arrayFilter(x -> NOT startsWith(x.1, '__'), JSONExtractKeysAndValuesRaw(labels)) AS kv
WHERE metric_name = ? AND __normalized=true`
WHERE metric_name = ? AND __normalized=true %s`
var args []interface{}
args = append(args, metricName)
@@ -5135,7 +5142,7 @@ WHERE metric_name = ? AND __normalized=true`
tableName = signozTSTableNameV41Week
}
query := fmt.Sprintf(baseQueryTemplate, signozMetricDBName, tableName)
query := fmt.Sprintf(baseQueryTemplate, signozMetricDBName, tableName, whereClause)
if start != nil && end != nil {
query += " AND unix_milli BETWEEN ? AND ?"
@@ -5958,6 +5965,13 @@ func (r *ClickHouseReader) GetInspectMetricsFingerprints(ctx context.Context, at
jsonExtracts = append(jsonExtracts, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", attr, keyAlias))
groupBys = append(groupBys, keyAlias)
}
conditions, _ := utils.BuildFilterConditions(&req.Filters, "")
whereClause := ""
if len(conditions) > 0 {
whereClause = "AND " + strings.Join(conditions, " AND ")
}
start, end, tsTable, _ := utils.WhichTSTableToUse(req.Start, req.End)
query := fmt.Sprintf(`
SELECT
@@ -5970,12 +5984,14 @@ FROM
FROM %s.%s
WHERE metric_name = ?
AND unix_milli BETWEEN ? AND ?
%s
)
GROUP BY %s
ORDER BY length(fingerprints) DESC, rand()
LIMIT 40`, // added rand to get diff value every time we run this query
strings.Join(jsonExtracts, ", "),
signozMetricDBName, tsTable,
whereClause,
strings.Join(groupBys, ", "))
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
rows, err := r.db.Query(valueCtx, query,
@@ -5991,20 +6007,26 @@ LIMIT 40`, // added rand to get diff value every time we run this query
var fingerprints []string
for rows.Next() {
// Create dynamic scanning based on number of attributes
var fingerprintsList []string
var batch []string
if err := rows.Scan(&fingerprintsList); err != nil {
if err := rows.Scan(&batch); err != nil {
return nil, &model.ApiError{Typ: model.ErrorExec, Err: err}
}
if len(fingerprints) == 0 || len(fingerprints)+len(fingerprintsList) < 40 {
fingerprints = append(fingerprints, fingerprintsList...)
}
if len(fingerprints) > 40 {
remaining := 40 - len(fingerprints)
if remaining <= 0 {
break
}
// if this batch would overshoot, only take as many as we need
if len(batch) > remaining {
fingerprints = append(fingerprints, batch[:remaining]...)
break
}
// otherwise take the whole batch and keep going
fingerprints = append(fingerprints, batch...)
}
if err := rows.Err(); err != nil {

View File

@@ -147,7 +147,7 @@ func (receiver *SummaryService) GetMetricsSummary(ctx context.Context, orgID val
})
g.Go(func() error {
attributes, err := receiver.reader.GetAttributesForMetricName(ctx, metricName, nil, nil)
attributes, err := receiver.reader.GetAttributesForMetricName(ctx, metricName, nil, nil, nil)
if err != nil {
return err
}
@@ -475,7 +475,7 @@ func (receiver *SummaryService) GetInspectMetrics(ctx context.Context, params *m
// Run the two queries concurrently using the derived context.
g.Go(func() error {
attrs, apiErr := receiver.reader.GetAttributesForMetricName(egCtx, params.MetricName, &params.Start, &params.End)
attrs, apiErr := receiver.reader.GetAttributesForMetricName(egCtx, params.MetricName, &params.Start, &params.End, &params.Filters)
if apiErr != nil {
return apiErr
}

View File

@@ -122,7 +122,7 @@ type Reader interface {
GetMetricsLastReceived(ctx context.Context, metricName string) (int64, *model.ApiError)
GetTotalTimeSeriesForMetricName(ctx context.Context, metricName string) (uint64, *model.ApiError)
GetActiveTimeSeriesForMetricName(ctx context.Context, metricName string, duration time.Duration) (uint64, *model.ApiError)
GetAttributesForMetricName(ctx context.Context, metricName string, start, end *int64) (*[]metrics_explorer.Attribute, *model.ApiError)
GetAttributesForMetricName(ctx context.Context, metricName string, start, end *int64, set *v3.FilterSet) (*[]metrics_explorer.Attribute, *model.ApiError)
ListSummaryMetrics(ctx context.Context, orgID valuer.UUID, req *metrics_explorer.SummaryListMetricsRequest) (*metrics_explorer.SummaryListMetricsResponse, *model.ApiError)

View File

@@ -33,13 +33,6 @@ func (migration *dropGroups) Register(migrations *migrate.Migrations) error {
}
func (migration *dropGroups) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
type Group struct {
bun.BaseModel `bun:"table:groups"`
@@ -49,6 +42,27 @@ func (migration *dropGroups) Up(ctx context.Context, db *bun.DB) error {
Name string `bun:"name,type:text,notnull,unique" json:"name"`
}
exists, err := migration.sqlstore.Dialect().TableExists(ctx, db, new(Group))
if err != nil {
return err
}
if !exists {
return nil
}
// Disable foreign keys temporarily
if err := migration.sqlstore.Dialect().ToggleForeignKeyConstraint(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
type existingUser struct {
bun.BaseModel `bun:"table:users"`
@@ -133,6 +147,11 @@ func (migration *dropGroups) Up(ctx context.Context, db *bun.DB) error {
return err
}
// Enable foreign keys
if err := migration.sqlstore.Dialect().ToggleForeignKeyConstraint(ctx, db, true); err != nil {
return err
}
return nil
}

View File

@@ -428,6 +428,15 @@ func (dialect *dialect) AddPrimaryKey(ctx context.Context, bun bun.IDB, oldModel
}
func (dialect *dialect) DropColumnWithForeignKeyConstraint(ctx context.Context, bunIDB bun.IDB, model interface{}, column string) error {
var isForeignKeyEnabled bool
if err := bunIDB.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&isForeignKeyEnabled); err != nil {
return err
}
if isForeignKeyEnabled {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "foreign keys are enabled, please disable them before running this migration")
}
existingTable := bunIDB.Dialect().Tables().Get(reflect.TypeOf(model))
columnExists, err := dialect.ColumnExists(ctx, bunIDB, existingTable.Name, column)
if err != nil {
@@ -455,11 +464,6 @@ func (dialect *dialect) DropColumnWithForeignKeyConstraint(ctx context.Context,
}
}
// Disable foreign keys temporarily
if _, err := bunIDB.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
return err
}
if _, err = createTableQuery.Exec(ctx); err != nil {
return err
}
@@ -479,10 +483,15 @@ func (dialect *dialect) DropColumnWithForeignKeyConstraint(ctx context.Context,
return err
}
// Re-enable foreign keys
if _, err := bunIDB.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
return nil
}
func (dialect *dialect) ToggleForeignKeyConstraint(ctx context.Context, bun *bun.DB, enable bool) error {
if enable {
_, err := bun.ExecContext(ctx, "PRAGMA foreign_keys = ON")
return err
}
return nil
_, err := bun.ExecContext(ctx, "PRAGMA foreign_keys = OFF")
return err
}

View File

@@ -82,4 +82,11 @@ type SQLDialect interface {
// Drops the column and the associated foreign key constraint for the given table and column.
DropColumnWithForeignKeyConstraint(context.Context, bun.IDB, interface{}, string) error
// Checks if a table exists.
TableExists(ctx context.Context, bun bun.IDB, table interface{}) (bool, error)
// Toggles foreign key constraint for the given database. This makes sense only for sqlite. This cannot take a transaction as an argument and needs to take the db
// as an argument.
ToggleForeignKeyConstraint(ctx context.Context, bun *bun.DB, enable bool) error
}

View File

@@ -60,3 +60,11 @@ func (dialect *dialect) IndexExists(ctx context.Context, bun bun.IDB, table stri
func (dialect *dialect) DropColumnWithForeignKeyConstraint(ctx context.Context, bun bun.IDB, model interface{}, column string) error {
return nil
}
func (dialect *dialect) TableExists(ctx context.Context, bun bun.IDB, table interface{}) (bool, error) {
return true, nil
}
func (dialect *dialect) ToggleForeignKeyConstraint(ctx context.Context, bun *bun.DB, enable bool) error {
return nil
}