mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 17:40:32 +01:00
Compare commits
4 Commits
feat/trace
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac07d3d37 | ||
|
|
9bab8e0ae2 | ||
|
|
8040f222ad | ||
|
|
a45212cb79 |
@@ -1,48 +1,76 @@
|
||||
# Migrating from the install script to Foundry
|
||||
# Migrating from the install script and `deploy/` to Foundry
|
||||
|
||||
The install script (`install.sh`) and the bundled Compose and Swarm files
|
||||
under `deploy/` are deprecated in favor of [Foundry][foundry], the supported
|
||||
way to install and manage SigNoz. This guide moves an existing Docker Compose
|
||||
or Docker Swarm deployment to Foundry and reattaches your existing volumes, so
|
||||
your data is preserved.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The install script is now deprecated and will no longer receive updates.
|
||||
> This guide is only for **existing** `install.sh` / `deploy/` deployments.
|
||||
> Setting up SigNoz for the first time? Skip migration and install Foundry
|
||||
> directly: [SigNoz install docs][install-docs].
|
||||
|
||||
This guide walks you through migrating an existing SigNoz deployment running via
|
||||
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
|
||||
## How it works
|
||||
|
||||
> [!NOTE]
|
||||
> Setting up SigNoz for the first time? You don't need this guide — follow the [SigNoz installation docs](https://signoz.io/docs/install/) instead.
|
||||
Foundry splits a deployment into two commands:
|
||||
|
||||
## Overview
|
||||
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
|
||||
- `foundryctl forge` generates the deployment manifests from a `casting.yaml`.
|
||||
It never touches running containers, so it is safe to re-run while you
|
||||
iterate.
|
||||
- `foundryctl cast` applies those manifests: it (re)creates the containers and
|
||||
reuses the volumes you point it at.
|
||||
|
||||
Two `foundryctl` commands are used throughout this guide:
|
||||
- **`forge`** — generates deployment manifests from your `casting.yaml`. It does not touch running containers, so it is safe to re-run while you iterate.
|
||||
- **`cast`** — applies the generated manifests: it creates and starts the containers (and pulls new images).
|
||||
You write one `casting.yaml`, point a few patches at your existing data
|
||||
volumes, then cast. The steps below are the same for Compose and Swarm; they
|
||||
differ only in the casting (step 3) and how you stop the old stack (step 5).
|
||||
|
||||
## Prerequisites
|
||||
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
|
||||
|
||||
## Migration Steps
|
||||
> [!WARNING]
|
||||
> **Before proceeding, back up both:**
|
||||
> - **Your docker volumes** — these hold your data.
|
||||
> - **Your existing `docker-compose.yaml` (and any config it references)** — keep a copy somewhere safe. The compose manifests are no longer distributed by SigNoz, so this backup is your only way to roll back to your previous setup.
|
||||
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
|
||||
Swarm).
|
||||
- `foundryctl` (installed in step 1).
|
||||
|
||||
1. Make a note of the volume names used by your existing deployment for the following components:
|
||||
- ClickHouse
|
||||
- SigNoz
|
||||
- ZooKeeper
|
||||
## Migrate
|
||||
|
||||
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
|
||||
### 1. Install Foundry
|
||||
|
||||
2. Generate your `casting.yaml`. Based on internal testing, the following casting should generate the manifests that mimic the legacy docker compose setup (compare against your backed-up `docker-compose.yaml`). Once created, run `foundryctl forge -f casting.yaml`.
|
||||
```bash
|
||||
curl -fsSL https://signoz.io/foundry.sh | bash
|
||||
```
|
||||
|
||||
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
|
||||
### 2. Keep your rollback path
|
||||
|
||||
> [!WARNING]
|
||||
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
|
||||
This migration reattaches your existing volumes in place; it does not move or
|
||||
delete your data. The only destructive action is passing `--volumes` / `-v`
|
||||
when you stop the old stack (step 5), so avoid that flag.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `replica` and `shard` macros below are placeholders. Replace them with the values from your existing ClickHouse configuration (check the `macros` section of your current ClickHouse config, e.g. `config.xml`/`metrika.xml`), otherwise the generated manifests will not match your existing data.
|
||||
> Keep a copy of your existing `docker-compose.yaml` / stack file (and any
|
||||
> config it references). SigNoz no longer distributes these files, so this copy
|
||||
> is your only way to roll back.
|
||||
|
||||
### 3. Write your `casting.yaml`
|
||||
|
||||
Use the casting for your deployment. Both reproduce the legacy single-node
|
||||
setup (ClickHouse + ZooKeeper + SQLite) and reattach your existing volumes;
|
||||
they differ only in `spec.deployment.flavor` and the volume-reuse patch
|
||||
(Compose volumes have a `name` to replace; Swarm volumes are bare, so the whole
|
||||
entry is replaced). If your deployment ran more than one shard or replica,
|
||||
adjust the volume patches accordingly. The
|
||||
[Docker Compose example][compose-example] is a useful reference.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `replica` and `shard` macros are placeholders. Replace them with the
|
||||
> values from your existing ClickHouse config (the `macros` section of
|
||||
> `config.xml` / `metrika.xml`), or the generated manifests will not match your
|
||||
> existing data.
|
||||
|
||||
<details>
|
||||
<summary><b>Docker Compose</b> casting.yaml</summary>
|
||||
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
@@ -61,8 +89,8 @@ spec:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your existing ClickHouse replica macro (see legacy configuration files for reference)
|
||||
shard: "01" # replace with your existing ClickHouse shard macro (see legacy configuration files for reference)
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
@@ -80,50 +108,163 @@ spec:
|
||||
value: root
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `user: root` patch on the ZooKeeper service is required so the container can read/write the data in your reused ZooKeeper volume, which was created with `root`-owned files by the legacy compose setup. Without it, ZooKeeper may fail to start with permission errors.
|
||||
</details>
|
||||
|
||||
If you had custom configurations for features like SMTP or additional ingestion processors/receivers, you will need to include those in your casting file via [patches](https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md), [custom configuration](https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files) or [environment variables](https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec) based on your previous configuration.
|
||||
<details>
|
||||
<summary><b>Docker Swarm</b> casting.yaml</summary>
|
||||
|
||||
3. Review your manifests, we suggest executing the following checks on your manifests before proceeding:
|
||||
- [ ] Validate the container images match what your deployment had, Foundry uses `latest` on generation by default.
|
||||
- [ ] If your signoz version was older than latest, please check the [upgrade path](https://signoz.io/docs/operate/upgrade/) first.
|
||||
- [ ] Check the produced manifests in `pours/deployment` match your older configurations. Extra consideration and review needs to be done on `compose.yaml` as this will be the main entry point for your new deployment.
|
||||
- [ ] The configuration files for clickhouse are now in YAML so validate your custom settings are present.
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
name: signoz
|
||||
spec:
|
||||
deployment:
|
||||
flavor: swarm
|
||||
mode: docker
|
||||
metastore:
|
||||
kind: sqlite
|
||||
telemetrykeeper:
|
||||
kind: zookeeper
|
||||
telemetrystore:
|
||||
spec:
|
||||
config:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
- op: replace
|
||||
path: /volumes/signoz-telemetrykeeper-0-data
|
||||
value:
|
||||
name: signoz-zookeeper-1
|
||||
- op: replace
|
||||
path: /volumes/signoz-telemetrystore-0-0-data
|
||||
value:
|
||||
name: signoz-clickhouse
|
||||
- op: replace
|
||||
path: /volumes/signoz-metastore-sqlite-0-data
|
||||
value:
|
||||
name: signoz-sqlite
|
||||
- op: add
|
||||
path: /services/signoz-telemetrykeeper-zookeeper-0/user
|
||||
value: root
|
||||
```
|
||||
|
||||
4. Execute a `docker compose down`. **Do not** include parameters such as `--volumes` (or `-v`), as it will wipe the volumes we need to maintain and reuse to avoid data loss.
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> This will generate downtime so please plan accordingly.
|
||||
> The `user: root` patch on the ZooKeeper service lets the container read and
|
||||
> write the data in your reused ZooKeeper volume, whose files the legacy setup
|
||||
> created as `root`. Without it, ZooKeeper may fail to start with permission
|
||||
> errors.
|
||||
|
||||
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
|
||||
If you had custom configuration (SMTP, extra ingestion receivers/processors,
|
||||
or custom ClickHouse settings), carry it over via [patches][patches],
|
||||
[custom config files][custom-config], or [environment variables][env-vars].
|
||||
|
||||
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
|
||||
### 4. Generate and review the manifests
|
||||
|
||||
```bash
|
||||
foundryctl forge -f casting.yaml
|
||||
```
|
||||
|
||||
Review `pours/deployment/` before deploying:
|
||||
|
||||
- [ ] Container images match your current deployment. Foundry generates with
|
||||
`latest` by default; if your SigNoz version was older than latest, check the
|
||||
[upgrade path][upgrade-path] first.
|
||||
- [ ] The generated manifests match your previous configuration, especially
|
||||
`compose.yaml` (the new entry point for your deployment).
|
||||
- [ ] The ClickHouse config is now YAML rather than XML; confirm your custom
|
||||
settings carried over (see [ClickHouse configuration files][ch-config] for
|
||||
the XML-to-YAML mapping).
|
||||
|
||||
### 5. Stop the old deployment
|
||||
|
||||
Use the command for your deployment. Do **not** pass `--volumes` / `-v`; that
|
||||
would delete the data you are migrating.
|
||||
|
||||
```bash
|
||||
docker compose down # Compose
|
||||
docker stack rm signoz # Swarm
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When `cast` is run, the migration container will execute its migrations.
|
||||
> This causes downtime, so plan accordingly.
|
||||
|
||||
## Verifying the Migration
|
||||
- SigNoz containers will be up and running.
|
||||
- Log in to the SigNoz UI and verify that data is present.
|
||||
- Signoz will run on localhost:8080
|
||||
- Validate that your data ingestion is receiving data.
|
||||
- Ingesters will receive data on localhost:4317(grpc) and localhost:4318(http)
|
||||
- Review the logs from both ClickHouse and ZooKeeper; no errors should be present.
|
||||
Confirm nothing is still bound to the volumes before continuing:
|
||||
|
||||
## Rolling Back
|
||||
Because step 4 brought the legacy stack down *without* `-v`, your original volumes
|
||||
are untouched and still hold your data. To roll back:
|
||||
```bash
|
||||
docker ps -a
|
||||
```
|
||||
|
||||
- Stop and remove the containers created by Foundry (`docker compose down`, again without `-v`).
|
||||
- Confirm the containers are gone with `docker ps -a` so nothing else is bound to the volumes.
|
||||
- Reapply your original docker compose file (`docker compose up -d`). It will reattach to the
|
||||
existing volumes and restore your prior state.
|
||||
### 6. Deploy with Foundry
|
||||
|
||||
```bash
|
||||
foundryctl cast -f casting.yaml
|
||||
```
|
||||
|
||||
This recreates the containers against your existing volumes and pulls the
|
||||
images. The migration container runs the schema migrations as part of `cast`.
|
||||
|
||||
**Prefer not to use `cast`?** The manifests in `pours/deployment/` are standard
|
||||
Docker artifacts you can apply yourself. Run the command from that directory so
|
||||
the relative config paths resolve:
|
||||
|
||||
```bash
|
||||
cd pours/deployment
|
||||
docker compose up -d # Compose
|
||||
docker stack deploy -c compose.yaml signoz # Swarm
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- All SigNoz containers are running.
|
||||
- The UI is reachable on `http://localhost:8080`, and OTLP on `4317` (gRPC)
|
||||
and `4318` (HTTP), so already-instrumented apps and saved bookmarks keep
|
||||
working.
|
||||
- Your existing data is present in the UI, and new data is being ingested.
|
||||
- ClickHouse and ZooKeeper logs show no errors.
|
||||
|
||||
## Roll back
|
||||
|
||||
Step 5 left your volumes untouched, so your data is intact. To return to the
|
||||
previous setup:
|
||||
|
||||
1. Bring down the Foundry deployment (`docker compose down` or
|
||||
`docker stack rm signoz`, again without `-v`).
|
||||
2. Confirm the containers are gone with `docker ps -a`.
|
||||
3. Re-apply your backed-up stack: `docker compose up -d` (Compose) or
|
||||
`docker stack deploy -c docker-compose.yaml signoz` (Swarm). It reattaches
|
||||
the same volumes and restores your prior state.
|
||||
|
||||
## Troubleshooting
|
||||
- Please reach out to our community on [Slack](https://signoz.io/slack).
|
||||
|
||||
If the migration runs into trouble, reach out on [Slack][slack] or open a
|
||||
[Foundry issue][foundry-issues].
|
||||
|
||||
## References
|
||||
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
|
||||
- [SigNoz documentation](https://signoz.io/docs)
|
||||
- [Foundry](https://github.com/SigNoz/foundry)
|
||||
|
||||
- [Foundry][foundry]
|
||||
- [Casting file reference][casting-ref]
|
||||
- [Custom config files][custom-config]
|
||||
- [Patches][patches]
|
||||
- [SigNoz documentation][signoz-docs]
|
||||
|
||||
[foundry]: https://github.com/SigNoz/foundry
|
||||
[install-docs]: https://signoz.io/docs/install/
|
||||
[compose-example]: https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose
|
||||
[patches]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md
|
||||
[custom-config]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files
|
||||
[env-vars]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec
|
||||
[casting-ref]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md
|
||||
[ch-config]: https://clickhouse.com/docs/operations/configuration-files
|
||||
[upgrade-path]: https://signoz.io/docs/operate/upgrade/
|
||||
[slack]: https://signoz.io/slack
|
||||
[foundry-issues]: https://github.com/SigNoz/foundry/issues
|
||||
[signoz-docs]: https://signoz.io/docs
|
||||
|
||||
@@ -4011,6 +4011,94 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
InframonitoringtypesAssociatedComponent:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesCheckComponentType'
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
type: object
|
||||
InframonitoringtypesAttributesComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- associatedComponent
|
||||
type: object
|
||||
InframonitoringtypesCheckComponentType:
|
||||
enum:
|
||||
- receiver
|
||||
- processor
|
||||
type: string
|
||||
InframonitoringtypesCheckType:
|
||||
enum:
|
||||
- hosts
|
||||
- processes
|
||||
- pods
|
||||
- nodes
|
||||
- deployments
|
||||
- daemonsets
|
||||
- statefulsets
|
||||
- jobs
|
||||
- namespaces
|
||||
- clusters
|
||||
- volumes
|
||||
type: string
|
||||
InframonitoringtypesChecks:
|
||||
properties:
|
||||
missingDefaultEnabledMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
missingOptionalMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
missingRequiredAttributes:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingAttributesComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentDefaultEnabledMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentOptionalMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentRequiredAttributes:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAttributesComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
ready:
|
||||
type: boolean
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesCheckType'
|
||||
required:
|
||||
- type
|
||||
- ready
|
||||
- presentDefaultEnabledMetrics
|
||||
- presentOptionalMetrics
|
||||
- presentRequiredAttributes
|
||||
- missingDefaultEnabledMetrics
|
||||
- missingOptionalMetrics
|
||||
- missingRequiredAttributes
|
||||
type: object
|
||||
InframonitoringtypesClusterRecord:
|
||||
properties:
|
||||
clusterCPU:
|
||||
@@ -4054,8 +4142,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesClusterRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4066,7 +4152,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDaemonSetRecord:
|
||||
@@ -4123,8 +4208,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesDaemonSetRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4135,7 +4218,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDeploymentRecord:
|
||||
@@ -4192,8 +4274,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesDeploymentRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4204,7 +4284,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesHostFilter:
|
||||
@@ -4270,8 +4349,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4282,7 +4359,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesJobRecord:
|
||||
@@ -4345,8 +4421,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesJobRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4357,9 +4431,59 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesMetricsComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
metrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metrics
|
||||
- associatedComponent
|
||||
type: object
|
||||
InframonitoringtypesMissingAttributesComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
documentationLink:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- attributes
|
||||
- associatedComponent
|
||||
- message
|
||||
- documentationLink
|
||||
type: object
|
||||
InframonitoringtypesMissingMetricsComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
documentationLink:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
metrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metrics
|
||||
- associatedComponent
|
||||
- message
|
||||
- documentationLink
|
||||
type: object
|
||||
InframonitoringtypesNamespaceRecord:
|
||||
properties:
|
||||
meta:
|
||||
@@ -4392,8 +4516,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNamespaceRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4404,7 +4526,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesNodeCondition:
|
||||
@@ -4469,8 +4590,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4481,7 +4600,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPodCountsByPhase:
|
||||
@@ -4567,8 +4685,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4579,7 +4695,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPostableClusters:
|
||||
@@ -4842,16 +4957,6 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesRequiredMetricsCheck:
|
||||
properties:
|
||||
missingMetrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- missingMetrics
|
||||
type: object
|
||||
InframonitoringtypesResponseType:
|
||||
enum:
|
||||
- list
|
||||
@@ -4911,8 +5016,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesStatefulSetRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4923,7 +5026,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesVolumeRecord:
|
||||
@@ -4971,8 +5073,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesVolumeRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4983,7 +5083,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
LlmpricingruletypesGettablePricingRules:
|
||||
@@ -14868,6 +14967,72 @@ paths:
|
||||
summary: Health check
|
||||
tags:
|
||||
- health
|
||||
/api/v2/infra_monitoring/checks:
|
||||
get:
|
||||
deprecated: false
|
||||
description: 'Checks whether the metrics and attributes required to power the
|
||||
infra-monitoring section selected by the ''type'' query parameter (hosts,
|
||||
processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces,
|
||||
clusters, volumes) are being received. For each collector receiver or processor
|
||||
that contributes required metrics or attributes, lists what is present and
|
||||
what is missing, with a prebuilt user-facing message and a docs link per missing
|
||||
component. Default-enabled metrics are those expected as soon as the receiver
|
||||
is configured; optional metrics require ''enabled: true'' in receiver config.
|
||||
''ready'' is true only when every missing list is empty.'
|
||||
operationId: GetChecks
|
||||
parameters:
|
||||
- in: query
|
||||
name: type
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesCheckType'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesChecks'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Run Infra Monitoring Setup Checks
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/clusters:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -14882,10 +15047,10 @@ paths:
|
||||
for custom groupBy keys; in both modes every row aggregates nodes and pods
|
||||
in the group. Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
|
||||
via offset/limit. Also reports missing required metrics and whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
via offset/limit. Also reports whether the requested time range falls before
|
||||
the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable,
|
||||
clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data
|
||||
is available for that field.'
|
||||
operationId: ListClusters
|
||||
requestBody:
|
||||
content:
|
||||
@@ -14958,11 +15123,11 @@ paths:
|
||||
row aggregates pods owned by daemonsets in the group. Supports filtering via
|
||||
a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit
|
||||
/ memory / memory_request / memory_limit / desired_nodes / current_nodes,
|
||||
and pagination via offset/limit. Also reports missing required metrics and
|
||||
whether the requested time range falls before the data retention boundary.
|
||||
Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit,
|
||||
daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes,
|
||||
currentNodes) return -1 as a sentinel when no data is available for that field.'
|
||||
and pagination via offset/limit. Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (daemonSetCPU,
|
||||
daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest,
|
||||
daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel
|
||||
when no data is available for that field.'
|
||||
operationId: ListDaemonSets
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15033,11 +15198,11 @@ paths:
|
||||
group. Supports filtering via a filter expression, custom groupBy, ordering
|
||||
by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
|
||||
/ desired_pods / available_pods, and pagination via offset/limit. Also reports
|
||||
missing required metrics and whether the requested time range falls before
|
||||
the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest,
|
||||
deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit,
|
||||
desiredPods, availablePods) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
whether the requested time range falls before the data retention boundary.
|
||||
Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit,
|
||||
deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods,
|
||||
availablePods) return -1 as a sentinel when no data is available for that
|
||||
field.'
|
||||
operationId: ListDeployments
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15102,10 +15267,9 @@ paths:
|
||||
custom groupBy to aggregate hosts by any attribute, ordering by any of the
|
||||
five metrics, and pagination via offset/limit. The response type is ''list''
|
||||
for the default host.name grouping or ''grouped_list'' for custom groupBy
|
||||
keys. Also reports missing required metrics and whether the requested time
|
||||
range falls before the data retention boundary. Numeric metric fields (cpu,
|
||||
memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
keys. Also reports whether the requested time range falls before the data
|
||||
retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListHosts
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15179,11 +15343,11 @@ paths:
|
||||
jobs in the group. Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
|
||||
/ desired_successful_pods / active_pods / failed_pods / successful_pods, and
|
||||
pagination via offset/limit. Also reports missing required metrics and whether
|
||||
the requested time range falls before the data retention boundary. Numeric
|
||||
metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest,
|
||||
jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
pagination via offset/limit. Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest,
|
||||
jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods,
|
||||
activePods, failedPods, successfulPods) return -1 as a sentinel when no data
|
||||
is available for that field.'
|
||||
operationId: ListJobs
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15248,10 +15412,10 @@ paths:
|
||||
type is ''list'' for the default k8s.namespace.name grouping or ''grouped_list''
|
||||
for custom groupBy keys; in both modes every row aggregates pods in the group.
|
||||
Supports filtering via a filter expression, custom groupBy, ordering by cpu
|
||||
/ memory, and pagination via offset/limit. Also reports missing required metrics
|
||||
and whether the requested time range falls before the data retention boundary.
|
||||
Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel
|
||||
when no data is available for that field.'
|
||||
/ memory, and pagination via offset/limit. Also reports whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
operationId: ListNamespaces
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15319,10 +15483,10 @@ paths:
|
||||
for custom groupBy keys (each row aggregates nodes in the group; condition
|
||||
stays no_data). Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
|
||||
via offset/limit. Also reports missing required metrics and whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
|
||||
as a sentinel when no data is available for that field.'
|
||||
via offset/limit. Also reports whether the requested time range falls before
|
||||
the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable,
|
||||
nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is
|
||||
available for that field.'
|
||||
operationId: ListNodes
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15391,11 +15555,10 @@ paths:
|
||||
is one pod with its current phase) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase:
|
||||
{ pending, running, succeeded, failed, unknown } derived from each pod''s
|
||||
latest phase in the window). Also reports missing required metrics and whether
|
||||
the requested time range falls before the data retention boundary. Numeric
|
||||
metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest,
|
||||
podMemoryLimit, podAge) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
latest phase in the window). Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest,
|
||||
podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1
|
||||
as a sentinel when no data is available for that field.'
|
||||
operationId: ListPods
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15462,11 +15625,10 @@ paths:
|
||||
usage, inodes, inodes_free, inodes_used), and pagination via offset/limit.
|
||||
The response type is ''list'' for the default k8s.persistentvolumeclaim.name
|
||||
grouping or ''grouped_list'' for custom groupBy keys; in both modes every
|
||||
row aggregates volumes in the group. Also reports missing required metrics
|
||||
and whether the requested time range falls before the data retention boundary.
|
||||
Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes,
|
||||
volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is
|
||||
available for that field.'
|
||||
row aggregates volumes in the group. Also reports whether the requested time
|
||||
range falls before the data retention boundary. Numeric metric fields (volumeAvailable,
|
||||
volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListVolumes
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15537,11 +15699,10 @@ paths:
|
||||
statefulsets in the group. Supports filtering via a filter expression, custom
|
||||
groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request
|
||||
/ memory_limit / desired_pods / current_pods, and pagination via offset/limit.
|
||||
Also reports missing required metrics and whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (statefulSetCPU,
|
||||
statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest,
|
||||
statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel
|
||||
when no data is available for that field.'
|
||||
Also reports whether the requested time range falls before the data retention
|
||||
boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit,
|
||||
statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods,
|
||||
currentPods) return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListStatefulSets
|
||||
requestBody:
|
||||
content:
|
||||
|
||||
@@ -90,8 +90,12 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
// initiate agent config handler
|
||||
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{
|
||||
logParsingPipelineController,
|
||||
signoz.Modules.SpanMapper,
|
||||
signoz.Modules.LLMPricingRule,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -57,6 +57,13 @@ export const TraceFilter = Loadable(
|
||||
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
|
||||
);
|
||||
|
||||
export const TraceDetail = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailOldRedirect = Loadable(
|
||||
() =>
|
||||
import(
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetChecks200,
|
||||
GetChecksParams,
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableDaemonSetsDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
@@ -39,7 +47,94 @@ import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
export const getChecks = (params: GetChecksParams, signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetChecks200>({
|
||||
url: `/api/v2/infra_monitoring/checks`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetChecksQueryKey = (params?: GetChecksParams) => {
|
||||
return [
|
||||
`/api/v2/infra_monitoring/checks`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetChecksQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getChecks>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetChecksParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetChecksQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getChecks>>> = ({
|
||||
signal,
|
||||
}) => getChecks(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getChecks>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetChecksQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getChecks>>
|
||||
>;
|
||||
export type GetChecksQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
|
||||
export function useGetChecks<
|
||||
TData = Awaited<ReturnType<typeof getChecks>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetChecksParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetChecksQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
export const invalidateGetChecks = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetChecksParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetChecksQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Clusters for Infra Monitoring
|
||||
*/
|
||||
export const listClusters = (
|
||||
@@ -122,7 +217,7 @@ export const useListClusters = <
|
||||
return useMutation(getListClustersMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List DaemonSets for Infra Monitoring
|
||||
*/
|
||||
export const listDaemonSets = (
|
||||
@@ -205,7 +300,7 @@ export const useListDaemonSets = <
|
||||
return useMutation(getListDaemonSetsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Deployments for Infra Monitoring
|
||||
*/
|
||||
export const listDeployments = (
|
||||
@@ -288,7 +383,7 @@ export const useListDeployments = <
|
||||
return useMutation(getListDeploymentsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const listHosts = (
|
||||
@@ -371,7 +466,7 @@ export const useListHosts = <
|
||||
return useMutation(getListHostsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Jobs for Infra Monitoring
|
||||
*/
|
||||
export const listJobs = (
|
||||
@@ -454,7 +549,7 @@ export const useListJobs = <
|
||||
return useMutation(getListJobsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Namespaces for Infra Monitoring
|
||||
*/
|
||||
export const listNamespaces = (
|
||||
@@ -537,7 +632,7 @@ export const useListNamespaces = <
|
||||
return useMutation(getListNamespacesMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const listNodes = (
|
||||
@@ -620,7 +715,7 @@ export const useListNodes = <
|
||||
return useMutation(getListNodesMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Pods for Infra Monitoring
|
||||
*/
|
||||
export const listPods = (
|
||||
@@ -703,7 +798,7 @@ export const useListPods = <
|
||||
return useMutation(getListPodsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes persistent volume claims (PVCs) with key volume metrics: available bytes, capacity bytes, usage (capacity - available), inodes, free inodes, and used inodes. Each row also includes metadata attributes (k8s.persistentvolumeclaim.name, k8s.pod.uid, k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.statefulset.name, k8s.cluster.name). Supports filtering via a filter expression, custom groupBy to aggregate volumes by any attribute, ordering by any of the six metrics (available, capacity, usage, inodes, inodes_free, inodes_used), and pagination via offset/limit. The response type is 'list' for the default k8s.persistentvolumeclaim.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates volumes in the group. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes persistent volume claims (PVCs) with key volume metrics: available bytes, capacity bytes, usage (capacity - available), inodes, free inodes, and used inodes. Each row also includes metadata attributes (k8s.persistentvolumeclaim.name, k8s.pod.uid, k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.statefulset.name, k8s.cluster.name). Supports filtering via a filter expression, custom groupBy to aggregate volumes by any attribute, ordering by any of the six metrics (available, capacity, usage, inodes, inodes_free, inodes_used), and pagination via offset/limit. The response type is 'list' for the default k8s.persistentvolumeclaim.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates volumes in the group. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Volumes for Infra Monitoring
|
||||
*/
|
||||
export const listVolumes = (
|
||||
@@ -786,7 +881,7 @@ export const useListVolumes = <
|
||||
return useMutation(getListVolumesMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List StatefulSets for Infra Monitoring
|
||||
*/
|
||||
export const listStatefulSets = (
|
||||
|
||||
@@ -5458,6 +5458,121 @@ export interface GlobaltypesConfigDTO {
|
||||
mcp_url: string | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesCheckComponentTypeDTO {
|
||||
receiver = 'receiver',
|
||||
processor = 'processor',
|
||||
}
|
||||
export interface InframonitoringtypesAssociatedComponentDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
type: InframonitoringtypesCheckComponentTypeDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesAttributesComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesCheckTypeDTO {
|
||||
hosts = 'hosts',
|
||||
processes = 'processes',
|
||||
pods = 'pods',
|
||||
nodes = 'nodes',
|
||||
deployments = 'deployments',
|
||||
daemonsets = 'daemonsets',
|
||||
statefulsets = 'statefulsets',
|
||||
jobs = 'jobs',
|
||||
namespaces = 'namespaces',
|
||||
clusters = 'clusters',
|
||||
volumes = 'volumes',
|
||||
}
|
||||
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
documentationLink: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
metrics: string[] | null;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesMissingAttributesComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
documentationLink: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
metrics: string[] | null;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesChecksDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingDefaultEnabledMetrics:
|
||||
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingOptionalMetrics:
|
||||
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingRequiredAttributes:
|
||||
| InframonitoringtypesMissingAttributesComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentDefaultEnabledMetrics:
|
||||
| InframonitoringtypesMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentOptionalMetrics: InframonitoringtypesMetricsComponentEntryDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentRequiredAttributes:
|
||||
| InframonitoringtypesAttributesComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ready: boolean;
|
||||
type: InframonitoringtypesCheckTypeDTO;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -5535,13 +5650,6 @@ export interface InframonitoringtypesClusterRecordDTO {
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesRequiredMetricsCheckDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingMetrics: string[] | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesResponseTypeDTO {
|
||||
list = 'list',
|
||||
grouped_list = 'grouped_list',
|
||||
@@ -5577,7 +5685,6 @@ export interface InframonitoringtypesClustersDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesClusterRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5655,7 +5762,6 @@ export interface InframonitoringtypesDaemonSetsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesDaemonSetRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5733,7 +5839,6 @@ export interface InframonitoringtypesDeploymentsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesDeploymentRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5819,7 +5924,6 @@ export interface InframonitoringtypesHostsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesHostRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5905,7 +6009,6 @@ export interface InframonitoringtypesJobsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesJobRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5955,7 +6058,6 @@ export interface InframonitoringtypesNamespacesDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesNamespaceRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6022,7 +6124,6 @@ export interface InframonitoringtypesNodesDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesNodeRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6106,7 +6207,6 @@ export interface InframonitoringtypesPodsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesPodRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6454,7 +6554,6 @@ export interface InframonitoringtypesStatefulSetsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesStatefulSetRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6523,7 +6622,6 @@ export interface InframonitoringtypesVolumesDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesVolumeRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -10246,6 +10344,21 @@ export type Healthz503 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetChecksParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
type: InframonitoringtypesCheckTypeDTO;
|
||||
};
|
||||
|
||||
export type GetChecks200 = {
|
||||
data: InframonitoringtypesChecksDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListClusters200 = {
|
||||
data: InframonitoringtypesClustersDTO;
|
||||
/**
|
||||
|
||||
35
frontend/src/api/trace/getTraceV2.tsx
Normal file
35
frontend/src/api/trace/getTraceV2.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV2PayloadProps,
|
||||
GetTraceV2SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV2';
|
||||
|
||||
const getTraceV2 = async (
|
||||
props: GetTraceV2PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
(node) => node !== props.selectedSpanId,
|
||||
);
|
||||
}
|
||||
const postData: GetTraceV2PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
};
|
||||
const response = await axios.post<GetTraceV2SuccessResponse>(
|
||||
`/traces/waterfall/${props.traceId}`,
|
||||
omit(postData, 'traceId'),
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV2;
|
||||
@@ -41,7 +41,6 @@ const getTraceV4 = async (
|
||||
> & { spans: WireSpan[] | null };
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
|
||||
25
frontend/src/assets/TraceDetail/Flamegraph.tsx
Normal file
25
frontend/src/assets/TraceDetail/Flamegraph.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
function FlamegraphImg(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
|
||||
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphImg;
|
||||
106
frontend/src/components/SpanHoverCard/SpanHoverCard.styles.scss
Normal file
106
frontend/src/components/SpanHoverCard/SpanHoverCard.styles.scss
Normal file
@@ -0,0 +1,106 @@
|
||||
.span-hover-card {
|
||||
.ant-popover-inner {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 4px;
|
||||
z-index: -1;
|
||||
will-change: background-color, backdrop-filter;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__operation {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.48px;
|
||||
}
|
||||
|
||||
&__service {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger-background);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__relative-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
gap: 8px;
|
||||
border-radius: 1px 0 0 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsla(358, 75%, 59%, 0.2) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
&-icon {
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__relative-text {
|
||||
color: var(--bg-cherry-300);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
103
frontend/src/components/SpanHoverCard/SpanHoverCard.tsx
Normal file
103
frontend/src/components/SpanHoverCard/SpanHoverCard.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface SpanHoverCardProps {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(duration);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// Calculate relative start time from trace start
|
||||
const relativeStartTime = span.timestamp - traceMetadata.startTime;
|
||||
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
|
||||
convertTimeToRelevantUnit(relativeStartTime);
|
||||
|
||||
// Format absolute start time
|
||||
const startTimeFormatted = dayjs(span.timestamp)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
|
||||
|
||||
const getContent = (): JSX.Element => (
|
||||
<div className="span-hover-card">
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Duration:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{toFixed(formattedDuration, 2)}
|
||||
{timeUnitName}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Events:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{span.event?.length || 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Start time:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{startTimeFormatted}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__relative-time">
|
||||
<div className="span-hover-card__relative-time-icon" />
|
||||
<Typography.Text className="span-hover-card__relative-text">
|
||||
{toFixed(relativeTime, 2)}
|
||||
{relativeTimeUnit} after trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
title={
|
||||
<div className="span-hover-card__title">
|
||||
<Typography.Text className="span-hover-card__operation">
|
||||
{span.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
mouseEnterDelay={0.2}
|
||||
content={getContent()}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanHoverCard;
|
||||
@@ -0,0 +1,291 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TimezoneContextType } from 'providers/Timezone';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanHoverCard from '../SpanHoverCard';
|
||||
|
||||
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
__esModule: true,
|
||||
useTimezone: (): TimezoneContextType => ({
|
||||
timezone: {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
browserTimezone: {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
updateTimezone: jest.fn(),
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
|
||||
isAdaptationEnabled: true,
|
||||
setIsAdaptationEnabled: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
|
||||
jest.mock('dayjs', () => {
|
||||
const mockDayjsInstance: any = {};
|
||||
|
||||
mockDayjsInstance.format = jest.fn((formatString: string) =>
|
||||
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
|
||||
formatString === 'DD MMM YYYY, HH:mm:ss'
|
||||
? '15 Mar 2024, 14:23:45'
|
||||
: 'mock-date',
|
||||
);
|
||||
|
||||
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
|
||||
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
|
||||
mockDayjsInstance.utcOffset = jest.fn(() => 0);
|
||||
|
||||
const mockDayjs = jest.fn(() => mockDayjsInstance);
|
||||
|
||||
Object.assign(mockDayjs, {
|
||||
extend: jest.fn(),
|
||||
// Support dayjs.tz.guess()
|
||||
tz: { guess: jest.fn(() => 'UTC') },
|
||||
});
|
||||
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
const HOVER_ELEMENT_ID = 'hover-element';
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
rootSpanId: 'root-span-id',
|
||||
parentSpanId: 'parent-span-id',
|
||||
name: 'GET /api/users',
|
||||
timestamp: 1679748225000000,
|
||||
durationNano: 150000000,
|
||||
serviceName: 'user-service',
|
||||
kind: 1,
|
||||
hasError: false,
|
||||
level: 1,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [
|
||||
{
|
||||
name: 'event1',
|
||||
timeUnixNano: 1679748225100000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'event2',
|
||||
timeUnixNano: 1679748225200000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
rootName: 'root-span',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'OK',
|
||||
spanKind: 'server',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 1,
|
||||
};
|
||||
|
||||
const mockTraceMetadata = {
|
||||
startTime: 1679748225000000,
|
||||
endTime: 1679748226000000,
|
||||
};
|
||||
|
||||
describe('SpanHoverCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders child element correctly', () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid="child-element">Hover me</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child-element')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hover me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows popover after 0.2 second delay on hover', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover over the element
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Popover should NOT appear immediately
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Advance time by 0.5 seconds
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Now popover should appear
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show popover if hover is too brief', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Quick hover and unhover (less than the 0.2s delay)
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100); // Only 0.1 seconds
|
||||
});
|
||||
fireEvent.mouseLeave(hoverElement);
|
||||
|
||||
// Advance past the full delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
// Popover should not appear
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays span information in popover content after delay', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check that popover shows span operation name in title
|
||||
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
|
||||
|
||||
// Check duration information
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
expect(screen.getByText('150ms')).toBeInTheDocument();
|
||||
|
||||
// Check events count
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
|
||||
// Check start time label
|
||||
expect(screen.getByText('Start time:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
|
||||
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays relative time information', async () => {
|
||||
const spanWithRelativeTime: Span = {
|
||||
...mockSpan,
|
||||
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check relative time display
|
||||
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles spans with no events correctly', async () => {
|
||||
const spanWithoutEvents: Span = {
|
||||
...mockSpan,
|
||||
event: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('verifies mouseEnterDelay prop is set to 0.5', () => {
|
||||
const { container } = render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
// The mouseEnterDelay prop should be set on the Popover component
|
||||
// This test verifies the implementation includes the delay
|
||||
const popover = container.querySelector('.ant-popover');
|
||||
expect(popover).not.toBeInTheDocument(); // Initially not visible
|
||||
|
||||
// Hover to trigger delay mechanism
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Should not appear before delay
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Should appear after delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
.flamegraph {
|
||||
display: flex;
|
||||
height: 30vh;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.flamegraph-chart {
|
||||
padding: 15px;
|
||||
|
||||
.loading-skeleton {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.flamegraph-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 20px;
|
||||
|
||||
.exec-time-service {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0rem;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.service-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 80%;
|
||||
|
||||
.service-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.square-box {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-service {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100px;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
|
||||
.service-progress-indicator {
|
||||
width: fit-content;
|
||||
--progress-width: 30px;
|
||||
}
|
||||
|
||||
.percent-value {
|
||||
color: var(--l1-foreground);
|
||||
text-align: right;
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.48px;
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { TraceFlamegraphStates } from './constants';
|
||||
import Error from './TraceFlamegraphStates/Error/Error';
|
||||
import NoData from './TraceFlamegraphStates/NoData/NoData';
|
||||
import Success from './TraceFlamegraphStates/Success/Success';
|
||||
|
||||
import './PaginatedTraceFlamegraph.styles.scss';
|
||||
|
||||
interface ITraceFlamegraphProps {
|
||||
serviceExecTime: Record<string, number>;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceFlamegraphStatsWidth: number;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
|
||||
function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
const {
|
||||
serviceExecTime,
|
||||
startTime,
|
||||
endTime,
|
||||
traceFlamegraphStatsWidth,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
|
||||
const { data, isFetching, error } = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
});
|
||||
|
||||
// get the current state of trace flamegraph based on the API lifecycle
|
||||
const traceFlamegraphState = useMemo(() => {
|
||||
if (isFetching) {
|
||||
if (
|
||||
data &&
|
||||
data.payload &&
|
||||
data.payload.spans &&
|
||||
data.payload.spans.length > 0
|
||||
) {
|
||||
return TraceFlamegraphStates.FETCHING_WITH_OLD_DATA_PRESENT;
|
||||
}
|
||||
return TraceFlamegraphStates.LOADING;
|
||||
}
|
||||
if (error) {
|
||||
return TraceFlamegraphStates.ERROR;
|
||||
}
|
||||
if (
|
||||
data &&
|
||||
data.payload &&
|
||||
data.payload.spans &&
|
||||
data.payload.spans.length === 0
|
||||
) {
|
||||
return TraceFlamegraphStates.NO_DATA;
|
||||
}
|
||||
|
||||
return TraceFlamegraphStates.SUCCESS;
|
||||
}, [error, isFetching, data]);
|
||||
|
||||
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
|
||||
const spans = useMemo(
|
||||
() => data?.payload?.spans || [],
|
||||
[data?.payload?.spans],
|
||||
);
|
||||
|
||||
// get the content based on the current state of the trace waterfall
|
||||
const getContent = useMemo(() => {
|
||||
switch (traceFlamegraphState) {
|
||||
case TraceFlamegraphStates.LOADING:
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
</div>
|
||||
);
|
||||
case TraceFlamegraphStates.ERROR:
|
||||
return <Error error={error as AxiosError} />;
|
||||
case TraceFlamegraphStates.NO_DATA:
|
||||
return <NoData id={traceId} />;
|
||||
case TraceFlamegraphStates.SUCCESS:
|
||||
case TraceFlamegraphStates.FETCHING_WITH_OLD_DATA_PRESENT:
|
||||
return (
|
||||
<Success
|
||||
spans={spans}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
selectedSpan={selectedSpan}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Spinner tip="Fetching the trace!" />;
|
||||
}
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
error,
|
||||
firstSpanAtFetchLevel,
|
||||
selectedSpan,
|
||||
spans,
|
||||
traceFlamegraphState,
|
||||
traceId,
|
||||
]);
|
||||
|
||||
const spread = useMemo(() => endTime - startTime, [endTime, startTime]);
|
||||
|
||||
return (
|
||||
<div className="flamegraph">
|
||||
<div
|
||||
className="flamegraph-stats"
|
||||
style={{ width: `${traceFlamegraphStatsWidth + 22}px` }}
|
||||
>
|
||||
<div className="exec-time-service">% exec time</div>
|
||||
<div className="stats">
|
||||
{Object.keys(serviceExecTime)
|
||||
.sort((a, b) => {
|
||||
if (spread <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const aValue = (serviceExecTime[a] * 100) / spread;
|
||||
const bValue = (serviceExecTime[b] * 100) / spread;
|
||||
return bValue - aValue;
|
||||
})
|
||||
.map((service) => {
|
||||
const value =
|
||||
spread <= 0 ? 0 : (serviceExecTime[service] * 100) / spread;
|
||||
const color = generateColor(service, themeColors.traceDetailColors);
|
||||
return (
|
||||
<div key={service} className="value-row">
|
||||
<section className="service-name">
|
||||
<div className="square-box" style={{ backgroundColor: color }} />
|
||||
<Tooltip title={service}>
|
||||
<Typography.Text className="service-text" truncate={1}>
|
||||
{service}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<section className="progress-service">
|
||||
<Progress
|
||||
percent={parseFloat(value.toFixed(2))}
|
||||
className="service-progress-indicator"
|
||||
showInfo={false}
|
||||
/>
|
||||
<Typography.Text className="percent-value">
|
||||
{parseFloat(value.toFixed(2))}%
|
||||
</Typography.Text>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flamegraph-chart"
|
||||
style={{ width: `calc(100% - ${traceFlamegraphStatsWidth + 22}px)` }}
|
||||
>
|
||||
{getContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceFlamegraph;
|
||||
@@ -0,0 +1,23 @@
|
||||
.error-flamegraph {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 15vh;
|
||||
|
||||
.error-flamegraph-img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
color: var(--muted-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import './Error.styles.scss';
|
||||
|
||||
interface IErrorProps {
|
||||
error: AxiosError;
|
||||
}
|
||||
|
||||
function Error(props: IErrorProps): JSX.Element {
|
||||
const { error } = props;
|
||||
|
||||
return (
|
||||
<div className="error-flamegraph">
|
||||
<img
|
||||
src={noDataUrl}
|
||||
alt="error-flamegraph"
|
||||
className="error-flamegraph-img"
|
||||
/>
|
||||
<Tooltip title={error?.message}>
|
||||
<Typography.Text className="no-data-text">
|
||||
{error?.message || 'Something went wrong!'}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Error;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface INoDataProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function NoData(props: INoDataProps): JSX.Element {
|
||||
const { id } = props;
|
||||
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -0,0 +1,49 @@
|
||||
.trace-flamegraph {
|
||||
height: 90%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.trace-flamegraph-virtuoso {
|
||||
overflow-x: hidden;
|
||||
|
||||
.flamegraph-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
.span-item {
|
||||
position: absolute;
|
||||
height: 12px;
|
||||
background-color: yellow;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
.event-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--primary-background);
|
||||
border: 1px solid var(--bg-robin-600);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
&.error {
|
||||
background-color: var(--danger-background);
|
||||
border-color: var(--bg-cherry-600);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { ListRange, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Tooltip } from 'antd';
|
||||
import Color from 'color';
|
||||
import TimelineV2 from 'components/TimelineV2/TimelineV2';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface ISuccessProps {
|
||||
spans: FlamegraphSpan[][];
|
||||
firstSpanAtFetchLevel: string;
|
||||
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
|
||||
function Success(props: ISuccessProps): JSX.Element {
|
||||
const {
|
||||
spans,
|
||||
setFirstSpanAtFetchLevel,
|
||||
traceMetadata,
|
||||
firstSpanAtFetchLevel,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string>('');
|
||||
const renderSpanLevel = useCallback(
|
||||
(_: number, spans: FlamegraphSpan[]): JSX.Element => (
|
||||
<div className="flamegraph-row">
|
||||
{spans.map((span) => {
|
||||
const spread = traceMetadata.endTime - traceMetadata.startTime;
|
||||
const leftOffset =
|
||||
((span.timestamp - traceMetadata.startTime) * 100) / spread;
|
||||
let width = ((span.durationNano / 1e6) * 100) / spread;
|
||||
if (width > 100) {
|
||||
width = 100;
|
||||
}
|
||||
const toolTipText = `${span.name}`;
|
||||
const searchParams = new URLSearchParams(search);
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
|
||||
const selectedSpanColor = isDarkMode
|
||||
? Color(color).lighten(0.7)
|
||||
: Color(color).darken(0.7);
|
||||
|
||||
if (span.hasError) {
|
||||
color = `var(--danger-background)`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={toolTipText} key={span.spanId}>
|
||||
<div
|
||||
className="span-item"
|
||||
style={{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor:
|
||||
selectedSpan?.spanId === span.spanId || hoveredSpanId === span.spanId
|
||||
? `${selectedSpanColor}`
|
||||
: color,
|
||||
}}
|
||||
onMouseEnter={(): void => setHoveredSpanId(span.spanId)}
|
||||
onMouseLeave={(): void => setHoveredSpanId('')}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
searchParams.set('spanId', span.spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}}
|
||||
>
|
||||
{span.event?.map((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
|
||||
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
|
||||
const { isError } = event;
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(
|
||||
eventTimeMs - span.timestamp,
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
|
||||
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
|
||||
>
|
||||
<div
|
||||
className={`event-dot ${isError ? 'error' : ''}`}
|
||||
style={{
|
||||
left: `${clampedOffset}%`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[traceMetadata.endTime, traceMetadata.startTime, selectedSpan, hoveredSpanId],
|
||||
);
|
||||
|
||||
const handleRangeChanged = useCallback(
|
||||
(range: ListRange) => {
|
||||
// if there are less than 50 levels on any load that means a single API call is sufficient
|
||||
if (spans.length < 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { startIndex, endIndex } = range;
|
||||
if (startIndex === 0 && spans[0][0].level !== 0) {
|
||||
setFirstSpanAtFetchLevel(spans[0][0].spanId);
|
||||
}
|
||||
|
||||
if (endIndex === spans.length - 1) {
|
||||
setFirstSpanAtFetchLevel(spans[spans.length - 1][0].spanId);
|
||||
}
|
||||
},
|
||||
[setFirstSpanAtFetchLevel, spans],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = spans.findIndex(
|
||||
(span) => span[0].spanId === firstSpanAtFetchLevel,
|
||||
);
|
||||
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index,
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, [firstSpanAtFetchLevel, spans]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="trace-flamegraph">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="trace-flamegraph-virtuoso"
|
||||
data={spans}
|
||||
itemContent={renderSpanLevel}
|
||||
rangeChanged={handleRangeChanged}
|
||||
/>
|
||||
</div>
|
||||
<TimelineV2
|
||||
startTimestamp={traceMetadata.startTime}
|
||||
endTimestamp={traceMetadata.endTime}
|
||||
timelineHeight={22}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Success;
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum TraceFlamegraphStates {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Popover, Spin, Tooltip } from 'antd';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { useTraceActions } from 'hooks/trace/useTraceActions';
|
||||
import {
|
||||
ArrowDownToDot,
|
||||
ArrowUpFromDot,
|
||||
Copy,
|
||||
Ellipsis,
|
||||
Pin,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
interface AttributeRecord {
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface AttributeActionsProps {
|
||||
record: AttributeRecord;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: (fieldKey: string) => void;
|
||||
showPinned?: boolean;
|
||||
showCopyOptions?: boolean;
|
||||
}
|
||||
|
||||
export default function AttributeActions({
|
||||
record,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
showPinned = true,
|
||||
showCopyOptions = true,
|
||||
}: AttributeActionsProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
|
||||
|
||||
const { onAddToQuery, onGroupByAttribute, onCopyFieldName, onCopyFieldValue } =
|
||||
useTraceActions();
|
||||
|
||||
const textToCopy = useMemo(() => {
|
||||
const str = record.value == null ? '' : String(record.value);
|
||||
// Remove surrounding double-quotes only (e.g., JSON-encoded string values)
|
||||
return str.replace(/^"|"$/g, '');
|
||||
}, [record.value]);
|
||||
|
||||
const handleFilterIn = useCallback(async (): Promise<void> => {
|
||||
if (!onAddToQuery || isFilterInLoading) {
|
||||
return;
|
||||
}
|
||||
setIsFilterInLoading(true);
|
||||
try {
|
||||
await Promise.resolve(
|
||||
onAddToQuery(record.field, record.value, OPERATORS['=']),
|
||||
);
|
||||
} finally {
|
||||
setIsFilterInLoading(false);
|
||||
}
|
||||
}, [onAddToQuery, record.field, record.value, isFilterInLoading]);
|
||||
|
||||
const handleFilterOut = useCallback(async (): Promise<void> => {
|
||||
if (!onAddToQuery || isFilterOutLoading) {
|
||||
return;
|
||||
}
|
||||
setIsFilterOutLoading(true);
|
||||
try {
|
||||
await Promise.resolve(
|
||||
onAddToQuery(record.field, record.value, OPERATORS['!=']),
|
||||
);
|
||||
} finally {
|
||||
setIsFilterOutLoading(false);
|
||||
}
|
||||
}, [onAddToQuery, record.field, record.value, isFilterOutLoading]);
|
||||
|
||||
const handleGroupBy = useCallback((): void => {
|
||||
if (onGroupByAttribute) {
|
||||
onGroupByAttribute(record.field);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [onGroupByAttribute, record.field]);
|
||||
|
||||
const handleCopyFieldName = useCallback((): void => {
|
||||
if (onCopyFieldName) {
|
||||
onCopyFieldName(record.field);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [onCopyFieldName, record.field]);
|
||||
|
||||
const handleCopyFieldValue = useCallback((): void => {
|
||||
if (onCopyFieldValue) {
|
||||
onCopyFieldValue(textToCopy);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [onCopyFieldValue, textToCopy]);
|
||||
|
||||
const handleTogglePin = useCallback((): void => {
|
||||
onTogglePin?.(record.field);
|
||||
}, [onTogglePin, record.field]);
|
||||
|
||||
const moreActionsContent = (
|
||||
<div className="attribute-actions-menu">
|
||||
<Button
|
||||
className="group-by-clause"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupBy}
|
||||
block
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
{showCopyOptions && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopyFieldName}
|
||||
block
|
||||
>
|
||||
Copy Field Name
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopyFieldValue}
|
||||
block
|
||||
>
|
||||
Copy Field Value
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
|
||||
{showPinned && (
|
||||
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
|
||||
<Button
|
||||
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
|
||||
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
|
||||
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
|
||||
onClick={handleTogglePin}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Filter for value">
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
aria-label="Filter for value"
|
||||
disabled={isFilterInLoading}
|
||||
icon={
|
||||
isFilterInLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={handleFilterIn}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Filter out value">
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
aria-label="Filter out value"
|
||||
disabled={isFilterOutLoading}
|
||||
icon={
|
||||
isFilterOutLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={handleFilterOut}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
arrow={false}
|
||||
content={moreActionsContent}
|
||||
rootClassName="attribute-actions-content"
|
||||
trigger="hover"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button
|
||||
data-testid="attribute-actions-more"
|
||||
aria-label="More attribute actions"
|
||||
icon={<Ellipsis size={14} />}
|
||||
className="filter-btn periscope-btn"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AttributeActions.defaultProps = {
|
||||
isPinned: false,
|
||||
showPinned: true,
|
||||
showCopyOptions: true,
|
||||
onTogglePin: undefined,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
.attributes-corner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.no-data {
|
||||
height: 400px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin: 12px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.attributes-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-block: 12px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
padding: 2px 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l1-border);
|
||||
.action-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.item-key-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.pin-icon {
|
||||
color: var(--bg-robin-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-key {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.copy-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.56px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: none;
|
||||
|
||||
&--is-open {
|
||||
display: flex;
|
||||
}
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-border);
|
||||
padding: 4px;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-top {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-actions-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-btn {
|
||||
text-align: left;
|
||||
height: auto;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.group-by-clause {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-actions-content {
|
||||
.ant-popover-inner {
|
||||
padding: 8px;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { flattenObject } from 'container/LogDetailedView/utils';
|
||||
import { usePinnedAttributes } from 'hooks/spanDetails/usePinnedAttributes';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import NoData from '../NoData/NoData';
|
||||
import AttributeActions from './AttributeActions';
|
||||
|
||||
import './Attributes.styles.scss';
|
||||
|
||||
interface AttributeRecord {
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface IAttributesProps {
|
||||
span: Span;
|
||||
isSearchVisible: boolean;
|
||||
shouldFocusOnToggle?: boolean;
|
||||
}
|
||||
|
||||
function Attributes(props: IAttributesProps): JSX.Element {
|
||||
const { span, isSearchVisible, shouldFocusOnToggle } = props;
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
|
||||
const flattenSpanData: Record<string, string> = useMemo(
|
||||
() => (span.tagMap ? flattenObject(span.tagMap) : {}),
|
||||
[span],
|
||||
);
|
||||
|
||||
const availableAttributes = useMemo(
|
||||
() => Object.keys(flattenSpanData),
|
||||
[flattenSpanData],
|
||||
);
|
||||
|
||||
const { pinnedAttributes, togglePin } =
|
||||
usePinnedAttributes(availableAttributes);
|
||||
|
||||
const sortPinnedAttributes = useCallback(
|
||||
(data: AttributeRecord[]): AttributeRecord[] =>
|
||||
data.sort((a, b) => {
|
||||
const aIsPinned = pinnedAttributes[a.field];
|
||||
const bIsPinned = pinnedAttributes[b.field];
|
||||
|
||||
if (aIsPinned && !bIsPinned) {
|
||||
return -1;
|
||||
}
|
||||
if (!aIsPinned && bIsPinned) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Within same pinning status, maintain alphabetical order
|
||||
return a.field.localeCompare(b.field);
|
||||
}),
|
||||
[pinnedAttributes],
|
||||
);
|
||||
|
||||
const datasource = useMemo(() => {
|
||||
const filtered = Object.keys(flattenSpanData)
|
||||
.filter((attribute) =>
|
||||
attribute.toLowerCase().includes(fieldSearchInput.toLowerCase()),
|
||||
)
|
||||
.map((key) => ({ field: key, value: flattenSpanData[key] }));
|
||||
|
||||
return sortPinnedAttributes(filtered);
|
||||
}, [flattenSpanData, fieldSearchInput, sortPinnedAttributes]);
|
||||
|
||||
return (
|
||||
<div className="attributes-corner">
|
||||
{isSearchVisible &&
|
||||
(datasource.length > 0 || fieldSearchInput.length > 0) && (
|
||||
<Input
|
||||
autoFocus={shouldFocusOnToggle}
|
||||
placeholder="Search for attribute..."
|
||||
className="search-input"
|
||||
value={fieldSearchInput}
|
||||
onChange={(e): void => setFieldSearchInput(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{datasource.length === 0 && fieldSearchInput.length === 0 && (
|
||||
<NoData name="attributes" />
|
||||
)}
|
||||
<section
|
||||
className={cx('attributes-container', isSearchVisible ? 'border-top' : '')}
|
||||
>
|
||||
{datasource
|
||||
.filter((item) => !!item.value && item.value !== '-')
|
||||
.map((item) => (
|
||||
<div
|
||||
className={cx('item', { pinned: pinnedAttributes[item.field] })}
|
||||
key={`${item.field} + ${item.value}`}
|
||||
>
|
||||
<div className="item-key-wrapper">
|
||||
<Typography.Text className="item-key" truncate={1}>
|
||||
{item.field}
|
||||
</Typography.Text>
|
||||
{pinnedAttributes[item.field] && (
|
||||
<Pin size={14} className="pin-icon" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<div className="copy-wrapper">
|
||||
<CopyClipboardHOC
|
||||
entityKey={item.value}
|
||||
textToCopy={item.value}
|
||||
tooltipText={item.value}
|
||||
>
|
||||
<Typography.Text className="item-value" truncate={1}>
|
||||
{item.value}
|
||||
</Typography.Text>
|
||||
</CopyClipboardHOC>
|
||||
</div>
|
||||
<AttributeActions
|
||||
record={item}
|
||||
isPinned={pinnedAttributes[item.field]}
|
||||
onTogglePin={togglePin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Attributes.defaultProps = {
|
||||
shouldFocusOnToggle: false,
|
||||
};
|
||||
|
||||
export default Attributes;
|
||||
@@ -0,0 +1,142 @@
|
||||
.events-table {
|
||||
.no-events {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
.events-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
.event {
|
||||
.ant-collapse {
|
||||
border: none;
|
||||
}
|
||||
.ant-collapse-content {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.ant-collapse-item {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
}
|
||||
.ant-collapse-header {
|
||||
display: flex;
|
||||
padding: 8px 6px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.ant-collapse-expand-icon {
|
||||
padding-inline-start: 0px;
|
||||
padding-inline-end: 0px;
|
||||
}
|
||||
|
||||
.collapse-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.diamond {
|
||||
fill: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
.event-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.attribute-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.attribute-key {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.timestamp-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
.timestamp-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.attribute-value {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
.attribute-value {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import { Collapse, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { Diamond } from '@signozhq/icons';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import NoData from '../NoData/NoData';
|
||||
import EventAttribute from './components/EventAttribute';
|
||||
import NoData from './NoData/NoData';
|
||||
|
||||
import styles from './Events.module.scss';
|
||||
import './Events.styles.scss';
|
||||
|
||||
interface IEventsTableProps {
|
||||
span: SpanV3;
|
||||
span: Span;
|
||||
startTime: number;
|
||||
isSearchVisible: boolean;
|
||||
}
|
||||
@@ -33,20 +33,21 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
setModalContent(null);
|
||||
};
|
||||
|
||||
const events = span.events;
|
||||
const events = span.event;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="events-table">
|
||||
{events.length === 0 && (
|
||||
<div className={styles.noEvents}>
|
||||
<div className="no-events">
|
||||
<NoData name="events" />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.eventsContainer}>
|
||||
<div className="events-container">
|
||||
{isSearchVisible && events.length > 0 && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search for events..."
|
||||
className="search-input"
|
||||
value={fieldSearchInput}
|
||||
onChange={(e): void => setFieldSearchInput(e.target.value)}
|
||||
/>
|
||||
@@ -57,7 +58,7 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
)
|
||||
.map((event) => (
|
||||
<div
|
||||
className={styles.event}
|
||||
className="event"
|
||||
key={`${event.name} ${JSON.stringify(event.attributeMap)}`}
|
||||
>
|
||||
<Collapse
|
||||
@@ -68,36 +69,38 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div className={styles.collapseTitle}>
|
||||
<Diamond size={14} className={styles.diamond} />
|
||||
<Typography.Text>{event.name}</Typography.Text>
|
||||
<div className="collapse-title">
|
||||
<Diamond size={14} className="diamond" />
|
||||
<Typography.Text className="collapse-title-name">
|
||||
{event.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.attributeContainer} key="timeUnixNano">
|
||||
<Typography.Text className={styles.attributeKey}>
|
||||
<div className="event-details">
|
||||
<div className="attribute-container" key="timeUnixNano">
|
||||
<Typography.Text className="attribute-key">
|
||||
Start Time
|
||||
</Typography.Text>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
<div className="timestamp-container">
|
||||
<Typography.Text className="attribute-value">
|
||||
{getYAxisFormattedValue(
|
||||
`${(event.timeUnixNano || 0) / 1e6 - startTime}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
<Typography.Text className="timestamp-text">
|
||||
since trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
<div className="timestamp-container">
|
||||
<Typography.Text className="attribute-value">
|
||||
{getYAxisFormattedValue(
|
||||
`${(event.timeUnixNano || 0) / 1e6 - span.timestamp}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
<Typography.Text className="timestamp-text">
|
||||
since span start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -127,7 +130,9 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
width="80vw"
|
||||
centered
|
||||
>
|
||||
<pre className={styles.fullView}>{modalContent?.content}</pre>
|
||||
<pre className="attribute-with-expandable-popover__full-view">
|
||||
{modalContent?.content}
|
||||
</pre>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
.attribute-with-expandable-popover {
|
||||
&__popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__expand-button {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
&__full-view {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Fullscreen } from '@signozhq/icons';
|
||||
|
||||
import styles from '../Events.module.scss';
|
||||
|
||||
import popoverStyles from './AttributeWithExpandablePopover.module.scss';
|
||||
import './AttributeWithExpandablePopover.styles.scss';
|
||||
|
||||
interface AttributeWithExpandablePopoverProps {
|
||||
attributeKey: string;
|
||||
@@ -19,13 +16,15 @@ function AttributeWithExpandablePopover({
|
||||
onExpand,
|
||||
}: AttributeWithExpandablePopoverProps): JSX.Element {
|
||||
const popoverContent = (
|
||||
<div className={popoverStyles.popover}>
|
||||
<pre className={popoverStyles.preview}>{attributeValue}</pre>
|
||||
<div className="attribute-with-expandable-popover__popover">
|
||||
<pre className="attribute-with-expandable-popover__preview">
|
||||
{attributeValue}
|
||||
</pre>
|
||||
<Button
|
||||
onClick={(): void => onExpand(attributeKey, attributeValue)}
|
||||
size="sm"
|
||||
className={popoverStyles.expandButton}
|
||||
prefix={<Fullscreen size={14} />}
|
||||
size="small"
|
||||
className="attribute-with-expandable-popover__expand-button"
|
||||
icon={<Fullscreen size={14} />}
|
||||
>
|
||||
Expand
|
||||
</Button>
|
||||
@@ -33,16 +32,16 @@ function AttributeWithExpandablePopover({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.attributeContainer} key={attributeKey}>
|
||||
<div className="attribute-container" key={attributeKey}>
|
||||
<Tooltip title={attributeKey}>
|
||||
<Typography.Text className={styles.attributeKey} truncate={1}>
|
||||
<Typography.Text className="attribute-key" truncate={1}>
|
||||
{attributeKey}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<div className="wrapper">
|
||||
<Popover content={popoverContent} trigger="hover" placement="topRight">
|
||||
<Typography.Text className={styles.attributeValue} truncate={1}>
|
||||
<Typography.Text className="attribute-value" truncate={1}>
|
||||
{attributeValue}
|
||||
</Typography.Text>
|
||||
</Popover>
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from '../Events.module.scss';
|
||||
|
||||
import AttributeWithExpandablePopover from './AttributeWithExpandablePopover';
|
||||
|
||||
const EXPANDABLE_ATTRIBUTE_KEYS = ['exception.stacktrace', 'exception.message'];
|
||||
@@ -34,15 +32,15 @@ function EventAttribute({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.attributeContainer} key={attributeKey}>
|
||||
<div className="attribute-container" key={attributeKey}>
|
||||
<Tooltip title={attributeKey}>
|
||||
<Typography.Text className={styles.attributeKey} truncate={1}>
|
||||
<Typography.Text className="attribute-key" truncate={1}>
|
||||
{attributeKey}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<div className={styles.wrapper}>
|
||||
<div className="wrapper">
|
||||
<Tooltip title={attributeValue}>
|
||||
<Typography.Text className={styles.attributeValue} truncate={1}>
|
||||
<Typography.Text className="attribute-value" truncate={1}>
|
||||
{attributeValue}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
@@ -0,0 +1,52 @@
|
||||
.no-linked-spans {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.linked-spans-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
|
||||
.item-key {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.item-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.56px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import NoData from '../NoData/NoData';
|
||||
|
||||
import './LinkedSpans.styles.scss';
|
||||
|
||||
interface LinkedSpansProps {
|
||||
span: Span;
|
||||
}
|
||||
|
||||
interface SpanReference {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
refType: string;
|
||||
}
|
||||
|
||||
function LinkedSpans(props: LinkedSpansProps): JSX.Element {
|
||||
const { span } = props;
|
||||
|
||||
const getLink = useCallback((item: SpanReference): string | null => {
|
||||
if (!item.traceId || !item.spanId) {
|
||||
return null;
|
||||
}
|
||||
return withBasePath(
|
||||
`${ROUTES.TRACE}/${item.traceId}${formUrlParams({
|
||||
spanId: item.spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Filter out CHILD_OF references as they are parent-child relationships
|
||||
const linkedSpans =
|
||||
span.references?.filter((ref: SpanReference) => ref.refType !== 'CHILD_OF') ||
|
||||
[];
|
||||
|
||||
if (linkedSpans.length === 0) {
|
||||
return (
|
||||
<div className="no-linked-spans">
|
||||
<NoData name="linked spans" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="linked-spans-container">
|
||||
{linkedSpans.map((item: SpanReference) => {
|
||||
const link = getLink(item);
|
||||
return (
|
||||
<div className="item" key={item.spanId}>
|
||||
<Typography.Text className="item-key" truncate={1}>
|
||||
Linked Span ID
|
||||
</Typography.Text>
|
||||
<div className="value-wrapper">
|
||||
<Tooltip title={item.spanId}>
|
||||
{link ? (
|
||||
<Typography.Link href={link} className="item-value" truncate={1}>
|
||||
{item.spanId}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
<Button type="link" className="item-value" disabled>
|
||||
{item.spanId}
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkedSpans;
|
||||
@@ -0,0 +1,20 @@
|
||||
.no-data {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-direction: column;
|
||||
|
||||
.no-data-img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
import './NoData.styles.scss';
|
||||
|
||||
interface INoDataProps {
|
||||
name: string;
|
||||
@@ -12,9 +12,9 @@ function NoData(props: INoDataProps): JSX.Element {
|
||||
const { name } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.noData}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
|
||||
<Typography.Text className={styles.noDataText}>
|
||||
<div className="no-data">
|
||||
<img src={noDataUrl} alt="no-data" className="no-data-img" />
|
||||
<Typography.Text className="no-data-text">
|
||||
No {name} found for selected span
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -0,0 +1,687 @@
|
||||
.span-details-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 44px); //44px -> trace details top bar
|
||||
border-left: 1px solid var(--l1-border);
|
||||
overflow-y: auto !important;
|
||||
&:not(&-docked) {
|
||||
min-width: 450px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.dot {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0px;
|
||||
|
||||
.item {
|
||||
padding: 8px 12px;
|
||||
&,
|
||||
.attribute-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
position: relative; // ensure absolutely-positioned children anchor to the row
|
||||
}
|
||||
|
||||
// Show attribute actions on hover for hardcoded rows
|
||||
.attribute-actions-wrapper {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
// style the action button group
|
||||
.action-btn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-border);
|
||||
padding: 4px;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--l1-border);
|
||||
.attribute-actions-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.span-name-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.loading-spinner-container {
|
||||
padding: 4px 8px;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.span-percentile-value-container {
|
||||
.span-percentile-value {
|
||||
color: var(--bg-sakura-400);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
|
||||
border-radius: 0 50px 50px 0;
|
||||
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
min-width: 48px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
|
||||
border-left: 1px solid var(--l1-border);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
word-break: normal;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&.span-percentile-value-container-open {
|
||||
.span-percentile-value {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentiles-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
fill: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
|
||||
stroke-width: 1px;
|
||||
stroke: var(--l1-border);
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
|
||||
.span-percentiles-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.span-percentiles-header-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
|
||||
.span-percentile-content-title {
|
||||
.span-percentile-value {
|
||||
color: var(--bg-sakura-400);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
}
|
||||
|
||||
.span-percentile-value-loader {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
margin-right: 4px;
|
||||
margin-left: 4px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-timerange {
|
||||
width: 100%;
|
||||
|
||||
.span-percentile-timerange-select {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table {
|
||||
.span-percentile-values-table-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.span-percentile-values-table-header {
|
||||
color: var(--l2-foreground);
|
||||
text-align: right;
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 181.818% */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-rows {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.span-percentile-values-table-data-rows-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-skeleton-title {
|
||||
width: 100% !important;
|
||||
margin-top: 0px !important;
|
||||
}
|
||||
|
||||
.ant-skeleton-paragraph {
|
||||
margin-top: 8px;
|
||||
|
||||
& > li + li {
|
||||
margin-top: 10px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0px 4px;
|
||||
|
||||
.span-percentile-values-table-data-row-key {
|
||||
flex: 0 0 auto;
|
||||
color: var(--l1-foreground);
|
||||
text-align: right;
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row-value {
|
||||
color: var(--l2-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on,
|
||||
'ss02' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
flex: 1;
|
||||
height: 0; /* line only */
|
||||
margin: 0 8px;
|
||||
border-top: 1px dashed var(--l1-border);
|
||||
|
||||
/* Use border image to control dash length & spacing */
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid; /* temporary solid for image */
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#1d212d 0,
|
||||
#1d212d 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.current-span-percentile-row {
|
||||
border-radius: 2px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--primary-background) 20%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.span-percentile-values-table-data-row-key {
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
flex: 1;
|
||||
height: 0; /* line only */
|
||||
margin: 0 8px;
|
||||
border-top: 1px dashed #abbdff;
|
||||
|
||||
/* Use border image to control dash length & spacing */
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid; /* temporary solid for image */
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#abbdff 0,
|
||||
#abbdff 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row-value {
|
||||
color: var(--text-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resource-attributes-select-container {
|
||||
overflow: hidden;
|
||||
width: calc(100% + 16px);
|
||||
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: -8px;
|
||||
z-index: 1000;
|
||||
|
||||
.resource-attributes-select-container-header {
|
||||
.resource-attributes-select-container-input {
|
||||
border-radius: 0px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 36px;
|
||||
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(139deg, var(--card) 0%, var(--card) 98.68%);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-attributes-items {
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.resource-attributes-select-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 12px;
|
||||
|
||||
.resource-attributes-select-item-checkbox {
|
||||
.ant-checkbox-disabled {
|
||||
background-color: var(--primary-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.resource-attributes-select-item-value {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-key {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.attribute-container .wrapper,
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.attribute-value {
|
||||
padding: 2px 8px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
}
|
||||
|
||||
.service {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
width: fit-content;
|
||||
|
||||
.dot {
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
border-radius: 0px;
|
||||
border: none;
|
||||
background: var(--l1-border);
|
||||
|
||||
.service-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.related-signals-section {
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.ant-btn.ant-btn-default {
|
||||
padding: 0 15px;
|
||||
&:not(:hover) {
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attributes-events {
|
||||
.details-drawer-tabs {
|
||||
.ant-tabs-extra-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search-icon {
|
||||
width: 33px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before {
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
margin: 0 !important;
|
||||
padding: 0 2px !important;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attributes-tab-btn,
|
||||
.events-tab-btn,
|
||||
.linked-spans-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
margin-right: 8px;
|
||||
gap: 4px;
|
||||
|
||||
.tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 10%, transparent);
|
||||
color: var(--l2-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.065px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.attributes-tab-btn:hover,
|
||||
.events-tab-btn:hover,
|
||||
.linked-spans-tab-btn:hover {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-tooltip {
|
||||
.ant-tooltip-content {
|
||||
width: 300px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.span-percentile-tooltip-text {
|
||||
color: var(--l2-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
.span-percentile-tooltip-text-percentile {
|
||||
color: var(--text-sakura-500);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.span-percentile-tooltip-text-link {
|
||||
color: var(--l2-foreground);
|
||||
text-align: right;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-details-drawer-docked {
|
||||
width: 48px;
|
||||
flex: 0 48px !important;
|
||||
|
||||
.header {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.resizable-handle {
|
||||
box-sizing: border-box;
|
||||
border: 2px solid transparent;
|
||||
&:hover,
|
||||
&[data-resize-handle-state='drag'],
|
||||
&[data-resize-handle-state='hover'] {
|
||||
border-color: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.linked-spans-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
1013
frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.tsx
Normal file
1013
frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -32,7 +33,7 @@ import { v4 as uuid } from 'uuid';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './SpanLogs.module.scss';
|
||||
import './spanLogs.styles.scss';
|
||||
|
||||
interface SpanLogsProps {
|
||||
traceId: string;
|
||||
@@ -200,13 +201,11 @@ function SpanLogs({
|
||||
|
||||
const renderFooter = useCallback((): JSX.Element | null => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
|
||||
);
|
||||
return <div className="logs-loading-skeleton"> Loading more logs ... </div>;
|
||||
}
|
||||
|
||||
if (hasReachedEndOfLogs) {
|
||||
return <div className={styles.logsLoadingSkeleton}> *** End *** </div>;
|
||||
return <div className="logs-loading-skeleton"> *** End *** </div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -214,10 +213,10 @@ function SpanLogs({
|
||||
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<div className={styles.spanLogsListContainer}>
|
||||
<div className="span-logs-list-container">
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className={styles.spanLogsVirtuoso}
|
||||
className="span-logs-virtuoso"
|
||||
key="span-logs-virtuoso"
|
||||
style={{ height: '100%' }}
|
||||
data={logs}
|
||||
@@ -235,19 +234,17 @@ function SpanLogs({
|
||||
);
|
||||
|
||||
const renderNoLogsFound = (): JSX.Element => (
|
||||
<div className={styles.spanLogsEmptyContent}>
|
||||
<section className={styles.description}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
|
||||
<Typography.Text className={styles.noDataText1}>
|
||||
<div className="span-logs-empty-content">
|
||||
<section className="description">
|
||||
<img src={noDataUrl} alt="no-data" className="no-data-img" />
|
||||
<Typography.Text className="no-data-text-1">
|
||||
No logs found for selected span.
|
||||
<span className={styles.noDataText2}>
|
||||
View logs for the current trace.
|
||||
</span>
|
||||
<span className="no-data-text-2">View logs for the current trace.</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.actionSection}>
|
||||
<section className="action-section">
|
||||
<Button
|
||||
className={styles.actionBtn}
|
||||
className="action-btn"
|
||||
variant="action"
|
||||
prefix={<Compass size={14} />}
|
||||
onClick={handleExplorerPageRedirect}
|
||||
@@ -284,7 +281,11 @@ function SpanLogs({
|
||||
return renderContent;
|
||||
};
|
||||
|
||||
return <div className={styles.spanLogs}>{renderSpanLogsContent()}</div>;
|
||||
return (
|
||||
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
|
||||
{renderSpanLogsContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SpanLogs.defaultProps = {
|
||||
emptyStateConfig: undefined,
|
||||
@@ -0,0 +1,76 @@
|
||||
.span-logs {
|
||||
margin-inline: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&-virtuoso {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
&-list-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.logs-loading-skeleton {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-empty-content {
|
||||
height: 100%;
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 96px;
|
||||
gap: 12px;
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: 320px;
|
||||
|
||||
.no-data-img {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.no-data-text-1 {
|
||||
color: var(--l2-foreground);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
.no-data-text-2 {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.action-section {
|
||||
width: 320px;
|
||||
|
||||
.action-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.span-related-signals-drawer {
|
||||
.ant-drawer-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 16px 15px;
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
--divider-vertical-margin: 10px;
|
||||
}
|
||||
.ant-drawer-close {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.view-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.views-tabs-container {
|
||||
padding: 16px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.open-in-explorer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
width: 114px;
|
||||
height: 32px;
|
||||
|
||||
.view-title {
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__applied-filters {
|
||||
padding: 11px;
|
||||
margin-inline: 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__filters-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__filter-tag {
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
cursor: default;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.infra-metrics-container {
|
||||
padding-inline: 16px;
|
||||
.infra-metrics-card {
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Drawer } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { BarChart, Compass, X } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { RelatedSignalsViews } from '../constants';
|
||||
import SpanLogs from '../SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
|
||||
import { hasInfraMetadata } from '../utils';
|
||||
|
||||
import './SpanRelatedSignals.styles.scss';
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
interface SpanRelatedSignalsProps {
|
||||
selectedSpan: Span;
|
||||
traceStartTime: number;
|
||||
traceEndTime: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialView: RelatedSignalsViews;
|
||||
}
|
||||
|
||||
function SpanRelatedSignals({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
isOpen,
|
||||
onClose,
|
||||
initialView,
|
||||
}: SpanRelatedSignalsProps): JSX.Element {
|
||||
const [selectedView, setSelectedView] =
|
||||
useState<RelatedSignalsViews>(initialView);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Extract infrastructure metadata from span attributes
|
||||
const infraMetadata = useMemo(() => {
|
||||
// Only return metadata if span has infrastructure metadata
|
||||
if (!hasInfraMetadata(selectedSpan)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clusterName: selectedSpan.tagMap['k8s.cluster.name'] || '',
|
||||
podName: selectedSpan.tagMap['k8s.pod.name'] || '',
|
||||
nodeName: selectedSpan.tagMap['k8s.node.name'] || '',
|
||||
hostName: selectedSpan.tagMap['host.name'] || '',
|
||||
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
|
||||
};
|
||||
}, [selectedSpan]);
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.traceId,
|
||||
spanId: selectedSpan.spanId,
|
||||
timeRange: {
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: isOpen,
|
||||
});
|
||||
|
||||
const handleTabChange = useCallback((value: string): void => {
|
||||
setSelectedView(value as RelatedSignalsViews);
|
||||
}, []);
|
||||
|
||||
const tabOptions = useMemo(() => {
|
||||
const baseOptions = [
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<LogsIcon width={14} height={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.LOGS,
|
||||
},
|
||||
];
|
||||
|
||||
// Add Infra option if infrastructure metadata is available
|
||||
if (infraMetadata) {
|
||||
baseOptions.push({
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.INFRA,
|
||||
});
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [infraMetadata]);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
|
||||
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
|
||||
|
||||
const traceIdFilter = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.traceId,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: traceIdFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
|
||||
searchParams.set(QueryParams.startTime, startTimeMs.toString());
|
||||
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||
|
||||
window.open(
|
||||
getAbsoluteUrl(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
|
||||
|
||||
const emptyStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="50%"
|
||||
title={
|
||||
<>
|
||||
<Divider
|
||||
type="vertical"
|
||||
className="span-related-signals-drawer__divider"
|
||||
/>
|
||||
<Typography.Text className="title">
|
||||
Related Signals - {selectedSpan.name}
|
||||
</Typography.Text>
|
||||
</>
|
||||
}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
|
||||
}}
|
||||
className="span-related-signals-drawer"
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} />}
|
||||
>
|
||||
{selectedSpan && (
|
||||
<div className="span-related-signals-drawer__content">
|
||||
<div className="views-tabs-container">
|
||||
<SignozRadioGroup
|
||||
value={selectedView}
|
||||
options={tabOptions}
|
||||
onChange={handleTabChange}
|
||||
className="related-signals-radio"
|
||||
/>
|
||||
{selectedView === RelatedSignalsViews.LOGS && (
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="open-in-explorer"
|
||||
onClick={handleExplorerPageRedirect}
|
||||
data-testid="open-in-explorer-button"
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedView === RelatedSignalsViews.LOGS && (
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.traceId}
|
||||
spanId={selectedSpan.spanId}
|
||||
timeRange={{
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isFetching={isFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === RelatedSignalsViews.INFRA && infraMetadata && (
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanRelatedSignals;
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import AttributeActions from '../Attributes/AttributeActions';
|
||||
|
||||
// Mock only Popover from antd to simplify hover/open behavior while keeping other components real
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
const MockPopover = ({
|
||||
content,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
...rest
|
||||
}: any): JSX.Element => (
|
||||
<div
|
||||
data-testid="mock-popover-wrapper"
|
||||
onMouseEnter={(): void => onOpenChange?.(true)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{open ? <div data-testid="mock-popover-content">{content}</div> : null}
|
||||
</div>
|
||||
);
|
||||
return { ...actual, Popover: MockPopover };
|
||||
});
|
||||
|
||||
// Mock getAggregateKeys API used inside useTraceActions to resolve autocomplete keys
|
||||
jest.mock('api/queryBuilder/getAttributeKeys', () => ({
|
||||
getAggregateKeys: jest.fn().mockResolvedValue({
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.method',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const record = { field: 'http.method', value: 'GET' };
|
||||
|
||||
describe('AttributeActions (unit)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders core action buttons (pin, filter in/out, more)', async () => {
|
||||
render(<AttributeActions record={record} isPinned={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Pin attribute' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Filter for value' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Filter out value' }),
|
||||
).toBeInTheDocument();
|
||||
// more actions (ellipsis) button
|
||||
expect(screen.getByTestId('attribute-actions-more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies "Filter for" and calls redirectWithQueryBuilderData with correct query', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const filterForBtn = screen.getByRole('button', { name: 'Filter for value' });
|
||||
|
||||
await userEvent.click(filterForBtn);
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies "Filter out" and calls redirectWithQueryBuilderData with correct query', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const filterOutBtn = screen.getByRole('button', { name: 'Filter out value' });
|
||||
|
||||
await userEvent.click(filterOutBtn);
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '!=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens more actions on hover and calls Group By handler; closes after click', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const ellipsisBtn = screen.getByTestId('attribute-actions-more');
|
||||
expect(ellipsisBtn).toBeInTheDocument();
|
||||
|
||||
// hover to trigger Popover open via mock
|
||||
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
|
||||
|
||||
// content appears
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Group By Attribute')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Group By Attribute'));
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
groupBy: expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'http.method' }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
// After clicking group by, popover should close
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('mock-popover-content')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides pin button when showPinned=false', async () => {
|
||||
render(<AttributeActions record={record} showPinned={false} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /pin attribute/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides copy options when showCopyOptions=false', async () => {
|
||||
render(<AttributeActions record={record} showCopyOptions={false} />);
|
||||
const ellipsisBtn = screen.getByTestId('attribute-actions-more');
|
||||
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Copy Field Name')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText('Copy Field Value')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
const mockNotifications = {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
const mockSetCopy = jest.fn();
|
||||
const mockQueryClient = {
|
||||
fetchQuery: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({ notifications: mockNotifications }),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
|
||||
}));
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: (): any => mockQueryClient,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the API response for getAggregateKeys
|
||||
const mockAggregateKeysResponse = {
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.method',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: true,
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
isColumn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
|
||||
});
|
||||
|
||||
// Mock trace data with realistic span attributes
|
||||
const createMockSpan = (): Span => ({
|
||||
spanId: '28a8a67365d0bd8b',
|
||||
traceId: '000000000000000071dc9b0a338729b4',
|
||||
name: 'HTTP GET /api/users',
|
||||
timestamp: 1699872000000000,
|
||||
durationNano: 150000000,
|
||||
serviceName: 'frontend-service',
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'OK',
|
||||
statusMessage: '',
|
||||
tagMap: {
|
||||
'http.method': 'GET',
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: '/api/users?page=1',
|
||||
'http.status_code': '200',
|
||||
'service.name': 'frontend-service',
|
||||
'span.kind': 'server',
|
||||
'user.id': '12345',
|
||||
'request.id': 'req-abc-123',
|
||||
},
|
||||
event: [],
|
||||
references: [],
|
||||
hasError: false,
|
||||
rootSpanId: '',
|
||||
parentSpanId: '',
|
||||
kind: 0,
|
||||
rootName: '',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
});
|
||||
const renderSpanDetailsDrawer = (span: Span = createMockSpan()): any => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const component = render(
|
||||
<MockQueryClientProvider>
|
||||
<AppProvider>
|
||||
<MemoryRouter>
|
||||
<Route>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={span}
|
||||
traceStartTime={span.timestamp}
|
||||
traceEndTime={span.timestamp + span.durationNano}
|
||||
/>
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
return { ...component, user };
|
||||
};
|
||||
|
||||
describe('AttributeActions User Flow Tests', () => {
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Complete Attribute Actions User Flow', () => {
|
||||
it('should allow user to interact with span attribute actions from trace detail page', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Verify Attributes tab is displayed with table view
|
||||
expect(screen.getByText('Attributes')).toBeInTheDocument();
|
||||
|
||||
// Verify attributes are displayed
|
||||
expect(screen.getByText('http.method')).toBeInTheDocument();
|
||||
expect(screen.getByText('GET')).toBeInTheDocument();
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('frontend-service')[0]).toBeInTheDocument();
|
||||
|
||||
// Find an attribute row to test actions on
|
||||
const httpMethodRow = screen.getByText('http.method').closest('.item');
|
||||
expect(httpMethodRow).toBeInTheDocument();
|
||||
|
||||
// Action buttons are always mounted in the DOM (only CSS-hidden until :hover),
|
||||
// so we can query them directly without simulating a pointer hover.
|
||||
const actionButtons = httpMethodRow!.querySelector('.action-btn');
|
||||
expect(actionButtons).toBeInTheDocument();
|
||||
|
||||
const filterForButton = httpMethodRow!.querySelector(
|
||||
'[aria-label="Filter for value"]',
|
||||
) as HTMLElement;
|
||||
const filterOutButton = httpMethodRow!.querySelector(
|
||||
'[aria-label="Filter out value"]',
|
||||
) as HTMLElement;
|
||||
expect(filterForButton).toBeInTheDocument();
|
||||
expect(filterOutButton).toBeInTheDocument();
|
||||
|
||||
// Test "Filter for" action — use fireEvent to skip userEvent's pointer
|
||||
// simulation and the Antd Tooltip mouseEnterDelay timers it triggers.
|
||||
fireEvent.click(filterForButton);
|
||||
|
||||
// Verify navigation to traces explorer with inclusive filter
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
// Reset mock for next test
|
||||
mockRedirectWithQueryBuilderData.mockClear();
|
||||
|
||||
// Test "Filter out" action
|
||||
fireEvent.click(filterOutButton);
|
||||
|
||||
// Verify navigation to traces explorer with exclusive filter
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '!=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
// Verify more actions button exists (popover functionality is tested in unit tests)
|
||||
const moreActionsButton = httpMethodRow!
|
||||
.querySelector('.lucide-ellipsis')
|
||||
?.closest('button');
|
||||
expect(moreActionsButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Filter Replacement Flow', () => {
|
||||
it('should replace previous filter when applying multiple filters on same field', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Find the http.method attribute row
|
||||
const httpMethodRow = screen.getByText('http.method').closest('.item');
|
||||
expect(httpMethodRow).toBeInTheDocument();
|
||||
|
||||
// Action buttons are always mounted (CSS-hidden until :hover, which jsdom
|
||||
// doesn't evaluate), so we can click them directly via fireEvent and skip
|
||||
// userEvent's pointer simulation + Antd Tooltip mouseEnterDelay timers.
|
||||
const filterForButton = httpMethodRow!.querySelector(
|
||||
'[aria-label="Filter for value"]',
|
||||
) as HTMLElement;
|
||||
expect(filterForButton).toBeInTheDocument();
|
||||
fireEvent.click(filterForButton);
|
||||
|
||||
// Verify first filter was applied
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
// Reset and simulate existing filter in current query
|
||||
mockRedirectWithQueryBuilderData.mockClear();
|
||||
|
||||
// Apply second filter on same field (should replace, not accumulate)
|
||||
const filterOutButton = httpMethodRow!.querySelector(
|
||||
'[aria-label="Filter out value"]',
|
||||
) as HTMLElement;
|
||||
expect(filterOutButton).toBeInTheDocument();
|
||||
fireEvent.click(filterOutButton);
|
||||
|
||||
// Verify the new call contains only the new filter (replacement behavior)
|
||||
await waitFor(() => {
|
||||
const lastCall =
|
||||
mockRedirectWithQueryBuilderData.mock.calls[
|
||||
mockRedirectWithQueryBuilderData.mock.calls.length - 1
|
||||
];
|
||||
const queryData = lastCall[0].builder.queryData[0];
|
||||
const httpMethodFilters = queryData.filters.items.filter(
|
||||
(item: any) => item.key.key === 'http.method',
|
||||
);
|
||||
|
||||
// Should have only one filter for http.method (the new one)
|
||||
expect(httpMethodFilters).toHaveLength(1);
|
||||
expect(httpMethodFilters[0].op).toBe('!=');
|
||||
expect(httpMethodFilters[0].value).toBe('GET');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle attributes with special characters and JSON values', async () => {
|
||||
const spanWithSpecialAttrs = createMockSpan();
|
||||
spanWithSpecialAttrs.tagMap = {
|
||||
'request.headers.content-type': 'application/json',
|
||||
'response.body': '{"status":"success","data":[]}',
|
||||
'trace.annotation': '"quoted_string_value"',
|
||||
};
|
||||
|
||||
const { user } = renderSpanDetailsDrawer(spanWithSpecialAttrs);
|
||||
|
||||
// Test attribute with dashes
|
||||
expect(screen.getByText('request.headers.content-type')).toBeInTheDocument();
|
||||
expect(screen.getByText('application/json')).toBeInTheDocument();
|
||||
|
||||
// Test JSON value
|
||||
expect(screen.getByText('response.body')).toBeInTheDocument();
|
||||
|
||||
// Test quoted string value - should remove surrounding quotes when copying
|
||||
const quotedAttrRow = screen.getByText('trace.annotation').closest('.item');
|
||||
await user.hover(quotedAttrRow!);
|
||||
|
||||
const actionButtons = quotedAttrRow!.querySelectorAll('.action-btn button');
|
||||
const moreActionsButton = actionButtons[actionButtons.length - 1];
|
||||
await user.hover(moreActionsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const copyFieldValueButton = screen.getByText('Copy Field Value');
|
||||
fireEvent.click(copyFieldValueButton);
|
||||
|
||||
// Verify quotes are stripped from copied value
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('quoted_string_value');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty attributes gracefully', async () => {
|
||||
const spanWithNoAttrs = createMockSpan();
|
||||
spanWithNoAttrs.tagMap = {};
|
||||
|
||||
renderSpanDetailsDrawer(spanWithNoAttrs);
|
||||
|
||||
// Verify no attributes message is displayed
|
||||
expect(
|
||||
screen.getByText('No attributes found for selected span'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,495 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
import {
|
||||
expectedHostOnlyMetadata,
|
||||
expectedInfraMetadata,
|
||||
expectedNodeOnlyMetadata,
|
||||
expectedPodOnlyMetadata,
|
||||
mockEmptyMetricsResponse,
|
||||
mockNodeMetricsResponse,
|
||||
mockPodMetricsResponse,
|
||||
mockSpanWithHostOnly,
|
||||
mockSpanWithInfraMetadata,
|
||||
mockSpanWithNodeOnly,
|
||||
mockSpanWithoutInfraMetadata,
|
||||
mockSpanWithPodOnly,
|
||||
} from './infraMetricsTestData';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${ROUTES.TRACE_DETAIL}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
});
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock uplot to avoid rendering issues
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock GetMetricQueryRange to track API calls
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock generateColor
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: jest.fn().mockReturnValue('#1f77b4'),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
function OverlayScrollbar({ children }: any) {
|
||||
return <div data-testid="overlay-scrollbar">{children}</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock Virtuoso
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock InfraMetrics component for focused testing
|
||||
jest.mock(
|
||||
'container/LogDetailedView/InfraMetrics/InfraMetrics',
|
||||
() =>
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
function MockInfraMetrics({
|
||||
podName,
|
||||
nodeName,
|
||||
hostName,
|
||||
clusterName,
|
||||
timestamp,
|
||||
dataSource,
|
||||
}: any) {
|
||||
return (
|
||||
<div data-testid="infra-metrics">
|
||||
<div data-testid="infra-pod-name">{podName}</div>
|
||||
<div data-testid="infra-node-name">{nodeName}</div>
|
||||
<div data-testid="infra-host-name">{hostName}</div>
|
||||
<div data-testid="infra-cluster-name">{clusterName}</div>
|
||||
<div data-testid="infra-timestamp">{timestamp}</div>
|
||||
<div data-testid="infra-data-source">{dataSource}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
// eslint-disable-next-line sonarjs/no-unused-collection
|
||||
let apiCallHistory: any[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
apiCallHistory = [];
|
||||
mockSafeNavigate.mockClear();
|
||||
mockWindowOpen.mockClear();
|
||||
mockUpdateAllQueriesOperators.mockClear();
|
||||
|
||||
// Setup API call tracking for infra metrics
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||
apiCallHistory.push(query);
|
||||
|
||||
// Return mock responses for different query types
|
||||
if (
|
||||
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
|
||||
(item: any) => item.key?.key === 'k8s_pod_name',
|
||||
)
|
||||
) {
|
||||
return Promise.resolve(mockPodMetricsResponse);
|
||||
}
|
||||
|
||||
if (
|
||||
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
|
||||
(item: any) => item.key?.key === 'k8s_node_name',
|
||||
)
|
||||
) {
|
||||
return Promise.resolve(mockNodeMetricsResponse);
|
||||
}
|
||||
|
||||
return Promise.resolve(mockEmptyMetricsResponse);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Mock QueryBuilder context value
|
||||
const mockQueryBuilderContextValue = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stagedQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
panelType: 'list',
|
||||
redirectWithQuery: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
handleStageQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
};
|
||||
|
||||
const renderSpanDetailsDrawer = (props = {}): void => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithInfraMetadata}
|
||||
traceStartTime={1640995200000} // 2022-01-01 00:00:00
|
||||
traceEndTime={1640995260000} // 2022-01-01 00:01:00
|
||||
{...props}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it('should detect infra metadata from span attributes', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on metrics tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
expect(infraMetricsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
// Wait for infra metrics to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify metadata extraction
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.hostName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
|
||||
DataSource.TRACES,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show infra tab when span lacks infra metadata', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithoutInfraMetadata}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Should NOT show infra tab, only logs tab
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /metrics/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show infra tab when span has infra metadata', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Should show both logs and infra tabs
|
||||
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle pod-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithPodOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify pod-only metadata
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.hostName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle node-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithNodeOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify node-only metadata
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.hostName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle host-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithHostOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify host-only metadata
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.hostName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.clusterName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch between logs and infra tabs correctly', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Initially should show logs tab content
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
|
||||
expect(logsButton).toBeInTheDocument();
|
||||
expect(infraMetricsButton).toBeInTheDocument();
|
||||
|
||||
// Ensure logs tab is active and wait for content to load
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on infra tab
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should not show logs content anymore
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Switch back to logs tab
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Should not show infra metrics anymore
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('infra-metrics')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify logs content is shown again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct data source and handle multiple infra identifiers', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Should show infra tab when span has any of: clusterName, podName, nodeName, hostName
|
||||
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify TRACES data source is passed
|
||||
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
|
||||
DataSource.TRACES,
|
||||
);
|
||||
|
||||
// All infra identifiers should be passed through
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
'test-pod-abc123',
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
'test-node-456',
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
'test-host.example.com',
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
'test-cluster',
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
// Constants
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_CLUSTER_NAME = 'test-cluster';
|
||||
const TEST_POD_NAME = 'test-pod-abc123';
|
||||
const TEST_NODE_NAME = 'test-node-456';
|
||||
const TEST_HOST_NAME = 'test-host.example.com';
|
||||
|
||||
// Mock span with infrastructure metadata (pod + node + host)
|
||||
export const mockSpanWithInfraMetadata: Span = {
|
||||
spanId: 'infra-span-id',
|
||||
traceId: TEST_TRACE_ID,
|
||||
name: 'api-service',
|
||||
serviceName: 'api-service',
|
||||
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
|
||||
durationNano: 2000000000, // 2 seconds in nanoseconds
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'STATUS_CODE_OK',
|
||||
statusMessage: '',
|
||||
parentSpanId: '',
|
||||
references: [],
|
||||
event: [],
|
||||
tagMap: {
|
||||
'k8s.cluster.name': TEST_CLUSTER_NAME,
|
||||
'k8s.pod.name': TEST_POD_NAME,
|
||||
'k8s.node.name': TEST_NODE_NAME,
|
||||
'host.name': TEST_HOST_NAME,
|
||||
'service.name': 'api-service',
|
||||
'http.method': 'GET',
|
||||
},
|
||||
hasError: false,
|
||||
rootSpanId: '',
|
||||
kind: 0,
|
||||
rootName: '',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
// Mock span with only pod metadata
|
||||
export const mockSpanWithPodOnly: Span = {
|
||||
...mockSpanWithInfraMetadata,
|
||||
spanId: 'pod-only-span-id',
|
||||
tagMap: {
|
||||
'k8s.cluster.name': TEST_CLUSTER_NAME,
|
||||
'k8s.pod.name': TEST_POD_NAME,
|
||||
'service.name': 'api-service',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock span with only node metadata
|
||||
export const mockSpanWithNodeOnly: Span = {
|
||||
...mockSpanWithInfraMetadata,
|
||||
spanId: 'node-only-span-id',
|
||||
tagMap: {
|
||||
'k8s.node.name': TEST_NODE_NAME,
|
||||
'service.name': 'api-service',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock span with only host metadata
|
||||
export const mockSpanWithHostOnly: Span = {
|
||||
...mockSpanWithInfraMetadata,
|
||||
spanId: 'host-only-span-id',
|
||||
tagMap: {
|
||||
'host.name': TEST_HOST_NAME,
|
||||
'service.name': 'api-service',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock span without any infrastructure metadata
|
||||
export const mockSpanWithoutInfraMetadata: Span = {
|
||||
...mockSpanWithInfraMetadata,
|
||||
spanId: 'no-infra-span-id',
|
||||
tagMap: {
|
||||
'service.name': 'api-service',
|
||||
'http.method': 'GET',
|
||||
'http.status_code': '200',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock infrastructure metrics API responses
|
||||
export const mockPodMetricsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { pod_name: TEST_POD_NAME },
|
||||
values: [
|
||||
[1640995200, '0.5'], // CPU usage
|
||||
[1640995260, '0.6'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockNodeMetricsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { node_name: TEST_NODE_NAME },
|
||||
values: [
|
||||
[1640995200, '2.1'], // Memory usage
|
||||
[1640995260, '2.3'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockEmptyMetricsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Expected infrastructure metadata extractions
|
||||
export const expectedInfraMetadata = {
|
||||
clusterName: TEST_CLUSTER_NAME,
|
||||
podName: TEST_POD_NAME,
|
||||
nodeName: TEST_NODE_NAME,
|
||||
hostName: TEST_HOST_NAME,
|
||||
};
|
||||
|
||||
export const expectedPodOnlyMetadata = {
|
||||
clusterName: TEST_CLUSTER_NAME,
|
||||
podName: TEST_POD_NAME,
|
||||
nodeName: '',
|
||||
hostName: '',
|
||||
spanTimestamp: '2022-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
export const expectedNodeOnlyMetadata = {
|
||||
clusterName: '',
|
||||
podName: '',
|
||||
nodeName: TEST_NODE_NAME,
|
||||
hostName: '',
|
||||
spanTimestamp: '2022-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
export const expectedHostOnlyMetadata = {
|
||||
clusterName: '',
|
||||
podName: '',
|
||||
nodeName: '',
|
||||
hostName: TEST_HOST_NAME,
|
||||
spanTimestamp: '2022-01-01T00:00:00.000Z',
|
||||
};
|
||||
224
frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts
Normal file
224
frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
// Constants
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SERVICE = 'test-service';
|
||||
|
||||
// Mock span data
|
||||
export const mockSpan: Span = {
|
||||
spanId: TEST_SPAN_ID,
|
||||
traceId: TEST_TRACE_ID,
|
||||
name: TEST_SERVICE,
|
||||
serviceName: TEST_SERVICE,
|
||||
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
|
||||
durationNano: 1000000000, // 1 second in nanoseconds
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'STATUS_CODE_OK',
|
||||
statusMessage: '',
|
||||
parentSpanId: '',
|
||||
references: [],
|
||||
event: [],
|
||||
tagMap: {
|
||||
'http.method': 'GET',
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: '/api/test',
|
||||
'http.status_code': '200',
|
||||
},
|
||||
hasError: false,
|
||||
rootSpanId: '',
|
||||
kind: 0,
|
||||
rootName: '',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
// Mock span with long status message (> 100 characters) for testing truncation
|
||||
export const mockSpanWithLongStatusMessage: Span = {
|
||||
...mockSpan,
|
||||
statusMessage:
|
||||
'Error: Connection timeout occurred while trying to reach the database server. The connection pool was exhausted and all retry attempts failed after 30 seconds.',
|
||||
};
|
||||
|
||||
// Mock span with short status message (<= 100 characters)
|
||||
export const mockSpanWithShortStatusMessage: Span = {
|
||||
...mockSpan,
|
||||
statusMessage: 'Connection successful',
|
||||
};
|
||||
|
||||
// Mock logs with proper relationships
|
||||
export const mockSpanLogs: ILog[] = [
|
||||
{
|
||||
id: 'span-log-1',
|
||||
timestamp: '2022-01-01T00:00:01.000Z',
|
||||
body: 'Processing request in span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: TEST_SPAN_ID,
|
||||
span_id: TEST_SPAN_ID,
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
{
|
||||
id: 'span-log-2',
|
||||
timestamp: '2022-01-01T00:00:02.000Z',
|
||||
body: 'Span operation completed',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: TEST_SPAN_ID,
|
||||
span_id: TEST_SPAN_ID,
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockContextLogs: ILog[] = [
|
||||
{
|
||||
id: 'context-log-before',
|
||||
timestamp: '2021-12-31T23:59:59.000Z',
|
||||
body: 'Context log before span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: 'different-span-id',
|
||||
span_id: 'different-span-id',
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
{
|
||||
id: 'context-log-after',
|
||||
timestamp: '2022-01-01T00:00:03.000Z',
|
||||
body: 'Context log after span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: 'another-different-span-id',
|
||||
span_id: 'another-different-span-id',
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Combined logs in chronological order
|
||||
export const mockAllLogs: ILog[] = [
|
||||
mockContextLogs[0], // before
|
||||
...mockSpanLogs, // span logs
|
||||
mockContextLogs[1], // after
|
||||
];
|
||||
|
||||
// Mock API responses
|
||||
export const mockSpanLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: mockSpanLogs.map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockBeforeLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [mockContextLogs[0]].map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAfterLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [mockContextLogs[1]].map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockEmptyLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Expected v5 filter expressions
|
||||
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
|
||||
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
|
||||
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
|
||||
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;
|
||||
11
frontend/src/container/SpanDetailsDrawer/constants.ts
Normal file
11
frontend/src/container/SpanDetailsDrawer/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export enum RelatedSignalsViews {
|
||||
LOGS = 'logs',
|
||||
// METRICS = 'metrics',
|
||||
INFRA = 'infra',
|
||||
}
|
||||
|
||||
export const RELATED_SIGNALS_VIEW_TYPES = {
|
||||
LOGS: RelatedSignalsViews.LOGS,
|
||||
// METRICS: RelatedSignalsViews.METRICS,
|
||||
INFRA: RelatedSignalsViews.INFRA,
|
||||
};
|
||||
24
frontend/src/container/SpanDetailsDrawer/utils.ts
Normal file
24
frontend/src/container/SpanDetailsDrawer/utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
/**
|
||||
* Infrastructure metadata keys that indicate infra signals are available
|
||||
*/
|
||||
export const INFRA_METADATA_KEYS = [
|
||||
'k8s.cluster.name',
|
||||
'k8s.pod.name',
|
||||
'k8s.node.name',
|
||||
'host.name',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Checks if a span has any infrastructure metadata attributes
|
||||
* @param span - The span to check for infrastructure metadata
|
||||
* @returns true if the span has at least one infrastructure metadata key, false otherwise
|
||||
*/
|
||||
export function hasInfraMetadata(span: Span | undefined): boolean {
|
||||
if (!span?.tagMap) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return INFRA_METADATA_KEYS.some((key) => span.tagMap?.[key]);
|
||||
}
|
||||
186
frontend/src/container/TraceMetadata/TraceMetadata.styles.scss
Normal file
186
frontend/src/container/TraceMetadata/TraceMetadata.styles.scss
Normal file
@@ -0,0 +1,186 @@
|
||||
.trace-metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 16px 0px 16px;
|
||||
|
||||
.metadata-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.first-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.previous-btn {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
padding: 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
margin-left: 6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
background: var(--l2-background);
|
||||
|
||||
.drafting {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
.service-entry-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.root-span-name {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-duration {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.start-time-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.datapoints-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.data-point {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--l1-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'case' on,
|
||||
'cpsp' on,
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 28px; /* 140% */
|
||||
letter-spacing: -0.1px;
|
||||
text-transform: uppercase;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
171
frontend/src/container/TraceMetadata/TraceMetadata.tsx
Normal file
171
frontend/src/container/TraceMetadata/TraceMetadata.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import removeLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs from 'dayjs';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BetweenHorizontalStart,
|
||||
CalendarClock,
|
||||
DraftingCompass,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import './TraceMetadata.styles.scss';
|
||||
|
||||
export interface ITraceMetadataProps {
|
||||
traceID: string;
|
||||
rootServiceName: string;
|
||||
rootSpanName: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
totalSpans: number;
|
||||
totalErrorSpans: number;
|
||||
notFound: boolean;
|
||||
isDataLoading: boolean;
|
||||
}
|
||||
|
||||
function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
|
||||
const {
|
||||
traceID,
|
||||
rootServiceName,
|
||||
rootSpanName,
|
||||
startTime,
|
||||
duration,
|
||||
totalErrorSpans,
|
||||
totalSpans,
|
||||
notFound,
|
||||
isDataLoading,
|
||||
} = props;
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const startTimeInMs = useMemo(
|
||||
() =>
|
||||
dayjs(startTime * 1e3)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS),
|
||||
[startTime, timezone.value],
|
||||
);
|
||||
|
||||
const handlePreviousBtnClick = (): void => {
|
||||
if (window.history.length > 1) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
};
|
||||
|
||||
const isOnOldRoute = !!useRouteMatch({
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const handleSwitchToNewView = (): void => {
|
||||
removeLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW);
|
||||
history.replace({
|
||||
pathname: `/trace/${traceID}`,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
state: location.state,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="trace-metadata">
|
||||
<section className="metadata-info">
|
||||
<div className="first-row">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className="previous-btn"
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={handlePreviousBtnClick}
|
||||
/>
|
||||
<div className="trace-name">
|
||||
<DraftingCompass size={14} className="drafting" />
|
||||
<Typography.Text className="trace-id">Trace ID</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
|
||||
{isOnOldRoute && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
className="new-view-btn"
|
||||
onClick={handleSwitchToNewView}
|
||||
>
|
||||
Try new experience
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDataLoading && (
|
||||
<div className="second-row">
|
||||
<div className="service-entry-info">
|
||||
<BetweenHorizontalStart size={14} />
|
||||
<Skeleton.Input active className="skeleton-input" size="small" />
|
||||
<Skeleton.Input active className="skeleton-input" size="small" />
|
||||
<Skeleton.Input active className="skeleton-input" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isDataLoading && !notFound && (
|
||||
<div className="second-row">
|
||||
<div className="service-entry-info">
|
||||
<BetweenHorizontalStart size={14} />
|
||||
<Typography.Text className="text">{rootServiceName}</Typography.Text>
|
||||
—
|
||||
<Typography.Text className="text root-span-name">
|
||||
{rootSpanName}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="trace-duration">
|
||||
<Tooltip title="Duration of trace">
|
||||
<Timer size={14} />
|
||||
</Tooltip>
|
||||
<Typography.Text className="text">
|
||||
{getYAxisFormattedValue(`${duration}`, 'ms')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="start-time-info">
|
||||
<Tooltip title="Start timestamp">
|
||||
<CalendarClock size={14} />
|
||||
</Tooltip>
|
||||
|
||||
<Typography.Text className="text">
|
||||
{startTimeInMs || 'N/A'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{!notFound && (
|
||||
<section className="datapoints-info">
|
||||
<div className="data-point">
|
||||
<Typography.Text className="text">Total Spans</Typography.Text>
|
||||
<Typography.Text className="value">{totalSpans}</Typography.Text>
|
||||
</div>
|
||||
<div className="separator" />
|
||||
<div className="data-point">
|
||||
<Typography.Text className="text">Error Spans</Typography.Text>
|
||||
<Typography.Text className="value">{totalErrorSpans}</Typography.Text>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceMetadata;
|
||||
@@ -0,0 +1,239 @@
|
||||
// Modal base styles
|
||||
.add-span-to-funnel-modal {
|
||||
&__loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
}
|
||||
&-container {
|
||||
.ant-modal {
|
||||
&-content,
|
||||
&-header {
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
&-header {
|
||||
border-bottom: none;
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 14px 16px !important;
|
||||
padding-bottom: 0 !important;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
margin-top: 0;
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
.add-span-to-funnel-modal {
|
||||
&__save-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
width: 135px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: flex;
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--l2-foreground);
|
||||
.ant-btn-icon {
|
||||
svg {
|
||||
stroke: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__discard-button {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
.ant-btn {
|
||||
border-radius: 2px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 !important;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main modal styles
|
||||
.add-span-to-funnel-modal {
|
||||
// Common button styles
|
||||
%button-base {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: Inter;
|
||||
}
|
||||
|
||||
// Details view styles
|
||||
&--details {
|
||||
.traces-funnel-details {
|
||||
height: unset;
|
||||
|
||||
&__steps-config {
|
||||
width: unset;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.funnel-step-wrapper {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.steps-content {
|
||||
padding: 0 16px;
|
||||
max-height: 500px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search section
|
||||
&__search {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
|
||||
&-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--l3-background);
|
||||
|
||||
.ant-input-prefix {
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
input {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
font-weight: 400;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create button
|
||||
&__create-button {
|
||||
@extend %button-base;
|
||||
width: 153px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-border);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
.funnel-item {
|
||||
padding: 8px 8px 12px 16px;
|
||||
&,
|
||||
&:first-child {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
line-height: 20px;
|
||||
}
|
||||
&__details {
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// List section
|
||||
&__list {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
.funnels-empty {
|
||||
&__content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.funnels-list {
|
||||
gap: 8px;
|
||||
|
||||
.funnel-item {
|
||||
padding: 8px 16px 12px;
|
||||
|
||||
&__details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
// Back button
|
||||
&__back-button {
|
||||
@extend %button-base;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
// Details section
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.funnel-configuration__steps {
|
||||
padding: 0;
|
||||
|
||||
.funnel-step {
|
||||
&__content .filters__service-and-span .ant-select {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
&__footer .error {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.inter-step-config {
|
||||
width: calc(100% - 104px);
|
||||
}
|
||||
}
|
||||
.funnel-item__actions-popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, Check, Loader, Plus, Search } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import {
|
||||
useFunnelDetails,
|
||||
useFunnelsList,
|
||||
} from 'hooks/TracesFunnels/useFunnels';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
|
||||
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
|
||||
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
|
||||
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
|
||||
import {
|
||||
FunnelProvider,
|
||||
useFunnelContext,
|
||||
} from 'pages/TracesFunnels/FunnelContext';
|
||||
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import './AddSpanToFunnelModal.styles.scss';
|
||||
|
||||
enum ModalView {
|
||||
LIST = 'list',
|
||||
DETAILS = 'details',
|
||||
}
|
||||
|
||||
function FunnelDetailsView({
|
||||
funnel,
|
||||
span,
|
||||
triggerAutoSave,
|
||||
showNotifications,
|
||||
onChangesDetected,
|
||||
triggerDiscard,
|
||||
}: {
|
||||
funnel: FunnelData;
|
||||
span: Span;
|
||||
triggerAutoSave: boolean;
|
||||
showNotifications: boolean;
|
||||
onChangesDetected: (hasChanges: boolean) => void;
|
||||
triggerDiscard: boolean;
|
||||
}): JSX.Element {
|
||||
const { handleRestoreSteps, steps } = useFunnelContext();
|
||||
|
||||
// Track changes between current steps and original steps
|
||||
useEffect(() => {
|
||||
const hasChanges = !isEqual(steps, funnel.steps);
|
||||
if (onChangesDetected) {
|
||||
onChangesDetected(hasChanges);
|
||||
}
|
||||
}, [steps, funnel.steps, onChangesDetected]);
|
||||
|
||||
// Handle discard when triggered from parent
|
||||
useEffect(() => {
|
||||
if (triggerDiscard && funnel.steps) {
|
||||
handleRestoreSteps(funnel.steps);
|
||||
}
|
||||
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
|
||||
|
||||
return (
|
||||
<div className="add-span-to-funnel-modal__details">
|
||||
<FunnelListItem
|
||||
funnel={funnel}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
isSpanDetailsPage
|
||||
/>
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddSpanToFunnelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
function AddSpanToFunnelModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
span,
|
||||
}: AddSpanToFunnelModalProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
||||
const [triggerSave, setTriggerSave] = useState<boolean>(false);
|
||||
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
|
||||
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
|
||||
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isFetching } = useFunnelsList();
|
||||
|
||||
const filteredData = useMemo(
|
||||
() =>
|
||||
filterFunnelsByQuery(data?.payload || [], searchQuery).sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
),
|
||||
[data?.payload, searchQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
data: funnelDetails,
|
||||
isLoading: isFunnelDetailsLoading,
|
||||
isFetching: isFunnelDetailsFetching,
|
||||
} = useFunnelDetails({
|
||||
funnelId: selectedFunnelId,
|
||||
});
|
||||
|
||||
const handleFunnelClick = (funnel: FunnelData): void => {
|
||||
setSelectedFunnelId(funnel.funnel_id);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
setIsCreatedFromSpan(false);
|
||||
};
|
||||
|
||||
const handleBack = (): void => {
|
||||
setActiveView(ModalView.LIST);
|
||||
setSelectedFunnelId(undefined);
|
||||
setIsUnsavedChanges(false);
|
||||
setTriggerSave(false);
|
||||
setIsCreatedFromSpan(false);
|
||||
};
|
||||
|
||||
const handleCreateNewClick = (): void => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveFunnel = (): void => {
|
||||
setTriggerSave(true);
|
||||
// Reset trigger after a brief moment to allow the save to be processed
|
||||
setTimeout(() => {
|
||||
setTriggerSave(false);
|
||||
onClose();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setTriggerDiscard(true);
|
||||
// Reset trigger after a brief moment
|
||||
setTimeout(() => {
|
||||
setTriggerDiscard(false);
|
||||
onClose();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const renderListView = (): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal">
|
||||
{!!filteredData?.length && (
|
||||
<div className="add-span-to-funnel-modal__search">
|
||||
<Input
|
||||
className="add-span-to-funnel-modal__search-input"
|
||||
placeholder="Search by name, description, or tags..."
|
||||
prefix={<Search size={12} />}
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="add-span-to-funnel-modal__list">
|
||||
<OverlayScrollbar>
|
||||
<TracesFunnelsContentRenderer
|
||||
isError={isError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={filteredData || []}
|
||||
onCreateFunnel={handleCreateNewClick}
|
||||
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
<CreateFunnel
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={(funnelId): void => {
|
||||
if (funnelId) {
|
||||
setSelectedFunnelId(funnelId);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
setIsCreatedFromSpan(true);
|
||||
}
|
||||
setIsCreateModalOpen(false);
|
||||
}}
|
||||
redirectToDetails={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
|
||||
<Button
|
||||
type="text"
|
||||
className="add-span-to-funnel-modal__back-button"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
All funnels
|
||||
</Button>
|
||||
<div className="traces-funnel-details">
|
||||
<div className="traces-funnel-details__steps-config">
|
||||
<Spin
|
||||
className="add-span-to-funnel-modal__loading-spinner"
|
||||
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
|
||||
indicator={<Loader className="animate-spin" size="md" />}
|
||||
>
|
||||
{selectedFunnelId && funnelDetails?.payload && (
|
||||
<FunnelProvider
|
||||
funnelId={selectedFunnelId}
|
||||
hasSingleStep={isCreatedFromSpan}
|
||||
>
|
||||
<FunnelDetailsView
|
||||
funnel={funnelDetails.payload}
|
||||
span={span}
|
||||
triggerAutoSave={triggerSave}
|
||||
showNotifications
|
||||
onChangesDetected={setIsUnsavedChanges}
|
||||
triggerDiscard={triggerDiscard}
|
||||
/>
|
||||
</FunnelProvider>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
width={570}
|
||||
title="Add span to funnel"
|
||||
className={cx('add-span-to-funnel-modal-container', {
|
||||
'add-span-to-funnel-modal-container--details':
|
||||
activeView === ModalView.DETAILS,
|
||||
})}
|
||||
footer={
|
||||
activeView === ModalView.DETAILS
|
||||
? [
|
||||
<Button
|
||||
type="default"
|
||||
key="discard"
|
||||
onClick={handleDiscard}
|
||||
className="add-span-to-funnel-modal__discard-button"
|
||||
disabled={!isUnsavedChanges}
|
||||
>
|
||||
Discard
|
||||
</Button>,
|
||||
<Button
|
||||
key="save"
|
||||
type="primary"
|
||||
className="add-span-to-funnel-modal__save-button"
|
||||
onClick={handleSaveFunnel}
|
||||
disabled={!isUnsavedChanges}
|
||||
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
|
||||
>
|
||||
Save Funnel
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button
|
||||
key="create"
|
||||
type="default"
|
||||
className="add-span-to-funnel-modal__create-button"
|
||||
onClick={handleCreateNewClick}
|
||||
icon={<Plus size={14} />}
|
||||
>
|
||||
Create new funnel
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
>
|
||||
{activeView === ModalView.LIST
|
||||
? renderListView()
|
||||
: renderDetailsView({ span })}
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSpanToFunnelModal;
|
||||
@@ -0,0 +1,28 @@
|
||||
.span-line-action-buttons {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.ant-btn-default {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 9px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-span-btn {
|
||||
border-color: var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanLineActionButtons from '../index';
|
||||
|
||||
// Mock the useCopySpanLink hook
|
||||
jest.mock('hooks/trace/useCopySpanLink');
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1000,
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
describe('SpanLineActionButtons', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mock before each test
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders copy link button with correct icon', () => {
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Check if the button is rendered with an icon
|
||||
const copyButton = screen.getByRole('button');
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
expect(copyButton.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSpanCopy when copy button is clicked', () => {
|
||||
const mockOnSpanCopy = jest.fn();
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: mockOnSpanCopy,
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Click the copy button
|
||||
const copyButton = screen.getByRole('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Verify the copy function was called
|
||||
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('applies correct styling classes', () => {
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Check if the main container has the correct class
|
||||
const container = screen
|
||||
.getByRole('button')
|
||||
.closest('.span-line-action-buttons');
|
||||
expect(container).toHaveClass('span-line-action-buttons');
|
||||
|
||||
// Check if the button has the correct class
|
||||
const copyButton = screen.getByRole('button');
|
||||
expect(copyButton).toHaveClass('copy-span-btn');
|
||||
});
|
||||
|
||||
it('copies span link to clipboard when copy button is clicked', () => {
|
||||
const mockSetCopy = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
delete: jest.fn(),
|
||||
set: jest.fn(),
|
||||
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
|
||||
};
|
||||
const mockPathname = '/test-path';
|
||||
const mockLocation = {
|
||||
origin: 'http://localhost:3000',
|
||||
};
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock useCopySpanLink hook
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mockUrlQuery.delete('spanId');
|
||||
mockUrlQuery.set('spanId', mockSpan.spanId);
|
||||
const link = `${
|
||||
window.location.origin
|
||||
}${mockPathname}?${mockUrlQuery.toString()}`;
|
||||
mockSetCopy(link);
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Click the copy button
|
||||
const copyButton = screen.getByRole('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Verify the copy function was called with correct link
|
||||
expect(mockSetCopy).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/test-path?spanId=test-span-id',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Link } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './SpanLineActionButtons.styles.scss';
|
||||
|
||||
export interface SpanLineActionButtonsProps {
|
||||
span: Span;
|
||||
}
|
||||
export default function SpanLineActionButtons({
|
||||
span,
|
||||
}: SpanLineActionButtonsProps): JSX.Element {
|
||||
const { onSpanCopy } = useCopySpanLink(span);
|
||||
|
||||
return (
|
||||
<div className="span-line-action-buttons">
|
||||
<Tooltip title="Copy Span Link">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<Link size={14} />}
|
||||
onClick={onSpanCopy}
|
||||
className="copy-span-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.trace-waterfall {
|
||||
height: calc(70vh - 236px);
|
||||
|
||||
.loading-skeleton {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
137
frontend/src/container/TraceWaterfall/TraceWaterfall.tsx
Normal file
137
frontend/src/container/TraceWaterfall/TraceWaterfall.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { Skeleton } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { TraceWaterfallStates } from './constants';
|
||||
import Error from './TraceWaterfallStates/Error/Error';
|
||||
import NoData from './TraceWaterfallStates/NoData/NoData';
|
||||
import Success from './TraceWaterfallStates/Success/Success';
|
||||
|
||||
import './TraceWaterfall.styles.scss';
|
||||
|
||||
export interface IInterestedSpan {
|
||||
spanId: string;
|
||||
isUncollapsed: boolean;
|
||||
}
|
||||
|
||||
interface ITraceWaterfallProps {
|
||||
traceId: string;
|
||||
uncollapsedNodes: string[];
|
||||
traceData:
|
||||
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
isFetchingTraceData: boolean;
|
||||
errorFetchingTraceData: unknown;
|
||||
interestedSpanId: IInterestedSpan;
|
||||
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
|
||||
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
}
|
||||
|
||||
function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
|
||||
const {
|
||||
traceData,
|
||||
isFetchingTraceData,
|
||||
errorFetchingTraceData,
|
||||
interestedSpanId,
|
||||
traceId,
|
||||
uncollapsedNodes,
|
||||
setInterestedSpanId,
|
||||
setTraceFlamegraphStatsWidth,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
// get the current state of trace waterfall based on the API lifecycle
|
||||
const traceWaterfallState = useMemo(() => {
|
||||
if (isFetchingTraceData) {
|
||||
if (
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.spans &&
|
||||
traceData.payload.spans.length > 0
|
||||
) {
|
||||
return TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT;
|
||||
}
|
||||
return TraceWaterfallStates.LOADING;
|
||||
}
|
||||
if (errorFetchingTraceData) {
|
||||
return TraceWaterfallStates.ERROR;
|
||||
}
|
||||
if (
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.spans &&
|
||||
traceData.payload.spans.length === 0
|
||||
) {
|
||||
return TraceWaterfallStates.NO_DATA;
|
||||
}
|
||||
|
||||
return TraceWaterfallStates.SUCCESS;
|
||||
}, [errorFetchingTraceData, isFetchingTraceData, traceData]);
|
||||
|
||||
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
|
||||
const spans = useMemo(
|
||||
() => traceData?.payload?.spans || [],
|
||||
[traceData?.payload?.spans],
|
||||
);
|
||||
|
||||
// get the content based on the current state of the trace waterfall
|
||||
const getContent = useMemo(() => {
|
||||
switch (traceWaterfallState) {
|
||||
case TraceWaterfallStates.LOADING:
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
);
|
||||
case TraceWaterfallStates.ERROR:
|
||||
return <Error error={errorFetchingTraceData as AxiosError} />;
|
||||
case TraceWaterfallStates.NO_DATA:
|
||||
return <NoData id={traceId} />;
|
||||
case TraceWaterfallStates.SUCCESS:
|
||||
case TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT:
|
||||
return (
|
||||
<Success
|
||||
spans={spans}
|
||||
traceMetadata={{
|
||||
traceId,
|
||||
startTime: traceData?.payload?.startTimestampMillis || 0,
|
||||
endTime: traceData?.payload?.endTimestampMillis || 0,
|
||||
hasMissingSpans: traceData?.payload?.hasMissingSpans || false,
|
||||
}}
|
||||
interestedSpanId={interestedSpanId || ''}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
setTraceFlamegraphStatsWidth={setTraceFlamegraphStatsWidth}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Spinner tip="Fetching the trace!" />;
|
||||
}
|
||||
}, [
|
||||
errorFetchingTraceData,
|
||||
interestedSpanId,
|
||||
selectedSpan,
|
||||
setInterestedSpanId,
|
||||
setSelectedSpan,
|
||||
setTraceFlamegraphStatsWidth,
|
||||
spans,
|
||||
traceData?.payload?.endTimestampMillis,
|
||||
traceData?.payload?.hasMissingSpans,
|
||||
traceData?.payload?.startTimestampMillis,
|
||||
traceId,
|
||||
traceWaterfallState,
|
||||
uncollapsedNodes,
|
||||
]);
|
||||
|
||||
return <div className="trace-waterfall">{getContent}</div>;
|
||||
}
|
||||
|
||||
export default TraceWaterfall;
|
||||
@@ -0,0 +1,30 @@
|
||||
.error-waterfall {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
margin: 20px;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
border-radius: 4px;
|
||||
background: var(--danger-background);
|
||||
|
||||
.text {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import './Error.styles.scss';
|
||||
|
||||
interface IErrorProps {
|
||||
error: AxiosError;
|
||||
}
|
||||
|
||||
function Error(props: IErrorProps): JSX.Element {
|
||||
const { error } = props;
|
||||
|
||||
return (
|
||||
<div className="error-waterfall">
|
||||
<Typography.Text className="text">Something went wrong!</Typography.Text>
|
||||
<Tooltip title={error?.message}>
|
||||
<Typography.Text className="value" truncate={1}>
|
||||
{error?.message}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Error;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface INoDataProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function NoData(props: INoDataProps): JSX.Element {
|
||||
const { id } = props;
|
||||
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -0,0 +1,46 @@
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px 0px 20px;
|
||||
gap: 12px;
|
||||
|
||||
.query-builder-search-v2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pre-next-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader,
|
||||
SolidInfoCircle,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Spin, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { BASE_FILTER_QUERY } from './constants';
|
||||
|
||||
import './Filters.styles.scss';
|
||||
|
||||
function prepareQuery(filters: TagFilter, traceID: string): Query {
|
||||
return {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.traces.builder.queryData[0],
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
|
||||
filters: {
|
||||
...filters,
|
||||
items: [
|
||||
...filters.items,
|
||||
{
|
||||
id: '5ab8e1cf',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'trace_id--string----true',
|
||||
},
|
||||
op: '=',
|
||||
value: traceID,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Filters({
|
||||
startTime,
|
||||
endTime,
|
||||
traceID,
|
||||
onFilteredSpansChange = (): void => {},
|
||||
}: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceID: string;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
const [noData, setNoData] = useState<boolean>(false);
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(value: TagFilter): void => {
|
||||
if (value.items.length === 0) {
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}
|
||||
setFilters(value);
|
||||
},
|
||||
[onFilteredSpansChange],
|
||||
);
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const handlePrevNext = useCallback(
|
||||
(index: number, spanId?: string): void => {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
if (spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
} else {
|
||||
searchParams.set('spanId', filteredSpanIds[index]);
|
||||
}
|
||||
|
||||
history.replace({ search: searchParams.toString() });
|
||||
},
|
||||
[filteredSpanIds, history, search],
|
||||
);
|
||||
|
||||
const { isFetching, error } = useGetQueryRange(
|
||||
{
|
||||
query: prepareQuery(filters, traceID),
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
selectColumns: [
|
||||
{
|
||||
key: 'name',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
{
|
||||
queryKey: [filters],
|
||||
enabled: filters.items.length > 0,
|
||||
onSuccess: (data) => {
|
||||
const isFilterActive = filters.items.length > 0;
|
||||
if (data?.payload.data.newResult.data.result[0].list) {
|
||||
const uniqueSpans = uniqBy(
|
||||
data?.payload.data.newResult.data.result[0].list,
|
||||
'data.spanID',
|
||||
);
|
||||
|
||||
const spanIds = uniqueSpans.map((val) => val.data.spanID);
|
||||
setFilteredSpanIds(spanIds);
|
||||
onFilteredSpansChange?.(spanIds, isFilterActive);
|
||||
handlePrevNext(0, spanIds[0]);
|
||||
setNoData(false);
|
||||
} else {
|
||||
setNoData(true);
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], isFilterActive);
|
||||
setCurrentSearchedIndex(0);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="filter-row">
|
||||
<QueryBuilderSearchV2
|
||||
query={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
}}
|
||||
onChange={handleFilterChange}
|
||||
hideSpanScopeSelector={false}
|
||||
skipQueryBuilderRedirect
|
||||
selectProps={{ listHeight: 125 }}
|
||||
/>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className="pre-next-toggle">
|
||||
<Typography.Text>
|
||||
{currentSearchedIndex + 1} / {filteredSpanIds.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ChevronUp size={14} />}
|
||||
disabled={currentSearchedIndex === 0}
|
||||
type="text"
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ChevronDown size={14} />}
|
||||
type="text"
|
||||
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isFetching && (
|
||||
<Spin indicator={<Loader className="animate-spin" />} size="small" />
|
||||
)}
|
||||
{error && (
|
||||
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
|
||||
<SolidInfoCircle size={14} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{noData && (
|
||||
<Typography.Text className="no-results">No results found</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Filters.defaultProps = {
|
||||
onFilteredSpansChange: undefined,
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
export const BASE_FILTER_QUERY: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.EMPTY,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: 200,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
selectColumns: [],
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
.success-content {
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
|
||||
.missing-spans {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
margin: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-600) 10%, transparent);
|
||||
|
||||
.left-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.text {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row-reverse;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.right-info:hover {
|
||||
background-color: unset;
|
||||
color: var(--bg-robin-200);
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-table {
|
||||
height: calc(70vh - 236px);
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0px 20px 20px 20px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
// default table overrides css for table v3
|
||||
.div-table {
|
||||
width: 100% !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.div-thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--l1-background) !important;
|
||||
|
||||
.div-tr {
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.div-tr {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.div-tr:hover {
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--bg-robin-200) 6%,
|
||||
transparent
|
||||
) !important;
|
||||
|
||||
.div-td .span-overview .second-row .add-funnel-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.span-overview {
|
||||
background: unset !important;
|
||||
|
||||
.span-overview-content {
|
||||
background: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.div-th,
|
||||
.div-td {
|
||||
box-shadow: none;
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.div-th {
|
||||
padding: 2px 4px;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.div-td {
|
||||
display: flex;
|
||||
height: 54px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.span-overview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
.connector-lines {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.span-overview-content {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
background-color: var(--l1-background);
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
&:not(:first-child) {
|
||||
.first-row {
|
||||
width: calc(100% - 28px);
|
||||
}
|
||||
}
|
||||
|
||||
.first-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
|
||||
.span-det {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
.collapse-uncollapse-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 4px;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
box-shadow: none;
|
||||
height: 20px;
|
||||
|
||||
.children-count {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
}
|
||||
|
||||
.span-name {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-code-container {
|
||||
display: flex;
|
||||
padding-right: 10px;
|
||||
|
||||
.status-code {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
padding: 3px;
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.success {
|
||||
border: 1px solid var(--primary-background);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid var(--danger-background);
|
||||
background: var(--danger-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 18px;
|
||||
width: 100%;
|
||||
.service-name {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
.add-funnel-button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
|
||||
&__separator {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
&__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-duration {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 54px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-left: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
.span-line {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
top: 35%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--primary-background);
|
||||
border: 1px solid var(--bg-robin-600);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
&.error {
|
||||
background-color: var(--danger-background);
|
||||
border-color: var(--bg-cherry-600);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
.span-line-text {
|
||||
position: relative;
|
||||
top: 40%;
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'case' on,
|
||||
'cpsp' on,
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.interested-span,
|
||||
.selected-non-matching-span {
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--bg-robin-200) 6%,
|
||||
transparent
|
||||
) !important;
|
||||
|
||||
.span-overview-content {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.dimmed-span {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.highlighted-span {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.selected-non-matching-span {
|
||||
.span-overview-content,
|
||||
.span-line-text {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.div-td + .div-td {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.div-th + .div-th {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.div-tr .div-th:nth-child(2) {
|
||||
width: calc(100% - var(--header-span-name-size) * 1px) !important;
|
||||
}
|
||||
.div-tr .div-td:nth-child(2) {
|
||||
width: calc(100% - var(--header-span-name-size) * 1px) !important;
|
||||
}
|
||||
.resizer {
|
||||
width: 10px !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: calc(70vh - 236px);
|
||||
right: 0;
|
||||
width: 2px;
|
||||
background: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.resizer.isResizing {
|
||||
background: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.resizer {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
*:hover > .resizer {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.missing-spans-waterfall-table {
|
||||
height: calc(70vh - 312px);
|
||||
}
|
||||
}
|
||||
|
||||
.span-dets {
|
||||
.related-logs {
|
||||
display: flex;
|
||||
width: 160px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ColumnDef, createColumnHelper } from '@tanstack/react-table';
|
||||
import { Virtualizer } from '@tanstack/react-virtual';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import SpanHoverCard from 'components/SpanHoverCard/SpanHoverCard';
|
||||
import { TableV3 } from 'components/TableV3/TableV3';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import SpanLineActionButtons from 'container/TraceWaterfall/SpanLineActionButtons';
|
||||
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Leaf,
|
||||
} from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import funnelAddUrl from '@/assets/Icons/funnel-add.svg';
|
||||
|
||||
import Filters from './Filters/Filters';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
// css config
|
||||
const CONNECTOR_WIDTH = 28;
|
||||
const VERTICAL_CONNECTOR_WIDTH = 1;
|
||||
|
||||
interface ITraceMetadata {
|
||||
traceId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
hasMissingSpans: boolean;
|
||||
}
|
||||
interface ISuccessProps {
|
||||
spans: Span[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
interestedSpanId: IInterestedSpan;
|
||||
uncollapsedNodes: string[];
|
||||
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
|
||||
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
}
|
||||
|
||||
function SpanOverview({
|
||||
span,
|
||||
isSpanCollapsed,
|
||||
handleCollapseUncollapse,
|
||||
handleSpanClick,
|
||||
handleAddSpanToFunnel,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
traceMetadata,
|
||||
}: {
|
||||
span: Span;
|
||||
isSpanCollapsed: boolean;
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
selectedSpan: Span | undefined;
|
||||
handleSpanClick: (span: Span) => void;
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
traceMetadata: ITraceMetadata;
|
||||
}): JSX.Element {
|
||||
const isRootSpan = span.level === 0;
|
||||
const { hasEditPermission } = useAppContext();
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
if (span.hasError) {
|
||||
color = `var(--danger-background)`;
|
||||
}
|
||||
|
||||
// Smart highlighting logic
|
||||
const isMatching =
|
||||
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
|
||||
const isSelected = selectedSpan?.spanId === span.spanId;
|
||||
const isDimmed = isFilterActive && !isMatching && !isSelected;
|
||||
const isHighlighted = isFilterActive && isMatching && !isSelected;
|
||||
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
|
||||
|
||||
return (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
style={{
|
||||
paddingLeft: `${
|
||||
isRootSpan
|
||||
? span.level * CONNECTOR_WIDTH
|
||||
: (span.level - 1) * (CONNECTOR_WIDTH + VERTICAL_CONNECTOR_WIDTH)
|
||||
}px`,
|
||||
backgroundImage: `url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="54"><line x1="0" y1="0" x2="0" y2="54" stroke="rgb(29 33 45)" stroke-width="1" /></svg>')`,
|
||||
backgroundRepeat: 'repeat',
|
||||
backgroundSize: `${CONNECTOR_WIDTH + 1}px 54px`,
|
||||
}}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{!isRootSpan && (
|
||||
<div className="connector-lines">
|
||||
<div
|
||||
style={{
|
||||
width: `${CONNECTOR_WIDTH}px`,
|
||||
height: '1px',
|
||||
borderTop: '1px solid var(--bg-slate-400)',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
top: '-10px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="span-overview-content">
|
||||
<section className="first-row">
|
||||
<div className="span-det">
|
||||
{span.hasChildren ? (
|
||||
<Button
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleCollapseUncollapse(span.spanId, !isSpanCollapsed);
|
||||
}}
|
||||
className="collapse-uncollapse-button"
|
||||
>
|
||||
{isSpanCollapsed ? (
|
||||
<ChevronRight size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)}
|
||||
<Typography.Text className="children-count">
|
||||
{span.subTreeNodeCount}
|
||||
</Typography.Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="collapse-uncollapse-button">
|
||||
<Leaf size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Typography.Text className="span-name">{span.name}</Typography.Text>
|
||||
</div>
|
||||
<HttpStatusBadge statusCode={span.tagMap?.['http.status_code']} />
|
||||
</section>
|
||||
<section className="second-row">
|
||||
<div style={{ width: '2px', background: color, height: '100%' }} />
|
||||
<Typography.Text className="service-name">
|
||||
{span.serviceName}
|
||||
</Typography.Text>
|
||||
{!!span.serviceName && !!span.name && (
|
||||
<div className="add-funnel-button">
|
||||
<span className="add-funnel-button__separator">·</span>
|
||||
<Tooltip
|
||||
title={
|
||||
!hasEditPermission
|
||||
? 'You need editor or admin access to add spans to funnels'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="add-funnel-button__button"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddSpanToFunnel(span);
|
||||
}}
|
||||
disabled={!hasEditPermission}
|
||||
icon={
|
||||
<img
|
||||
className="add-funnel-button__icon"
|
||||
src={funnelAddUrl}
|
||||
alt="funnel-icon"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpanDuration({
|
||||
span,
|
||||
traceMetadata,
|
||||
handleSpanClick,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
handleSpanClick: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}): JSX.Element {
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(
|
||||
span.durationNano / 1e6,
|
||||
);
|
||||
|
||||
const spread = traceMetadata.endTime - traceMetadata.startTime;
|
||||
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
|
||||
const width = (span.durationNano * 1e2) / (spread * 1e6);
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
|
||||
if (span.hasError) {
|
||||
color = `var(--danger-background)`;
|
||||
}
|
||||
|
||||
const [hasActionButtons, setHasActionButtons] = useState(false);
|
||||
|
||||
const isMatching =
|
||||
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
|
||||
const isSelected = selectedSpan?.spanId === span.spanId;
|
||||
const isDimmed = isFilterActive && !isMatching && !isSelected;
|
||||
const isHighlighted = isFilterActive && isMatching && !isSelected;
|
||||
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
|
||||
|
||||
const handleMouseEnter = (): void => {
|
||||
setHasActionButtons(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (): void => {
|
||||
setHasActionButtons(false);
|
||||
};
|
||||
|
||||
// Calculate text positioning to handle overflow cases
|
||||
const textStyle = useMemo(() => {
|
||||
const spanRightEdge = leftOffset + width;
|
||||
const textWidthApprox = 8; // Approximate text width in percentage
|
||||
|
||||
// If span would cause text overflow, right-align text to span end
|
||||
if (leftOffset > 100 - textWidthApprox) {
|
||||
return {
|
||||
right: `${100 - spanRightEdge}%`,
|
||||
color,
|
||||
textAlign: 'right' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: left-align text to span start
|
||||
return {
|
||||
left: `${leftOffset}%`,
|
||||
color,
|
||||
};
|
||||
}, [leftOffset, width, color]);
|
||||
|
||||
return (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx('span-duration', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
<div
|
||||
className="span-line"
|
||||
style={{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor: color,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{span.event?.map((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
|
||||
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
|
||||
const { isError } = event;
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(
|
||||
eventTimeMs - span.timestamp,
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
|
||||
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
|
||||
>
|
||||
<div
|
||||
className={`event-dot ${isError ? 'error' : ''}`}
|
||||
style={{
|
||||
left: `${clampedOffset}%`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasActionButtons && <SpanLineActionButtons span={span} />}
|
||||
<Typography.Text
|
||||
className="span-line-text"
|
||||
truncate={1}
|
||||
style={textStyle}
|
||||
>{`${toFixed(time, 2)} ${timeUnitName}`}</Typography.Text>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
// table config
|
||||
const columnDefHelper = createColumnHelper<Span>();
|
||||
|
||||
function getWaterfallColumns({
|
||||
handleCollapseUncollapse,
|
||||
uncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: {
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
uncollapsedNodes: string[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
handleSpanClick: (span: Span) => void;
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}): ColumnDef<Span, any>[] {
|
||||
const waterfallColumns: ColumnDef<Span, any>[] = [
|
||||
columnDefHelper.display({
|
||||
id: 'span-name',
|
||||
header: '',
|
||||
cell: (props): JSX.Element => (
|
||||
<SpanOverview
|
||||
span={props.row.original}
|
||||
handleCollapseUncollapse={handleCollapseUncollapse}
|
||||
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
|
||||
selectedSpan={selectedSpan}
|
||||
handleSpanClick={handleSpanClick}
|
||||
handleAddSpanToFunnel={handleAddSpanToFunnel}
|
||||
traceMetadata={traceMetadata}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
),
|
||||
size: 450,
|
||||
/**
|
||||
* Note: The TanStack table currently does not support percentage-based column sizing.
|
||||
* Therefore, we specify both `minSize` and `maxSize` for the "span-name" column to ensure
|
||||
* that its width remains between 240px and 900px. Setting a `maxSize` here is important
|
||||
* because the "span-duration" column has column resizing disabled, making it difficult
|
||||
* to enforce a minimum width for that column. By constraining the "span-name" column,
|
||||
* we indirectly control the minimum width available for the "span-duration" column.
|
||||
*/
|
||||
minSize: 240,
|
||||
maxSize: 900,
|
||||
}),
|
||||
columnDefHelper.display({
|
||||
id: 'span-duration',
|
||||
header: () => <div />,
|
||||
enableResizing: false,
|
||||
cell: (props): JSX.Element => (
|
||||
<SpanDuration
|
||||
span={props.row.original}
|
||||
traceMetadata={traceMetadata}
|
||||
selectedSpan={selectedSpan}
|
||||
handleSpanClick={handleSpanClick}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
return waterfallColumns;
|
||||
}
|
||||
|
||||
function Success(props: ISuccessProps): JSX.Element {
|
||||
const {
|
||||
spans,
|
||||
traceMetadata,
|
||||
interestedSpanId,
|
||||
uncollapsedNodes,
|
||||
setInterestedSpanId,
|
||||
setTraceFlamegraphStatsWidth,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
|
||||
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean) => {
|
||||
setFilteredSpanIds(spanIds);
|
||||
setIsFilterActive(isActive);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCollapseUncollapse = useCallback(
|
||||
(spanId: string, collapse: boolean) => {
|
||||
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
|
||||
},
|
||||
[setInterestedSpanId],
|
||||
);
|
||||
|
||||
const handleVirtualizerInstanceChanged = (
|
||||
instance: Virtualizer<HTMLDivElement, Element>,
|
||||
): void => {
|
||||
const { range } = instance;
|
||||
// when there are less than 500 elements in the API call that means there is nothing to fetch on top and bottom so
|
||||
// do not trigger the API call
|
||||
if (spans.length < 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.startIndex === 0 && instance.isScrolling) {
|
||||
// do not trigger for trace root as nothing to fetch above
|
||||
if (spans[0].level !== 0) {
|
||||
setInterestedSpanId({ spanId: spans[0].spanId, isUncollapsed: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
|
||||
setInterestedSpanId({
|
||||
spanId: spans[spans.length - 1].spanId,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
|
||||
useState(false);
|
||||
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
|
||||
Span | undefined
|
||||
>(undefined);
|
||||
const handleAddSpanToFunnel = useCallback((span: Span): void => {
|
||||
setIsAddSpanToFunnelModalOpen(true);
|
||||
setSelectedSpanToAddToFunnel(span);
|
||||
}, []);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(span: Span): void => {
|
||||
setSelectedSpan(span);
|
||||
if (span?.spanId) {
|
||||
urlQuery.set('spanId', span?.spanId);
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
},
|
||||
[setSelectedSpan, urlQuery, safeNavigate],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getWaterfallColumns({
|
||||
handleCollapseUncollapse,
|
||||
uncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}),
|
||||
[
|
||||
handleCollapseUncollapse,
|
||||
uncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
|
||||
const idx = spans.findIndex(
|
||||
(span) => span.spanId === interestedSpanId.spanId,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
setTimeout(() => {
|
||||
virtualizerRef.current?.scrollToIndex(idx, {
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, 400);
|
||||
|
||||
setSelectedSpan(spans[idx]);
|
||||
}
|
||||
} else {
|
||||
setSelectedSpan((prev) => {
|
||||
if (!prev) {
|
||||
return spans[0];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [interestedSpanId, setSelectedSpan, spans]);
|
||||
|
||||
return (
|
||||
<div className="success-content">
|
||||
{traceMetadata.hasMissingSpans && (
|
||||
<div className="missing-spans">
|
||||
<section className="left-info">
|
||||
<CircleAlert size={14} />
|
||||
<Typography.Text className="text">
|
||||
This trace has missing spans
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<Button
|
||||
icon={<ArrowUpRight size={14} />}
|
||||
className="right-info"
|
||||
type="text"
|
||||
onClick={(): WindowProxy | null =>
|
||||
window.open(
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Filters
|
||||
startTime={traceMetadata.startTime / 1e3}
|
||||
endTime={traceMetadata.endTime / 1e3}
|
||||
traceID={traceMetadata.traceId}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
/>
|
||||
<TableV3
|
||||
columns={columns}
|
||||
data={spans}
|
||||
config={{
|
||||
handleVirtualizerInstanceChanged,
|
||||
}}
|
||||
customClassName={cx(
|
||||
'waterfall-table',
|
||||
traceMetadata.hasMissingSpans ? 'missing-spans-waterfall-table' : '',
|
||||
)}
|
||||
virtualiserRef={virtualizerRef}
|
||||
setColumnWidths={setTraceFlamegraphStatsWidth}
|
||||
/>
|
||||
{selectedSpanToAddToFunnel && (
|
||||
<AddSpanToFunnelModal
|
||||
span={selectedSpanToAddToFunnel}
|
||||
isOpen={isAddSpanToFunnelModalOpen}
|
||||
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Success;
|
||||
@@ -0,0 +1,264 @@
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { SpanDuration } from '../Success';
|
||||
|
||||
// Constants to avoid string duplication
|
||||
const SPAN_DURATION_TEXT = '1.16 ms';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
const INTERESTED_SPAN_CLASS = 'interested-span';
|
||||
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
|
||||
const DIMMED_SPAN_CLASS = 'dimmed-span';
|
||||
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
jest.mock('@signozhq/ui/badge', () => ({
|
||||
...jest.requireActual('@signozhq/ui/badge'),
|
||||
Badge: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1160000, // 1ms in nano
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
const mockTraceMetadata = {
|
||||
traceId: 'test-trace-id',
|
||||
startTime: 1234567000,
|
||||
endTime: 1234569000,
|
||||
hasMissingSpans: false,
|
||||
};
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SpanDuration', () => {
|
||||
const mockSetSelectedSpan = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockUrlQueryGet = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock URL query hook
|
||||
(useUrlQuery as jest.Mock).mockReturnValue({
|
||||
set: mockUrlQuerySet,
|
||||
get: mockUrlQueryGet,
|
||||
toString: () => 'spanId=test-span-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls handleSpanClick when clicked', () => {
|
||||
const mockHandleSpanClick = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockHandleSpanClick}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find and click the span duration element
|
||||
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
|
||||
fireEvent.click(spanElement);
|
||||
|
||||
// Verify handleSpanClick was called with the correct span
|
||||
expect(mockHandleSpanClick).toHaveBeenCalledWith(mockSpan);
|
||||
});
|
||||
|
||||
it('shows action buttons on hover', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
|
||||
|
||||
// Initially, action buttons should not be visible
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
// Hover over the span
|
||||
fireEvent.mouseEnter(spanElement);
|
||||
|
||||
// Action buttons should now be visible
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
|
||||
// Mouse leave should hide the buttons
|
||||
fireEvent.mouseLeave(spanElement);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies highlighted-span class when span matches filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[mockSpan.spanId]}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies dimmed-span class when span does not match filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={['other-span-id']}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[mockSpan.spanId]}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={['different-span-id']}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected and no filter is active', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('dims span when filter is active but no matches found', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]} // Empty array but filter is active
|
||||
isFilterActive // This is the key difference
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import Success from '../Success';
|
||||
|
||||
// Mock the required hooks with proper typing
|
||||
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
|
||||
(params: { search: string }) => void
|
||||
>;
|
||||
const mockUrlQuery = new URLSearchParams();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
|
||||
|
||||
// App provider is already handled by test-utils
|
||||
|
||||
// React Router is already globally mocked
|
||||
|
||||
// Mock complex external dependencies that cause provider issues
|
||||
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
|
||||
function SpanHoverCard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
SpanHoverCard.displayName = 'SpanHoverCard';
|
||||
return SpanHoverCard;
|
||||
});
|
||||
|
||||
// Mock the Filters component that's causing React Query issues
|
||||
jest.mock('../Filters/Filters', () => {
|
||||
function Filters(): null {
|
||||
return null;
|
||||
}
|
||||
Filters.displayName = 'Filters';
|
||||
return Filters;
|
||||
});
|
||||
|
||||
// Mock other potential dependencies
|
||||
jest.mock(
|
||||
'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal',
|
||||
() => {
|
||||
function AddSpanToFunnelModal(): null {
|
||||
return null;
|
||||
}
|
||||
AddSpanToFunnelModal.displayName = 'AddSpanToFunnelModal';
|
||||
return AddSpanToFunnelModal;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('container/TraceWaterfall/SpanLineActionButtons', () => {
|
||||
function SpanLineActionButtons(): null {
|
||||
return null;
|
||||
}
|
||||
SpanLineActionButtons.displayName = 'SpanLineActionButtons';
|
||||
return SpanLineActionButtons;
|
||||
});
|
||||
|
||||
jest.mock('components/HttpStatusBadge/HttpStatusBadge', () => {
|
||||
function HttpStatusBadge(): null {
|
||||
return null;
|
||||
}
|
||||
HttpStatusBadge.displayName = 'HttpStatusBadge';
|
||||
return HttpStatusBadge;
|
||||
});
|
||||
|
||||
// Mock other utilities that might cause issues
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: (): string => '#1890ff',
|
||||
}));
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (
|
||||
value: number,
|
||||
): { time: number; timeUnitName: string } => ({
|
||||
time: value < 1000 ? value : value / 1000,
|
||||
timeUnitName: value < 1000 ? 'ms' : 's',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('utils/toFixed', () => ({
|
||||
toFixed: (value: number, decimals: number): string => value.toFixed(decimals),
|
||||
}));
|
||||
|
||||
// Create a simplified mock TableV3 that renders the actual column components
|
||||
jest.mock('components/TableV3/TableV3', () => ({
|
||||
TableV3: ({
|
||||
columns,
|
||||
data,
|
||||
}: {
|
||||
columns: unknown[];
|
||||
data: Span[];
|
||||
}): JSX.Element => {
|
||||
// Get the current props from the columns (which contain the current state)
|
||||
const spanOverviewColumn = columns[0] as {
|
||||
cell?: (props: any) => JSX.Element;
|
||||
};
|
||||
const spanDurationColumn = columns[1] as {
|
||||
cell?: (props: any) => JSX.Element;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="trace-table">
|
||||
{data.map((row: Span) => {
|
||||
// Create proper cell props that match what TanStack Table expects
|
||||
const cellProps = {
|
||||
row: {
|
||||
original: row,
|
||||
getValue: (): Span => row,
|
||||
getAllCells: (): any[] => [],
|
||||
getVisibleCells: (): any[] => [],
|
||||
getUniqueValues: (): any[] => [],
|
||||
getIsSelected: (): boolean => false,
|
||||
getIsSomeSelected: (): boolean => false,
|
||||
getIsAllSelected: (): boolean => false,
|
||||
getCanSelect: (): boolean => true,
|
||||
getCanSelectSubRows: (): boolean => true,
|
||||
getCanSelectAll: (): boolean => true,
|
||||
toggleSelected: (): void => {},
|
||||
getToggleSelectedHandler: (): (() => void) => (): void => {},
|
||||
},
|
||||
column: { id: 'span-name' },
|
||||
table: {},
|
||||
cell: {},
|
||||
renderValue: (): Span => row,
|
||||
getValue: (): Span => row,
|
||||
};
|
||||
|
||||
const durationCellProps = {
|
||||
...cellProps,
|
||||
column: { id: 'span-duration' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={row.spanId} data-testid={`table-row-${row.spanId}`}>
|
||||
{/* Render span overview column */}
|
||||
<div data-testid={`cell-0-${row.spanId}`}>
|
||||
{spanOverviewColumn?.cell?.(cellProps)}
|
||||
</div>
|
||||
{/* Render span duration column */}
|
||||
<div data-testid={`cell-1-${row.spanId}`}>
|
||||
{spanDurationColumn?.cell?.(durationCellProps)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const mockTraceMetadata = {
|
||||
traceId: 'test-trace-id',
|
||||
startTime: 1679748225000000,
|
||||
endTime: 1679748226000000,
|
||||
hasMissingSpans: false,
|
||||
};
|
||||
|
||||
const createMockSpan = (spanId: string, level = 1): Span => ({
|
||||
spanId,
|
||||
traceId: 'test-trace-id',
|
||||
rootSpanId: 'span-1',
|
||||
parentSpanId: level === 0 ? '' : 'span-1',
|
||||
name: `Test Span ${spanId}`,
|
||||
serviceName: 'test-service',
|
||||
timestamp: mockTraceMetadata.startTime + level * 100000,
|
||||
durationNano: 50000000,
|
||||
level,
|
||||
hasError: false,
|
||||
kind: 1,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'Test Root Span',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'OK',
|
||||
spanKind: 'server',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 1,
|
||||
});
|
||||
|
||||
const mockSpans = [
|
||||
createMockSpan('span-1', 0),
|
||||
createMockSpan('span-2', 1),
|
||||
createMockSpan('span-3', 1),
|
||||
];
|
||||
|
||||
// Shared TestComponent for all tests
|
||||
function TestComponent(): JSX.Element {
|
||||
const [selectedSpan, setSelectedSpan] = React.useState<Span | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
setTraceFlamegraphStatsWidth={jest.fn()}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Span Click User Flows', () => {
|
||||
const FIRST_SPAN_TEST_ID = 'cell-0-span-1';
|
||||
const FIRST_SPAN_DURATION_TEST_ID = 'cell-1-span-1';
|
||||
const SECOND_SPAN_TEST_ID = 'cell-0-span-2';
|
||||
const SPAN_OVERVIEW_CLASS = '.span-overview';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
const INTERESTED_SPAN_CLASS = 'interested-span';
|
||||
const SECOND_SPAN_DURATION_TEST_ID = 'cell-1-span-2';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Clear all URL parameters
|
||||
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
|
||||
});
|
||||
|
||||
it('clicking span updates URL with spanId parameter', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
setTraceFlamegraphStatsWidth={jest.fn()}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/trace' },
|
||||
);
|
||||
|
||||
// Initially URL should not have spanId
|
||||
expect(mockUrlQuery.get('spanId')).toBeNull();
|
||||
|
||||
// Click on the actual span element (not the wrapper)
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify URL was updated with spanId
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith({
|
||||
search: expect.stringContaining('spanId=span-1'),
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking span duration visually selects the span', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click on span-2 to test selection change
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2DurationElement);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('both click areas produce the same visual result', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanOverviewElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Initially both areas should show the same visual selection (first span is auto-selected)
|
||||
expect(spanOverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click span-2 to test selection change
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2Element);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanOverviewElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Now span-2 should be selected, span-1 should not
|
||||
expect(spanOverviewElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
|
||||
// Check that span-2 is selected
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2OverviewElement = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
expect(span2OverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking different spans updates selection correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const span1Element = span1Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
expect(span1Element).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click second span
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2Element);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const span1Element = span1Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Second span should be selected, first should not
|
||||
expect(span1Element).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2Element).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing URL parameters when selecting spans', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Pre-populate URL with existing parameters
|
||||
mockUrlQuery.set('existingParam', 'existingValue');
|
||||
mockUrlQuery.set('anotherParam', 'anotherValue');
|
||||
|
||||
render(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
setTraceFlamegraphStatsWidth={jest.fn()}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/trace' },
|
||||
);
|
||||
|
||||
// Click on the actual span element (not the wrapper)
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify existing parameters are preserved and spanId is added
|
||||
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
|
||||
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
|
||||
// Verify navigation was called with all parameters
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith({
|
||||
search: expect.stringMatching(
|
||||
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
7
frontend/src/container/TraceWaterfall/constants.ts
Normal file
7
frontend/src/container/TraceWaterfall/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum TraceWaterfallStates {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
|
||||
}
|
||||
31
frontend/src/hooks/trace/useGetTraceFlamegraph.tsx
Normal file
31
frontend/src/hooks/trace/useGetTraceFlamegraph.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceFlamegraph from 'api/trace/getTraceFlamegraph';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceFlamegraphPayloadProps,
|
||||
GetTraceFlamegraphSuccessResponse,
|
||||
} from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
const useGetTraceFlamegraph = (
|
||||
props: GetTraceFlamegraphPayloadProps,
|
||||
): UseLicense =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceFlamegraph(props),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.selectFields,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseLicense = UseQueryResult<
|
||||
SuccessResponse<GetTraceFlamegraphSuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceFlamegraph;
|
||||
30
frontend/src/hooks/trace/useGetTraceV2.tsx
Normal file
30
frontend/src/hooks/trace/useGetTraceV2.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceV2 from 'api/trace/getTraceV2';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV2PayloadProps,
|
||||
GetTraceV2SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV2';
|
||||
|
||||
const useGetTraceV2 = (props: GetTraceV2PayloadProps): UseLicense =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceV2(props),
|
||||
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_WATERFALL,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseLicense = UseQueryResult<
|
||||
SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceV2;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Redirect, useParams } from 'react-router-dom';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
// Legacy /trace-old/:id now redirects to the current /trace/:id view,
|
||||
// preserving the query string and hash.
|
||||
export default function TraceDetailOldRedirect(): JSX.Element {
|
||||
const { id } = useParams<TraceDetailV3URLProps>();
|
||||
const { id } = useParams<TraceDetailV2URLProps>();
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
|
||||
125
frontend/src/pages/TraceDetailV2/NoData/NoData.styles.scss
Normal file
125
frontend/src/pages/TraceDetailV2/NoData/NoData.styles.scss
Normal file
@@ -0,0 +1,125 @@
|
||||
.not-found-trace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
width: 500px;
|
||||
gap: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.not-found-img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.not-found-text-1 {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
.not-found-text-2 {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reasons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.reason-1 {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
|
||||
.construction-img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
.reason-2 {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
|
||||
.broom-img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.none-of-above {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
width: 160px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
box-shadow: none;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
@@ -8,59 +8,53 @@ import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import constructionUrl from '@/assets/Icons/construction.svg';
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
import './NoData.styles.scss';
|
||||
|
||||
function NoData(): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
return (
|
||||
<div className={styles.notFoundTrace} data-testid="trace-no-data">
|
||||
<section className={styles.description}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.notFoundImg} />
|
||||
<Typography.Text className={styles.notFoundText1}>
|
||||
<div className="not-found-trace">
|
||||
<section className="description">
|
||||
<img src={noDataUrl} alt="no-data" className="not-found-img" />
|
||||
<Typography.Text className="not-found-text-1">
|
||||
Uh-oh! We cannot show the selected trace.
|
||||
<span className={styles.notFoundText2}>
|
||||
<span className="not-found-text-2">
|
||||
This can happen in either of the two scenarios -
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.reasons}>
|
||||
<div className={styles.reason}>
|
||||
<img src={constructionUrl} alt="no-data" className={styles.reasonImg} />
|
||||
<Typography.Text className={styles.reasonText}>
|
||||
<section className="reasons">
|
||||
<div className="reason-1">
|
||||
<img src={constructionUrl} alt="no-data" className="construction-img" />
|
||||
<Typography.Text className="text">
|
||||
The trace data has not been rendered on your SigNoz server yet. You can
|
||||
wait for a bit and refresh this page if this is the case.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.reason}>
|
||||
<img src={broomUrl} alt="no-data" className={styles.reasonImg} />
|
||||
<Typography.Text className={styles.reasonText}>
|
||||
<div className="reason-2">
|
||||
<img src={broomUrl} alt="no-data" className="broom-img" />
|
||||
<Typography.Text className="text">
|
||||
The trace has been deleted as the data has crossed it’s retention period.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.noneOfAbove}>
|
||||
<Typography.Text className={styles.noneText}>
|
||||
<section className="none-of-above">
|
||||
<Typography.Text className="text">
|
||||
If you feel the issue is none of the above, please contact support.
|
||||
</Typography.Text>
|
||||
<div className={styles.actionBtns}>
|
||||
<div className="action-btns">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.actionBtn}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
className="action-btn"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={(): void => window.location.reload()}
|
||||
testId="trace-no-data-refresh-button"
|
||||
>
|
||||
Refresh this page
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.actionBtn}
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
className="action-btn"
|
||||
icon={<LifeBuoy size={14} />}
|
||||
onClick={(): void => handleContactSupport(isCloudUserVal)}
|
||||
testId="trace-no-data-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
158
frontend/src/pages/TraceDetailV2/TraceDetailV2.styles.scss
Normal file
158
frontend/src/pages/TraceDetailV2/TraceDetailV2.styles.scss
Normal file
@@ -0,0 +1,158 @@
|
||||
.traces-module-container {
|
||||
.funnel-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.trace-detail-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
.trace-module {
|
||||
.ant-tabs-tab {
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
.tab-item {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
.ant-tabs-nav {
|
||||
margin: 0px;
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before {
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
transform: translate(15px, 0px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.old-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
/* Bifrost (Ancient)/Content/sm */
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 44px);
|
||||
|
||||
.trace-left-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
padding-top: 16px;
|
||||
|
||||
.flamegraph-waterfall-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 31px;
|
||||
color: var(--l2-foreground);
|
||||
padding: 5px 20px;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.span-list-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 31px;
|
||||
padding: 5px 20px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-visualisation-tabs {
|
||||
.ant-tabs-tab {
|
||||
border-radius: 2px 0px 0px 0px;
|
||||
background: var(--l2-background);
|
||||
border-radius: 2px 2px 0px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
height: 31px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.ant-btn {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab + .ant-tabs-tab {
|
||||
margin: 0px;
|
||||
border-left: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
height: 1px !important;
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
transform: translate(15px, 0px) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
margin: 0px;
|
||||
padding: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
177
frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx
Normal file
177
frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@signozhq/resizable';
|
||||
import { Button, Tabs } from 'antd';
|
||||
import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
|
||||
import cx from 'classnames';
|
||||
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
|
||||
import SpanDetailsDrawer from 'container/SpanDetailsDrawer/SpanDetailsDrawer';
|
||||
import TraceMetadata from 'container/TraceMetadata/TraceMetadata';
|
||||
import TraceWaterfall, {
|
||||
IInterestedSpan,
|
||||
} from 'container/TraceWaterfall/TraceWaterfall';
|
||||
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import NoData from './NoData/NoData';
|
||||
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
function TraceDetailsV2(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV2URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [traceFlamegraphStatsWidth, setTraceFlamegraphStatsWidth] =
|
||||
useState<number>(450);
|
||||
const [isSpanDetailsDocked, setIsSpanDetailsDocked] = useState<boolean>(false);
|
||||
const [selectedSpan, setSelectedSpan] = useState<Span>();
|
||||
|
||||
useEffect(() => {
|
||||
setInterestedSpanId({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
});
|
||||
}, [urlQuery]);
|
||||
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV2({
|
||||
traceId,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (traceData && traceData.payload && traceData.payload.uncollapsedSpans) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSpan) {
|
||||
setIsSpanDetailsDocked(false);
|
||||
}
|
||||
}, [selectedSpan]);
|
||||
|
||||
const noData = useMemo(
|
||||
() =>
|
||||
!isFetchingTraceData &&
|
||||
!errorFetchingTraceData &&
|
||||
defaultTo(traceData?.payload?.spans?.length, 0) === 0,
|
||||
[
|
||||
errorFetchingTraceData,
|
||||
isFetchingTraceData,
|
||||
traceData?.payload?.spans?.length,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (noData) {
|
||||
setIsSpanDetailsDocked(true);
|
||||
}
|
||||
}, [noData]);
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FlamegraphImg />}
|
||||
className="flamegraph-waterfall-toggle"
|
||||
>
|
||||
Flamegraph
|
||||
</Button>
|
||||
),
|
||||
key: 'flamegraph',
|
||||
children: (
|
||||
<>
|
||||
<TraceFlamegraph
|
||||
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap || {}}
|
||||
startTime={traceData?.payload?.startTimestampMillis || 0}
|
||||
endTime={traceData?.payload?.endTimestampMillis || 0}
|
||||
traceFlamegraphStatsWidth={traceFlamegraphStatsWidth}
|
||||
selectedSpan={selectedSpan}
|
||||
/>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
setTraceFlamegraphStatsWidth={setTraceFlamegraphStatsWidth}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
autoSaveId="trace-drawer"
|
||||
className="trace-layout"
|
||||
>
|
||||
<ResizablePanel minSize={20} maxSize={80} className="trace-left-content">
|
||||
<TraceMetadata
|
||||
traceID={traceId}
|
||||
isDataLoading={isFetchingTraceData}
|
||||
duration={
|
||||
(traceData?.payload?.endTimestampMillis || 0) -
|
||||
(traceData?.payload?.startTimestampMillis || 0)
|
||||
}
|
||||
startTime={(traceData?.payload?.startTimestampMillis || 0) / 1e3}
|
||||
rootServiceName={traceData?.payload?.rootServiceName || ''}
|
||||
rootSpanName={traceData?.payload?.rootServiceEntryPoint || ''}
|
||||
totalErrorSpans={traceData?.payload?.totalErrorSpansCount || 0}
|
||||
totalSpans={traceData?.payload?.totalSpansCount || 0}
|
||||
notFound={noData}
|
||||
/>
|
||||
{!noData ? (
|
||||
<Tabs items={items} animated className="trace-visualisation-tabs" />
|
||||
) : (
|
||||
<NoData />
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle className="resizable-handle" />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={20}
|
||||
minSize={20}
|
||||
maxSize={50}
|
||||
className={cx('span-details-drawer', {
|
||||
'span-details-drawer-docked': isSpanDetailsDocked,
|
||||
})}
|
||||
>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={isSpanDetailsDocked}
|
||||
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsV2;
|
||||
88
frontend/src/pages/TraceDetailV2/index.tsx
Normal file
88
frontend/src/pages/TraceDetailV2/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Tabs } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, Cone, TowerControl } from '@signozhq/icons';
|
||||
|
||||
import TraceDetailsV2 from './TraceDetailV2';
|
||||
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
interface INewTraceDetailProps {
|
||||
items: {
|
||||
label: JSX.Element;
|
||||
key: string;
|
||||
children: JSX.Element;
|
||||
}[];
|
||||
}
|
||||
|
||||
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
const { items } = props;
|
||||
return (
|
||||
<div className="traces-module-container">
|
||||
<Tabs
|
||||
items={items}
|
||||
animated
|
||||
className="trace-module"
|
||||
tabBarExtraContent={{
|
||||
right: (
|
||||
<div className="trace-detail-header-actions">
|
||||
<HeaderRightSection
|
||||
enableShare
|
||||
enableFeedback
|
||||
enableAnnouncements={false}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
onTabClick={(activeKey): void => {
|
||||
if (activeKey === 'saved-views') {
|
||||
history.push(ROUTES.TRACES_SAVE_VIEWS);
|
||||
}
|
||||
if (activeKey === 'trace-details') {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
if (activeKey === 'funnels') {
|
||||
logEvent('Trace Funnels: visited from trace details page', {});
|
||||
history.push(ROUTES.TRACES_FUNNELS);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TraceDetailsPage(): JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<Compass size={16} /> Explorer
|
||||
</div>
|
||||
),
|
||||
key: 'trace-details',
|
||||
children: <TraceDetailsV2 />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<Cone className="funnel-icon" size={16} /> Funnels
|
||||
</div>
|
||||
),
|
||||
key: 'funnels',
|
||||
children: <div />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<TowerControl size={16} /> Views
|
||||
</div>
|
||||
),
|
||||
key: 'saved-views',
|
||||
children: <div />,
|
||||
},
|
||||
];
|
||||
|
||||
return <NewTraceDetail items={items} />;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
.notFoundTrace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
width: 500px;
|
||||
gap: var(--spacing-12);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.notFoundImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.notFoundText1 {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.notFoundText2 {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.reasons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.reason {
|
||||
display: flex;
|
||||
padding: var(--spacing-6);
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-6);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
.reasonImg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.reasonText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.noneOfAbove {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.noneText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.actionBtns {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 160px;
|
||||
}
|
||||
@@ -148,7 +148,7 @@ function AnalyticsPanel({
|
||||
className="floating-panel__drag-handle"
|
||||
/>
|
||||
|
||||
<div className={styles.body} data-testid="trace-analytics-panel">
|
||||
<div className={styles.body}>
|
||||
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
|
||||
@@ -60,7 +60,7 @@ function DockModeSwitcher({
|
||||
{DOCK_OPTIONS.map((option) => (
|
||||
<TooltipRoot key={option.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<span data-testid={`dock-mode-${option.value}`}>
|
||||
<span>
|
||||
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
.noEvents {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.eventsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.event {
|
||||
:global(.ant-collapse) {
|
||||
border: none;
|
||||
}
|
||||
:global(.ant-collapse-content) {
|
||||
border-top: none;
|
||||
}
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
:global(.ant-collapse-content-box) {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
}
|
||||
:global(.ant-collapse-header) {
|
||||
display: flex;
|
||||
padding: var(--spacing-4) var(--spacing-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
padding-inline-start: 0px;
|
||||
padding-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.collapseTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.diamond {
|
||||
fill: var(--accent-primary);
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.attributeContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.attributeKey {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.timestampContainer {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
|
||||
.attributeValue {
|
||||
display: flex;
|
||||
padding: 2px var(--spacing-4);
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.timestampText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
padding: 2px var(--spacing-4);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.attributeValue {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.fullView {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
.preview {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: var(--spacing-4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
@@ -65,9 +68,6 @@ import {
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
import Events from './Events/Events';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
|
||||
@@ -424,8 +424,9 @@ function SpanDetailsContent({
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
|
||||
<Events
|
||||
span={selectedSpan}
|
||||
span={{ ...selectedSpan, event: selectedSpan.events } as any}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
.spanLogs {
|
||||
margin-inline: var(--spacing-8);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spanLogsVirtuoso {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
.spanLogsListContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logsLoadingSkeleton {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.spanLogsEmptyContent {
|
||||
height: 100%;
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-48);
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-6);
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.noDataText1 {
|
||||
color: var(--l2-foreground);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.noDataText2 {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.actionSection {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -64,11 +64,7 @@ export function SpanTooltipContent({
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<div key={row.key} className={styles.row}>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
@@ -81,7 +81,7 @@ function TraceDetailsHeader({
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV3URLProps>();
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
|
||||
import { useFlamegraphTestHook } from './hooks/useFlamegraphTestHook';
|
||||
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
|
||||
@@ -160,14 +159,6 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
|
||||
useCanvasSetup(canvasRef, containerRef, drawFlamegraph, overlayCanvasRef);
|
||||
|
||||
// E2E-only: expose the live span→rect map so specs can target canvas bars.
|
||||
// No-op unless window.__SIGNOZ_E2E__ is set (Playwright addInitScript).
|
||||
useFlamegraphTestHook({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spanRectsRef,
|
||||
});
|
||||
|
||||
const {
|
||||
cursorXPercent,
|
||||
cursorX,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
|
||||
import { SpanRect } from '../types';
|
||||
|
||||
/**
|
||||
* E2E test hook for the canvas flamegraph. The flamegraph is `<canvas>`, so
|
||||
* individual bars have no DOM nodes to target — but `spanRectsRef` already
|
||||
* holds the live span→rectangle map (CSS pixels) used for hit-testing. This
|
||||
* exposes a thin, read-only view of it on `window.__sigTraceFlame__` so a
|
||||
* Playwright spec can resolve a span's on-screen point and drive real
|
||||
* hover/click events at it (see tests/e2e/helpers/flamegraph.ts).
|
||||
*
|
||||
* Gated on `window.__SIGNOZ_E2E__` (set by Playwright via addInitScript), so
|
||||
* nothing is attached in normal runtime — the e2e build is a production build,
|
||||
* so this must be a RUNTIME flag, not a NODE_ENV/mode check.
|
||||
*/
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FlamegraphTestApi {
|
||||
getSpanPoint: (spanId: string) => Point | null;
|
||||
isSpanInView: (spanId: string) => boolean;
|
||||
// Resting group color of a span's bar — changes when colour-by changes.
|
||||
getSpanColor: (spanId: string) => string | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SIGNOZ_E2E__?: boolean;
|
||||
__sigTraceFlame__?: FlamegraphTestApi;
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of `getCanvasPointer` in useFlamegraphHover: a CSS-space span rect
|
||||
// maps back to a viewport point at the bar's center.
|
||||
function rectToViewportCenter(canvas: HTMLCanvasElement, r: SpanRect): Point {
|
||||
const box = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const cssHeight = canvas.height / dpr;
|
||||
const cssX = r.x + r.width / 2;
|
||||
const cssY = r.y + r.height / 2;
|
||||
return {
|
||||
x: box.left + cssX * (box.width / cssWidth),
|
||||
y: box.top + cssY * (box.height / cssHeight),
|
||||
};
|
||||
}
|
||||
|
||||
interface UseFlamegraphTestHookParams {
|
||||
canvasRef: MutableRefObject<HTMLCanvasElement | null>;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
spanRectsRef: MutableRefObject<SpanRect[]>;
|
||||
}
|
||||
|
||||
export function useFlamegraphTestHook({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spanRectsRef,
|
||||
}: UseFlamegraphTestHookParams): void {
|
||||
useEffect(() => {
|
||||
if (!window.__SIGNOZ_E2E__) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Reads `.current` at call time, so it always reflects the latest draw.
|
||||
const findRect = (spanId: string): SpanRect | undefined =>
|
||||
spanRectsRef.current.find((r) => r.span.spanId === spanId);
|
||||
|
||||
window.__sigTraceFlame__ = {
|
||||
getSpanPoint: (spanId): Point | null => {
|
||||
const canvas = canvasRef.current;
|
||||
const rect = findRect(spanId);
|
||||
return canvas && rect ? rectToViewportCenter(canvas, rect) : null;
|
||||
},
|
||||
isSpanInView: (spanId): boolean => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
const rect = findRect(spanId);
|
||||
if (!canvas || !container || !rect) {
|
||||
return false;
|
||||
}
|
||||
const pt = rectToViewportCenter(canvas, rect);
|
||||
const box = container.getBoundingClientRect();
|
||||
return (
|
||||
pt.x >= box.left &&
|
||||
pt.x <= box.right &&
|
||||
pt.y >= box.top &&
|
||||
pt.y <= box.bottom
|
||||
);
|
||||
},
|
||||
getSpanColor: (spanId): string | null => findRect(spanId)?.color ?? null,
|
||||
};
|
||||
|
||||
return (): void => {
|
||||
delete window.__sigTraceFlame__;
|
||||
};
|
||||
}, [canvasRef, containerRef, spanRectsRef]);
|
||||
}
|
||||
@@ -28,9 +28,6 @@ export interface SpanRect {
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
// Resting fill color for the current colour-by grouping. Optional: only the
|
||||
// draw path sets it; consumers (e.g. the e2e colour-by hook) read it.
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface EventRect {
|
||||
|
||||
@@ -279,9 +279,6 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
width,
|
||||
height: metrics.SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
// Resting group color (selected/hovered bars override the fill, but this
|
||||
// still reflects the colour-by grouping — used by the e2e colour-by hook).
|
||||
color: isDarkMode ? color : colorDark,
|
||||
});
|
||||
|
||||
span.event?.forEach((event) => {
|
||||
|
||||
@@ -72,7 +72,7 @@ function FunnelDetailsView({
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
span={span as any}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
|
||||
@@ -1,45 +1,35 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanLineActionButtons from '../index';
|
||||
|
||||
// Mock the useCopySpanLink hook
|
||||
jest.mock('hooks/trace/useCopySpanLink');
|
||||
|
||||
const mockSpan: SpanV3 = {
|
||||
span_id: 'test-span-id',
|
||||
trace_id: 'test-trace-id',
|
||||
parent_span_id: 'test-parent-span-id',
|
||||
timestamp: 1234567890,
|
||||
duration_nano: 1000,
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
'service.name': 'test-service',
|
||||
has_error: false,
|
||||
status_message: 'test-status-message',
|
||||
status_code: 0,
|
||||
status_code_string: 'test-status-code-string',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1000,
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
kind: 0,
|
||||
kind_string: 'test-span-kind',
|
||||
has_children: false,
|
||||
has_sibling: false,
|
||||
sub_tree_node_count: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
attributes: {},
|
||||
resource: {},
|
||||
events: [],
|
||||
http_method: '',
|
||||
http_url: '',
|
||||
http_host: '',
|
||||
db_name: '',
|
||||
db_operation: '',
|
||||
external_http_method: '',
|
||||
external_http_url: '',
|
||||
response_status_code: '',
|
||||
is_remote: '',
|
||||
flags: 0,
|
||||
trace_state: '',
|
||||
};
|
||||
|
||||
describe('SpanLineActionButtons', () => {
|
||||
@@ -104,7 +94,7 @@ describe('SpanLineActionButtons', () => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mockUrlQuery.delete('spanId');
|
||||
mockUrlQuery.set('spanId', mockSpan.span_id);
|
||||
mockUrlQuery.set('spanId', mockSpan.spanId);
|
||||
const link = `${
|
||||
window.location.origin
|
||||
}${mockPathname}?${mockUrlQuery.toString()}`;
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Link } from '@signozhq/icons';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import styles from './SpanLineActionButtons.module.scss';
|
||||
|
||||
export interface SpanLineActionButtonsProps {
|
||||
span: SpanV3;
|
||||
span: Span;
|
||||
}
|
||||
export default function SpanLineActionButtons({
|
||||
span,
|
||||
|
||||
@@ -259,10 +259,7 @@ function Filters({
|
||||
);
|
||||
|
||||
const highlightErrorsToggle = (
|
||||
<div
|
||||
className={styles.highlightErrorsToggle}
|
||||
data-testid="highlight-errors-toggle"
|
||||
>
|
||||
<div className={styles.highlightErrorsToggle}>
|
||||
<Typography.Text>Highlight errors</Typography.Text>
|
||||
<Switch
|
||||
color="cherry"
|
||||
|
||||
@@ -246,19 +246,6 @@ const SpanOverview = memo(function SpanOverview({
|
||||
onAddSpanToFunnel(span);
|
||||
};
|
||||
|
||||
// e2e hook: expose the filter highlight/dim state as a stable attribute, since
|
||||
// the styles.* classes are hashed at build time and can't be asserted.
|
||||
let spanState = 'default';
|
||||
if (isHighlighted) {
|
||||
spanState = 'highlighted';
|
||||
} else if (isDimmed) {
|
||||
spanState = 'dimmed';
|
||||
} else if (isSelectedNonMatching) {
|
||||
spanState = 'selected-non-matching';
|
||||
} else if (isSelected) {
|
||||
spanState = 'selected';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.spanOverview, {
|
||||
@@ -267,7 +254,6 @@ const SpanOverview = memo(function SpanOverview({
|
||||
[styles.isSelectedNonMatching]: isSelectedNonMatching,
|
||||
[styles.isDimmed]: isDimmed,
|
||||
})}
|
||||
data-span-state={spanState}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
onMouseEnter={(): void => onHoverEnter(span.span_id)}
|
||||
onMouseLeave={(): void => onHoverLeave()}
|
||||
@@ -315,7 +301,6 @@ const SpanOverview = memo(function SpanOverview({
|
||||
{span.has_children && (
|
||||
<span
|
||||
className={styles.treeArrow}
|
||||
data-testid={`cell-collapse-${span.span_id}`}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
@@ -41,6 +41,19 @@ jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
|
||||
|
||||
// React Router is already globally mocked
|
||||
|
||||
// Mock complex external dependencies that cause provider issues
|
||||
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
|
||||
function SpanHoverCard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
SpanHoverCard.displayName = 'SpanHoverCard';
|
||||
return SpanHoverCard;
|
||||
});
|
||||
|
||||
// Mock the Filters component that's causing React Query issues
|
||||
jest.mock('../Filters/Filters', () => {
|
||||
function Filters(): null {
|
||||
|
||||
@@ -11,12 +11,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import NoData from 'pages/TraceDetailV2/NoData/NoData';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
|
||||
@@ -9,7 +9,7 @@ import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/Funnel
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
|
||||
@@ -23,7 +23,7 @@ import './FunnelConfiguration.styles.scss';
|
||||
interface FunnelConfigurationProps {
|
||||
funnel: FunnelData;
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: SpanV3;
|
||||
span?: Span;
|
||||
triggerAutoSave?: boolean;
|
||||
showNotifications?: boolean;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { Plus, Undo2 } from '@signozhq/icons';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import FunnelStep from './FunnelStep';
|
||||
import InterStepConfig from './InterStepConfig';
|
||||
@@ -18,7 +18,7 @@ function StepsContent({
|
||||
span,
|
||||
}: {
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: SpanV3;
|
||||
span?: Span;
|
||||
}): JSX.Element {
|
||||
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
|
||||
const { hasEditPermission } = useAppContext();
|
||||
@@ -30,7 +30,7 @@ function StepsContent({
|
||||
|
||||
const stepWasAdded = handleAddStep();
|
||||
if (stepWasAdded) {
|
||||
handleReplaceStep(steps.length, span['service.name'], span.name);
|
||||
handleReplaceStep(steps.length, span.serviceName, span.name);
|
||||
}
|
||||
logEvent(
|
||||
'Trace Funnels: span added for a new step from trace details page',
|
||||
@@ -61,12 +61,12 @@ function StepsContent({
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
(step.service_name === span['service.name'] &&
|
||||
(step.service_name === span.serviceName &&
|
||||
step.span_name === span.name) ||
|
||||
!hasEditPermission
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span['service.name'], span.name)
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user