mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-12 16:23:19 +00:00
Compare commits
6 Commits
feat/trace
...
feat/pnpm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f054121477 | ||
|
|
61df12d126 | ||
|
|
b846faa1fa | ||
|
|
557451ed81 | ||
|
|
25c513ec2f | ||
|
|
ae71f2608a |
@@ -1,4 +1,22 @@
|
||||
services:
|
||||
init-clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
container_name: init-clickhouse
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
version="v0.0.1"
|
||||
node_os=$$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
node_arch=$$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/)
|
||||
echo "Fetching histogram-binary for $${node_os}/$${node_arch}"
|
||||
cd /tmp
|
||||
wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F$${version}/histogram-quantile_$${node_os}_$${node_arch}.tar.gz"
|
||||
tar -xvzf histogram-quantile.tar.gz
|
||||
mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
container_name: clickhouse
|
||||
@@ -7,6 +25,7 @@ services:
|
||||
- ${PWD}/fs/etc/clickhouse-server/users.d/users.xml:/etc/clickhouse-server/users.d/users.xml
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/:/var/lib/clickhouse/
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
|
||||
- ${PWD}/../../../deploy/common/clickhouse/custom-function.xml:/etc/clickhouse-server/custom-function.xml
|
||||
ports:
|
||||
- '127.0.0.1:8123:8123'
|
||||
- '127.0.0.1:9000:9000'
|
||||
@@ -22,7 +41,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- zookeeper
|
||||
init-clickhouse:
|
||||
condition: service_completed_successfully
|
||||
zookeeper:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- CLICKHOUSE_SKIP_USER_SETUP=1
|
||||
zookeeper:
|
||||
|
||||
@@ -44,4 +44,6 @@
|
||||
<shard>01</shard>
|
||||
<replica>01</replica>
|
||||
</macros>
|
||||
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
|
||||
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
|
||||
</clickhouse>
|
||||
14
.github/workflows/goci.yaml
vendored
14
.github/workflows/goci.yaml
vendored
@@ -77,6 +77,10 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: setup-pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: docker-community
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -106,9 +110,13 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: setup-pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: install-frontend
|
||||
run: cd frontend && yarn install
|
||||
run: cd frontend && pnpm install
|
||||
- name: generate-api-clients
|
||||
run: |
|
||||
cd frontend && yarn generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run yarn generate:api in frontend/ locally and commit."; exit 1)
|
||||
cd frontend && pnpm generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -25,6 +25,10 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: setup-pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: build-frontend
|
||||
run: make js-build
|
||||
- name: upload-frontend-artifact
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -41,6 +41,10 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: setup-pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: build-frontend
|
||||
run: make js-build
|
||||
- name: upload-frontend-artifact
|
||||
|
||||
11
.github/workflows/jsci.yaml
vendored
11
.github/workflows/jsci.yaml
vendored
@@ -78,10 +78,15 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
yarn install
|
||||
pnpm install
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
@@ -98,7 +103,7 @@ jobs:
|
||||
- name: Generate permissions.type.ts
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
yarn generate:permissions-type
|
||||
pnpm generate:permissions-type
|
||||
|
||||
- name: Teardown test environment
|
||||
if: always()
|
||||
@@ -108,6 +113,6 @@ jobs:
|
||||
- name: Check for changes
|
||||
run: |
|
||||
if ! git diff --exit-code frontend/src/hooks/useAuthZ/permissions.type.ts; then
|
||||
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: npm run generate:permissions-type (from the frontend directory)"
|
||||
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: pnpm generate:permissions-type (from the frontend directory)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
12
.github/workflows/run-e2e.yaml
vendored
12
.github/workflows/run-e2e.yaml
vendored
@@ -27,6 +27,11 @@ jobs:
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Mask secrets and input
|
||||
run: |
|
||||
echo "::add-mask::${{ secrets.BASE_URL }}"
|
||||
@@ -37,12 +42,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm install -g yarn
|
||||
yarn
|
||||
pnpm install
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: frontend
|
||||
run: yarn playwright install --with-deps
|
||||
run: pnpm playwright install --with-deps
|
||||
|
||||
- name: Run Playwright Tests
|
||||
working-directory: frontend
|
||||
@@ -51,7 +55,7 @@ jobs:
|
||||
LOGIN_USERNAME="${{ secrets.LOGIN_USERNAME }}" \
|
||||
LOGIN_PASSWORD="${{ secrets.LOGIN_PASSWORD }}" \
|
||||
USER_ROLE="${{ github.event.inputs.userRole }}" \
|
||||
yarn playwright test
|
||||
pnpm playwright test
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
10
.gitpod.yml
10
.gitpod.yml
@@ -1,19 +1,21 @@
|
||||
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
|
||||
# and commit this file to your remote git repository to share the goodness with others.
|
||||
|
||||
|
||||
tasks:
|
||||
- name: Run Docker Images
|
||||
init: |
|
||||
cd ./deploy/docker
|
||||
sudo docker compose up -d
|
||||
|
||||
- name: Install pnpm
|
||||
init: |
|
||||
npm i -g pnpm
|
||||
|
||||
- name: Run Frontend
|
||||
init: |
|
||||
cd ./frontend
|
||||
yarn install
|
||||
command:
|
||||
yarn dev
|
||||
pnpm install
|
||||
command: pnpm dev
|
||||
|
||||
ports:
|
||||
- port: 8080
|
||||
|
||||
2
Makefile
2
Makefile
@@ -154,7 +154,7 @@ $(GO_BUILD_ARCHS_ENTERPRISE_RACE): go-build-enterprise-race-%: $(TARGET_DIR)
|
||||
.PHONY: js-build
|
||||
js-build: ## Builds the js frontend
|
||||
@echo ">> building js frontend"
|
||||
@cd $(JS_BUILD_CONTEXT) && CI=1 yarn install && yarn build
|
||||
@cd $(JS_BUILD_CONTEXT) && CI=1 pnpm install && pnpm build
|
||||
|
||||
##############################################################
|
||||
# docker commands
|
||||
|
||||
@@ -3,8 +3,9 @@ FROM node:22-bookworm AS build
|
||||
WORKDIR /opt/
|
||||
COPY ./frontend/ ./
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
RUN CI=1 yarn install
|
||||
RUN CI=1 yarn build
|
||||
RUN CI=1 npm i -g pnpm
|
||||
RUN CI=1 pnpm install
|
||||
RUN CI=1 pnpm build
|
||||
|
||||
FROM golang:1.25-bookworm
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.1
|
||||
image: signoz/signoz:v0.115.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.1
|
||||
image: signoz/signoz:v0.115.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
image: signoz/signoz:${VERSION:-v0.115.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
image: signoz/signoz:${VERSION:-v0.115.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -13,13 +13,12 @@ Before diving in, make sure you have these tools installed:
|
||||
- Download from [go.dev/dl](https://go.dev/dl/)
|
||||
- Check [go.mod](../../go.mod#L3) for the minimum version
|
||||
|
||||
|
||||
- **Node** - Powers our frontend
|
||||
- Download from [nodejs.org](https://nodejs.org)
|
||||
- Check [.nvmrc](../../frontend/.nvmrc) for the version
|
||||
|
||||
- **Yarn** - Our frontend package manager
|
||||
- Follow the [installation guide](https://yarnpkg.com/getting-started/install)
|
||||
- **Pnpm** - Our frontend package manager
|
||||
- Follow the [installation guide](https://pnpm.io/installation)
|
||||
|
||||
- **Docker** - For running Clickhouse and Postgres locally
|
||||
- Get it from [docs.docker.com/get-docker](https://docs.docker.com/get-docker/)
|
||||
@@ -95,7 +94,7 @@ This command:
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
yarn install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Create a `.env` file in this directory:
|
||||
@@ -105,10 +104,10 @@ This command:
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
> 💡 **Tip**: `yarn dev` will automatically rebuild when you make changes to the code
|
||||
> 💡 **Tip**: `pnpm dev` will automatically rebuild when you make changes to the code
|
||||
|
||||
Now you're all set to start developing! Happy coding! 🎉
|
||||
|
||||
|
||||
@@ -18,40 +18,40 @@ The configuration file is a JSON array containing data source objects. Each obje
|
||||
|
||||
### Required Keys
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `dataSource` | `string` | Unique identifier for the data source (kebab-case, e.g., `"aws-ec2"`) |
|
||||
| `label` | `string` | Display name shown to users (e.g., `"AWS EC2"`) |
|
||||
| `tags` | `string[]` | Array of category tags for grouping (e.g., `["AWS"]`, `["database"]`) |
|
||||
| `module` | `string` | Destination module after onboarding completion |
|
||||
| `imgUrl` | `string` | Path to the logo/icon **(SVG required)** (e.g., `"/Logos/ec2.svg"`) |
|
||||
| Key | Type | Description |
|
||||
| ------------ | ---------- | --------------------------------------------------------------------- |
|
||||
| `dataSource` | `string` | Unique identifier for the data source (kebab-case, e.g., `"aws-ec2"`) |
|
||||
| `label` | `string` | Display name shown to users (e.g., `"AWS EC2"`) |
|
||||
| `tags` | `string[]` | Array of category tags for grouping (e.g., `["AWS"]`, `["database"]`) |
|
||||
| `module` | `string` | Destination module after onboarding completion |
|
||||
| `imgUrl` | `string` | Path to the logo/icon **(SVG required)** (e.g., `"/Logos/ec2.svg"`) |
|
||||
|
||||
### Optional Keys
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `link` | `string` | Docs link to redirect to (e.g., `"/docs/aws-monitoring/ec2/"`) |
|
||||
| `relatedSearchKeywords` | `string[]` | Array of keywords for search functionality |
|
||||
| `question` | `object` | Nested question object for multi-step flows |
|
||||
| `internalRedirect` | `boolean` | When `true`, navigates within the app instead of showing docs |
|
||||
| Key | Type | Description |
|
||||
| ----------------------- | ---------- | -------------------------------------------------------------- |
|
||||
| `link` | `string` | Docs link to redirect to (e.g., `"/docs/aws-monitoring/ec2/"`) |
|
||||
| `relatedSearchKeywords` | `string[]` | Array of keywords for search functionality |
|
||||
| `question` | `object` | Nested question object for multi-step flows |
|
||||
| `internalRedirect` | `boolean` | When `true`, navigates within the app instead of showing docs |
|
||||
|
||||
## Module Values
|
||||
|
||||
The `module` key determines where users are redirected after completing onboarding:
|
||||
|
||||
| Value | Destination |
|
||||
|-------|-------------|
|
||||
| `apm` | APM / Traces |
|
||||
| `logs` | Logs Explorer |
|
||||
| `metrics` | Metrics Explorer |
|
||||
| `dashboards` | Dashboards |
|
||||
| `infra-monitoring-hosts` | Infrastructure Monitoring - Hosts |
|
||||
| `infra-monitoring-k8s` | Infrastructure Monitoring - Kubernetes |
|
||||
| `messaging-queues-kafka` | Messaging Queues - Kafka |
|
||||
| `messaging-queues-celery` | Messaging Queues - Celery |
|
||||
| `integrations` | Integrations page |
|
||||
| `home` | Home page |
|
||||
| `api-monitoring` | API Monitoring |
|
||||
| Value | Destination |
|
||||
| ------------------------- | -------------------------------------- |
|
||||
| `apm` | APM / Traces |
|
||||
| `logs` | Logs Explorer |
|
||||
| `metrics` | Metrics Explorer |
|
||||
| `dashboards` | Dashboards |
|
||||
| `infra-monitoring-hosts` | Infrastructure Monitoring - Hosts |
|
||||
| `infra-monitoring-k8s` | Infrastructure Monitoring - Kubernetes |
|
||||
| `messaging-queues-kafka` | Messaging Queues - Kafka |
|
||||
| `messaging-queues-celery` | Messaging Queues - Celery |
|
||||
| `integrations` | Integrations page |
|
||||
| `home` | Home page |
|
||||
| `api-monitoring` | API Monitoring |
|
||||
|
||||
## Question Object Structure
|
||||
|
||||
@@ -91,14 +91,14 @@ The `question` object enables multi-step selection flows:
|
||||
|
||||
### Question Keys
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `desc` | `string` | Question text displayed to the user |
|
||||
| `type` | `string` | Currently only `"select"` is supported |
|
||||
| `helpText` | `string` | (Optional) Additional help text below the question |
|
||||
| `helpLink` | `string` | (Optional) Docs link for the help section |
|
||||
| Key | Type | Description |
|
||||
| -------------- | -------- | ----------------------------------------------------------- |
|
||||
| `desc` | `string` | Question text displayed to the user |
|
||||
| `type` | `string` | Currently only `"select"` is supported |
|
||||
| `helpText` | `string` | (Optional) Additional help text below the question |
|
||||
| `helpLink` | `string` | (Optional) Docs link for the help section |
|
||||
| `helpLinkText` | `string` | (Optional) Text for the help link (default: "Learn more →") |
|
||||
| `options` | `array` | Array of option objects |
|
||||
| `options` | `array` | Array of option objects |
|
||||
|
||||
## Option Object Structure
|
||||
|
||||
@@ -291,7 +291,7 @@ Options can be simple (direct link) or nested (with another question):
|
||||
|
||||
1. Add your data source object to the JSON array
|
||||
2. Ensure the logo exists in `public/Logos/`
|
||||
3. Test the flow locally with `yarn dev`
|
||||
3. Test the flow locally with `pnpm dev`
|
||||
4. Validation:
|
||||
- Navigate to the [onboarding page](http://localhost:3301/get-started-with-signoz-cloud) on your local machine
|
||||
- Data source appears in the list
|
||||
|
||||
@@ -4,4 +4,5 @@ build
|
||||
i18-generate-hash.js
|
||||
src/parser/TraceOperatorParser/**
|
||||
|
||||
orval.config.ts
|
||||
orval.config.ts
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -41,7 +41,7 @@ module.exports = {
|
||||
'jsx-a11y', // Accessibility rules
|
||||
'import', // Import/export linting
|
||||
'sonarjs', // Code quality/complexity
|
||||
// TODO: Uncomment after running: yarn add -D eslint-plugin-spellcheck
|
||||
// TODO: Uncomment after running: pnpm add -D eslint-plugin-spellcheck
|
||||
// 'spellcheck', // Correct spellings
|
||||
],
|
||||
settings: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd frontend && yarn run commitlint --edit $1
|
||||
cd frontend && pnpm run commitlint --edit $1
|
||||
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd frontend && yarn lint-staged
|
||||
cd frontend && pnpm lint-staged
|
||||
|
||||
@@ -12,4 +12,6 @@ public/
|
||||
# Ignore all files in parser folder:
|
||||
src/parser/**
|
||||
|
||||
src/TraceOperator/parser/**
|
||||
src/TraceOperator/parser/**
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -28,8 +28,8 @@ Follow the steps below
|
||||
1. ```git clone https://github.com/SigNoz/signoz.git && cd signoz/frontend```
|
||||
1. change baseURL to ```<test environment URL>``` in file ```src/constants/env.ts```
|
||||
|
||||
1. ```yarn install```
|
||||
1. ```yarn dev```
|
||||
1. ```pnpm install```
|
||||
1. ```pnpm dev```
|
||||
|
||||
```Note: Please ping us in #contributing channel in our slack community and we will DM you with <test environment URL>```
|
||||
|
||||
@@ -41,7 +41,7 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
### `pnpm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
|
||||
@@ -49,12 +49,12 @@ Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
### `pnpm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
### `pnpm build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
@@ -64,7 +64,7 @@ Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
### `pnpm eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
@@ -100,6 +100,6 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/a
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `yarn build` fails to minify
|
||||
### `pnpm build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* Adds custom matchers from the react testing library to all tests
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import 'jest-styled-components';
|
||||
|
||||
import { server } from './src/mocks-server/server';
|
||||
|
||||
@@ -80,7 +80,7 @@ export default defineConfig({
|
||||
header: (info: { title: string; version: string }): string[] => [
|
||||
`! Do not edit manually`,
|
||||
`* The file has been auto-generated using Orval for SigNoz`,
|
||||
`* regenerate with 'yarn generate:api'`,
|
||||
`* regenerate with 'pnpm generate:api'`,
|
||||
...(info.title ? [info.title] : []),
|
||||
...(info.version ? [`OpenAPI spec version: ${info.version}`] : []),
|
||||
],
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@ant-design/icons": "4.8.0",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/lang-javascript": "6.2.3",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@dnd-kit/core": "6.1.0",
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
@@ -40,7 +41,7 @@
|
||||
"@grafana/data": "^11.2.3",
|
||||
"@mdx-js/loader": "2.3.0",
|
||||
"@mdx-js/react": "2.3.0",
|
||||
"@monaco-editor/react": "^4.3.1",
|
||||
"@monaco-editor/react": "~4.3.1",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@radix-ui/react-tabs": "1.0.4",
|
||||
"@radix-ui/react-tooltip": "1.0.7",
|
||||
@@ -97,7 +98,7 @@
|
||||
"color-alpha": "2.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"d3-hierarchy": "1.1.9",
|
||||
"dayjs": "^1.10.7",
|
||||
"dompurify": "3.2.4",
|
||||
"dotenv": "8.2.0",
|
||||
@@ -122,6 +123,7 @@
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"papaparse": "5.4.1",
|
||||
"posthog-js": "1.298.0",
|
||||
"rc-select": "~14.10.0",
|
||||
"rc-tween-one": "3.0.6",
|
||||
"react": "18.2.0",
|
||||
"react-addons-update": "15.6.3",
|
||||
@@ -182,18 +184,22 @@
|
||||
"@babel/preset-env": "^7.22.14",
|
||||
"@babel/preset-react": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.21.4",
|
||||
"@codemirror/view": "~6.5.1",
|
||||
"@commitlint/cli": "^20.4.2",
|
||||
"@commitlint/config-conventional": "^20.4.2",
|
||||
"@faker-js/faker": "9.3.0",
|
||||
"@jest/globals": "30.2.0",
|
||||
"@jest/types": "^30.3.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/fontfaceobserver": "2.1.0",
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/mini-css-extract-plugin": "^2.5.1",
|
||||
@@ -212,6 +218,7 @@
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"@types/redux-mock-store": "1.0.4",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
"@typescript-eslint/parser": "^4.33.0",
|
||||
@@ -227,6 +234,7 @@
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"eslint-plugin-sonarjs": "^0.12.0",
|
||||
"glob": "^13.0.6",
|
||||
"husky": "^7.0.4",
|
||||
"imagemin": "^8.0.1",
|
||||
"imagemin-svgo": "^10.0.1",
|
||||
@@ -248,6 +256,7 @@
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.34.5",
|
||||
"svgo": "4.0.0",
|
||||
"tailwindcss": "3",
|
||||
"ts-api-utils": "2.4.0",
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-node": "^10.2.1",
|
||||
@@ -281,6 +290,32 @@
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"@codemirror/view": "~6.5.1",
|
||||
"@types/d3-hierarchy": "1.1.11"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"debug": "4.3.4",
|
||||
"semver": "7.5.4",
|
||||
"xml2js": "0.5.0",
|
||||
"phin": "^3.7.1",
|
||||
"body-parser": "1.20.3",
|
||||
"http-proxy-middleware": "3.0.5",
|
||||
"cross-spawn": "7.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"serialize-javascript": "6.0.2",
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.4",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"@codemirror/view": "~6.5.1",
|
||||
"@types/d3-hierarchy": "1.1.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Read from ".env" file.
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(dirname, '.env') });
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
|
||||
23271
frontend/pnpm-lock.yaml
generated
Normal file
23271
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -180,7 +180,7 @@ async function main() {
|
||||
PERMISSIONS_TYPE_FILE,
|
||||
);
|
||||
log('Linting generated file...');
|
||||
execSync(`cd frontend && yarn eslint --fix ${relativePath}`, {
|
||||
execSync(`cd frontend && pnpm eslint --fix ${relativePath}`, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ echo "\n✅ Prettier formatting successful"
|
||||
|
||||
# Fix linting issues
|
||||
echo "\n\n---\nRunning eslint...\n"
|
||||
if ! yarn lint --fix --quiet src/api/generated; then
|
||||
if ! pnpm lint --fix --quiet src/api/generated; then
|
||||
echo "ESLint check failed! Please fix linting errors before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import getAll from 'api/v1/user/get';
|
||||
@@ -236,13 +236,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
useEffect(() => {
|
||||
// if it is an old route navigate to the new route
|
||||
if (isOldRoute) {
|
||||
const redirectUrl = oldNewRoutesMapping[pathname];
|
||||
|
||||
const newLocation = {
|
||||
...location,
|
||||
pathname: redirectUrl,
|
||||
};
|
||||
history.replace(newLocation);
|
||||
// this will be handled by the redirect component below
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -296,6 +290,19 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
}
|
||||
}, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]);
|
||||
|
||||
if (isOldRoute) {
|
||||
const redirectUrl = oldNewRoutesMapping[pathname];
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: redirectUrl,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: disabling this rule as there is no need to have div
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
.custom-time-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.zoom-out-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
padding: 10px;
|
||||
height: 33px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.timeSelection-input {
|
||||
&:hover {
|
||||
|
||||
@@ -16,6 +16,15 @@ jest.mock('react-router-dom', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: jest.fn(() => jest.fn()),
|
||||
useSelector: jest.fn(() => ({
|
||||
minTime: 0,
|
||||
maxTime: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => {
|
||||
const actual = jest.requireActual('providers/Timezone');
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import {
|
||||
FixedDurationSuggestionOptions,
|
||||
@@ -17,9 +19,11 @@ import {
|
||||
RelativeDurationSuggestionOptions,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useZoomOut } from 'hooks/useZoomOut';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import { isZoomOutDisabled } from 'lib/zoomOutUtils';
|
||||
import { defaultTo, isFunction, noop } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -66,6 +70,8 @@ interface CustomTimePickerProps {
|
||||
showRecentlyUsed?: boolean;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -88,6 +94,7 @@ function CustomTimePicker({
|
||||
showRecentlyUsed = true,
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
@@ -116,6 +123,14 @@ function CustomTimePicker({
|
||||
|
||||
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
|
||||
|
||||
const durationMs = (maxTime - minTime) / 1e6;
|
||||
const zoomOutDisabled = showLiveLogs || isZoomOutDisabled(durationMs);
|
||||
|
||||
const handleZoomOut = useZoomOut({
|
||||
isDisabled: zoomOutDisabled,
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
});
|
||||
|
||||
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
|
||||
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
|
||||
const getSelectedTimeRangeLabelInRelativeFormat = (
|
||||
@@ -631,6 +646,23 @@ function CustomTimePicker({
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
{!showLiveLogs && !isModalTimeSelection && (
|
||||
<Tooltip
|
||||
title={
|
||||
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
className="zoom-out-btn"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomOutDisabled}
|
||||
data-testid="zoom-out-btn"
|
||||
prefixIcon={<ZoomOut size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import CustomTimePicker from '../CustomTimePicker';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const NOW_MS = 1705312800000;
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: (selector: (state: MockAppState) => unknown): unknown => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: (NOW_MS - 15 * MS_PER_MIN) * 1e6,
|
||||
maxTime: NOW_MS * 1e6,
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: () => string;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: (): string => 'relativeTime=45m',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { timezone: { value: string; offset: string } } => ({
|
||||
timezone: { value: 'UTC', offset: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const defaultProps = {
|
||||
onSelect: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
selectedValue: '15m',
|
||||
selectedTime: '15m',
|
||||
onValidCustomDateChange: jest.fn(),
|
||||
open: false,
|
||||
setOpen: jest.fn(),
|
||||
items: [
|
||||
{ value: '15m', label: 'Last 15 minutes' },
|
||||
{ value: '1h', label: 'Last 1 hour' },
|
||||
],
|
||||
minTime: (now - 15 * 60 * 1000) * 1e6,
|
||||
maxTime: now * 1e6,
|
||||
};
|
||||
|
||||
describe('CustomTimePicker - zoom out button', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render zoom out button when showLiveLogs is false', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
expect(screen.getByTestId('zoom-out-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when showLiveLogs is true', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={true} />);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when isModalTimeSelection is true', () => {
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
showLiveLogs={false}
|
||||
isModalTimeSelection={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleZoomOut when zoom out button is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use real ladder logic: 15m range zooms to 45m preset and updates URL', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete activeLogId when zoom out is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
|
||||
it('should disable zoom button when time range is >= 1 month', () => {
|
||||
const now = Date.now();
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
minTime={(now - 31 * MS_PER_DAY) * 1e6}
|
||||
maxTime={now * 1e6}
|
||||
showLiveLogs={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
expect(zoomOutBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
.timeline-v3-container {
|
||||
// flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import {
|
||||
getIntervals,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
Interval,
|
||||
} from './utils';
|
||||
|
||||
import './TimelineV3.styles.scss';
|
||||
|
||||
interface ITimelineV3Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
}
|
||||
|
||||
function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
const spread = endTimestamp - startTimestamp;
|
||||
if (spread < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
|
||||
|
||||
setIntervals(intervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
'endTimestamp cannot be less than startTimestamp',
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
);
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
|
||||
|
||||
return (
|
||||
<div ref={ref as never} className="timeline-v3-container">
|
||||
<svg
|
||||
width={width}
|
||||
height={timelineHeight * 2.5}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
overflow="visible"
|
||||
>
|
||||
{intervals &&
|
||||
intervals.length > 0 &&
|
||||
intervals.map((interval, index) => (
|
||||
<g
|
||||
transform={`translate(${(interval.percentage * width) / 100},0)`}
|
||||
key={`${interval.percentage + interval.label + index}`}
|
||||
textAnchor="middle"
|
||||
fontSize="0.6rem"
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineV3;
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
IIntervalUnit,
|
||||
Interval,
|
||||
INTERVAL_UNITS,
|
||||
resolveTimeFromInterval,
|
||||
} from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
export type { Interval };
|
||||
|
||||
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
|
||||
export function getMinimumIntervalsBasedOnWidth(width: number): number {
|
||||
if (width < 640) {
|
||||
return 3;
|
||||
}
|
||||
if (width < 768) {
|
||||
return 4;
|
||||
}
|
||||
if (width < 1024) {
|
||||
return 5;
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes timeline intervals with offset-aware labels.
|
||||
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
|
||||
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
|
||||
*/
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
|
||||
const intervalSpreadNormalized =
|
||||
intervalSpread < 1.0
|
||||
? intervalSpread
|
||||
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
|
||||
10 ** (integerPartLength - 1);
|
||||
|
||||
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
|
||||
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
|
||||
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
const standardInterval = INTERVAL_UNITS[idx];
|
||||
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
|
||||
intervalUnit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervals: Interval[] = [
|
||||
{
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let tempBaseSpread = baseSpread;
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (tempBaseSpread && intervals.length < 20) {
|
||||
let intervalTime: number;
|
||||
|
||||
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
|
||||
intervalTime = elapsedIntervals + tempBaseSpread;
|
||||
tempBaseSpread = 0;
|
||||
} else {
|
||||
intervalTime = elapsedIntervals + intervalSpreadNormalized;
|
||||
tempBaseSpread -= intervalSpreadNormalized;
|
||||
}
|
||||
|
||||
elapsedIntervals = intervalTime;
|
||||
const labelTime = offsetTimestamp + intervalTime;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
@@ -33,125 +33,6 @@ const themeColors = {
|
||||
purple: '#800080',
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
traceDetailColorsV3: {
|
||||
// Blues
|
||||
dodgerBlue: '#2F80ED',
|
||||
royalBlue: '#3366E6',
|
||||
steelBlue: '#4682B4',
|
||||
|
||||
// Teals / Cyans
|
||||
turquoise: '#00CEC9',
|
||||
lagoon: '#1ABC9C',
|
||||
cyanBright: '#22A6F2',
|
||||
|
||||
// Greens
|
||||
emeraldGreen: '#27AE60',
|
||||
mediumSeaGreen: '#3CB371',
|
||||
limeGreen: '#A3E635',
|
||||
|
||||
// Yellows / Golds
|
||||
festivalYellow: '#F2C94C',
|
||||
sunflower: '#FFD93D',
|
||||
warmAmber: '#FFCA28',
|
||||
|
||||
// Purples / Violets
|
||||
mediumPurple: '#BB6BD9',
|
||||
royalPurple: '#9B51E0',
|
||||
orchid: '#DA77F2',
|
||||
|
||||
// Accent
|
||||
neonViolet: '#C77DFF',
|
||||
electricPurple: '#6C5CE7',
|
||||
arcticBlue: '#48DBFB',
|
||||
|
||||
// Blues extended
|
||||
blue1: '#1F63E0',
|
||||
blue2: '#3A7AED',
|
||||
blue3: '#5A9DF5',
|
||||
blue4: '#2874A6',
|
||||
blue5: '#2E86C1',
|
||||
blue6: '#3498DB',
|
||||
|
||||
// Cyans
|
||||
cyan1: '#00B0AA',
|
||||
cyan2: '#33D6C2',
|
||||
cyan3: '#66E9DA',
|
||||
|
||||
// Greens extended
|
||||
green1: '#1E8449',
|
||||
green2: '#2ECC71',
|
||||
green3: '#58D68D',
|
||||
green4: '#229954',
|
||||
green5: '#27AE60',
|
||||
green6: '#52BE80',
|
||||
|
||||
// Forest
|
||||
forest1: '#27AE60',
|
||||
forest2: '#2ECC71',
|
||||
forest3: '#58D68D',
|
||||
|
||||
// Lime
|
||||
lime1: '#A3E635',
|
||||
lime2: '#B9F18D',
|
||||
lime3: '#D4FFB0',
|
||||
|
||||
// Teals
|
||||
teal1: '#009688',
|
||||
teal2: '#1ABC9C',
|
||||
teal3: '#48C9B0',
|
||||
teal4: '#1ABC9C',
|
||||
teal5: '#48C9B0',
|
||||
teal6: '#76D7C4',
|
||||
|
||||
// Yellows
|
||||
yellow1: '#F1C40F',
|
||||
yellow2: '#F7DC6F',
|
||||
yellow3: '#F9E79F',
|
||||
|
||||
// Gold
|
||||
gold1: '#F39C12',
|
||||
gold2: '#F1C40F',
|
||||
gold3: '#F7DC6F',
|
||||
gold4: '#B7950B',
|
||||
gold5: '#F1C40F',
|
||||
gold6: '#F4D03F',
|
||||
|
||||
// Mustard
|
||||
mustard1: '#F1C40F',
|
||||
mustard2: '#F7DC6F',
|
||||
mustard3: '#F9E79F',
|
||||
|
||||
// Aqua
|
||||
aqua1: '#00BFFF',
|
||||
aqua2: '#1E90FF',
|
||||
aqua3: '#63B8FF',
|
||||
|
||||
// Purple extended
|
||||
purple1: '#8E44AD',
|
||||
purple2: '#9B59B6',
|
||||
purple3: '#BB8FCE',
|
||||
|
||||
violet1: '#8E44AD',
|
||||
violet2: '#9B59B6',
|
||||
violet3: '#BB8FCE',
|
||||
violet4: '#7D3C98',
|
||||
violet5: '#8E44AD',
|
||||
violet6: '#9B59B6',
|
||||
|
||||
// Lavender
|
||||
lavender1: '#9B59B6',
|
||||
lavender2: '#AF7AC5',
|
||||
lavender3: '#C39BD3',
|
||||
|
||||
// Oranges (safe ones, not red-ish)
|
||||
orange4: '#D35400',
|
||||
orange5: '#E67E22',
|
||||
orange6: '#EB984E',
|
||||
|
||||
coral1: '#E67E22',
|
||||
coral2: '#F39C12',
|
||||
coral3: '#F5B041',
|
||||
},
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
|
||||
@@ -4,8 +4,8 @@ import { getColorsForSeverityLabels, isRedLike } from '../utils';
|
||||
|
||||
describe('getColorsForSeverityLabels', () => {
|
||||
it('should return slate for blank labels', () => {
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_VANILLA_400);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_VANILLA_400);
|
||||
});
|
||||
|
||||
it('should return correct colors for known severity variants', () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ export function getColorsForSeverityLabels(
|
||||
const trimmed = label.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return Color.BG_SLATE_300;
|
||||
return Color.BG_VANILLA_400; // Default color for empty labels
|
||||
}
|
||||
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
|
||||
@@ -119,6 +119,6 @@ export function getColorsForSeverityLabels(
|
||||
|
||||
return (
|
||||
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
|
||||
Color.BG_SLATE_400
|
||||
Color.BG_VANILLA_400
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -234,20 +235,7 @@ function DateTimeSelection({
|
||||
|
||||
const updateLocalStorageForRoutes = useCallback(
|
||||
(value: Time | string): void => {
|
||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||
if (preRoutes !== null) {
|
||||
const preRoutesObject = JSON.parse(preRoutes);
|
||||
|
||||
const preRoute = {
|
||||
...preRoutesObject,
|
||||
};
|
||||
preRoute[location.pathname] = value;
|
||||
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify(preRoute),
|
||||
);
|
||||
}
|
||||
persistTimeDurationForRoute(location.pathname, String(value));
|
||||
},
|
||||
[location.pathname],
|
||||
);
|
||||
@@ -738,6 +726,7 @@ function DateTimeSelection({
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
minTime={minTimeForDateTimePicker}
|
||||
maxTime={maxTimeForDateTimePicker}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
/>
|
||||
|
||||
{showAutoRefresh && selectedTime !== 'custom' && (
|
||||
|
||||
160
frontend/src/hooks/__tests__/useZoomOut.test.ts
Normal file
160
frontend/src/hooks/__tests__/useZoomOut.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { useZoomOut } from '../useZoomOut';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockUrlQueryToString = jest.fn(() => '');
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: <T>(selector: (state: MockAppState) => T): T => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: 15 * 60 * 1000 * 1e6, // 15 min in nanoseconds
|
||||
maxTime: 30 * 60 * 1000 * 1e6, // 30 min in nanoseconds (mock for getNextZoomOutRange)
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: typeof mockUrlQueryToString;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: mockUrlQueryToString,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockGetNextZoomOutRange = jest.fn();
|
||||
jest.mock('lib/zoomOutUtils', () => ({
|
||||
getNextZoomOutRange: (
|
||||
...args: unknown[]
|
||||
): ReturnType<typeof mockGetNextZoomOutRange> =>
|
||||
mockGetNextZoomOutRange(...args),
|
||||
}));
|
||||
|
||||
describe('useZoomOut', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlQueryToString.mockReturnValue('relativeTime=45m');
|
||||
});
|
||||
|
||||
it('should do nothing when isDisabled is true', () => {
|
||||
const { result } = renderHook(() => useZoomOut({ isDisabled: true }));
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockGetNextZoomOutRange).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when getNextZoomOutRange returns null', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockGetNextZoomOutRange).toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dispatch preset and update URL when result has preset', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000, 2000],
|
||||
preset: '45m',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/logs-explorer'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch custom range and update URL when result has no preset', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000000, 2000000],
|
||||
preset: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(
|
||||
QueryParams.startTime,
|
||||
'1000000',
|
||||
);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.endTime, '2000000');
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.relativeTime);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/logs-explorer'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete urlParamsToDelete when provided', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000, 2000],
|
||||
preset: '45m',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useZoomOut({
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
});
|
||||
79
frontend/src/hooks/useZoomOut.ts
Normal file
79
frontend/src/hooks/useZoomOut.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
|
||||
export interface UseZoomOutOptions {
|
||||
/** When true, the zoom out handler does nothing (e.g. when live logs are enabled) */
|
||||
isDisabled?: boolean;
|
||||
/** URL params to delete when zooming out (e.g. [QueryParams.activeLogId] for logs) */
|
||||
urlParamsToDelete?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable hook for zoom-out functionality in explorers (logs, traces, etc.).
|
||||
* Computes the next time range using the zoom-out ladder, updates Redux global time,
|
||||
* and navigates with the new URL params.
|
||||
*/
|
||||
const EMPTY_PARAMS: string[] = [];
|
||||
|
||||
export function useZoomOut(options: UseZoomOutOptions = {}): () => void {
|
||||
const { isDisabled = false, urlParamsToDelete = EMPTY_PARAMS } = options;
|
||||
const urlParamsToDeleteRef = useRef(urlParamsToDelete);
|
||||
urlParamsToDeleteRef.current = urlParamsToDelete;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return useCallback((): void => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
const minMs = Math.floor((minTime ?? 0) / 1e6);
|
||||
const maxMs = Math.floor((maxTime ?? 0) / 1e6);
|
||||
const result = getNextZoomOutRange(minMs, maxMs);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const [newStartMs, newEndMs] = result.range;
|
||||
const { preset } = result;
|
||||
|
||||
if (preset) {
|
||||
dispatch(UpdateTimeInterval(preset));
|
||||
urlQuery.delete(QueryParams.startTime);
|
||||
urlQuery.delete(QueryParams.endTime);
|
||||
urlQuery.set(QueryParams.relativeTime, preset);
|
||||
persistTimeDurationForRoute(location.pathname, preset);
|
||||
} else {
|
||||
dispatch(UpdateTimeInterval('custom', [newStartMs, newEndMs]));
|
||||
urlQuery.set(QueryParams.startTime, String(newStartMs));
|
||||
urlQuery.set(QueryParams.endTime, String(newEndMs));
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
}
|
||||
for (const param of urlParamsToDeleteRef.current) {
|
||||
urlQuery.delete(param);
|
||||
}
|
||||
safeNavigate(`${location.pathname}?${urlQuery.toString()}`);
|
||||
}, [
|
||||
dispatch,
|
||||
isDisabled,
|
||||
location.pathname,
|
||||
maxTime,
|
||||
minTime,
|
||||
safeNavigate,
|
||||
urlQuery,
|
||||
]);
|
||||
}
|
||||
147
frontend/src/lib/__tests__/zoomOutUtils.test.ts
Normal file
147
frontend/src/lib/__tests__/zoomOutUtils.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
getNextDurationInLadder,
|
||||
getNextZoomOutRange,
|
||||
isZoomOutDisabled,
|
||||
ZoomOutResult,
|
||||
} from '../zoomOutUtils';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const MS_PER_HOUR = 60 * MS_PER_MIN;
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
||||
|
||||
// Fixed "now" for deterministic tests: 2024-01-15 12:00:00 UTC
|
||||
const NOW_MS = 1705312800000;
|
||||
|
||||
describe('zoomOutUtils', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getNextDurationInLadder', () => {
|
||||
it('should use 3x zoom out below 15m until reaching 15m', () => {
|
||||
expect(getNextDurationInLadder(1 * MS_PER_MIN)).toBe(3 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_MIN)).toBe(6 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(3 * MS_PER_MIN)).toBe(9 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(4 * MS_PER_MIN)).toBe(12 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(5 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // cap at 15m
|
||||
expect(getNextDurationInLadder(6 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // 18m capped
|
||||
});
|
||||
|
||||
it('should return next step for each ladder rung from 15m onward', () => {
|
||||
expect(getNextDurationInLadder(10 * MS_PER_MIN)).toBe(15 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(15 * MS_PER_MIN)).toBe(45 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(45 * MS_PER_MIN)).toBe(2 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(7 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(21 * MS_PER_HOUR)).toBe(1 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(1 * MS_PER_DAY)).toBe(2 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_DAY)).toBe(3 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(3 * MS_PER_DAY)).toBe(1 * MS_PER_WEEK);
|
||||
expect(getNextDurationInLadder(1 * MS_PER_WEEK)).toBe(2 * MS_PER_WEEK);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_WEEK)).toBe(30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return MAX when at or past 1 month (no wrap)', () => {
|
||||
expect(getNextDurationInLadder(30 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(31 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return next step for duration between ladder rungs', () => {
|
||||
expect(getNextDurationInLadder(1 * MS_PER_HOUR)).toBe(2 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(5 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(12 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextZoomOutRange', () => {
|
||||
it('should return null when duration is zero or negative', () => {
|
||||
expect(getNextZoomOutRange(NOW_MS, NOW_MS)).toBeNull();
|
||||
expect(getNextZoomOutRange(NOW_MS, NOW_MS - 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return center-anchored range and preset=null when new end does not exceed now (Phase 1)', () => {
|
||||
// 15m range centered well before now so zoom to 45m keeps end <= now
|
||||
// Center at now-30m: end = center + 22.5m = now - 7.5m <= now
|
||||
const centerMs = NOW_MS - 30 * MS_PER_MIN;
|
||||
const start15m = centerMs - 7.5 * MS_PER_MIN;
|
||||
const end15m = centerMs + 7.5 * MS_PER_MIN;
|
||||
const result = getNextZoomOutRange(start15m, end15m) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBeNull(); // Phase 1: preserve center-anchored range, avoid GetMinMax "last X from now"
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd - newStart).toBe(45 * MS_PER_MIN);
|
||||
const newCenter = (newStart + newEnd) / 2;
|
||||
expect(Math.abs(newCenter - centerMs)).toBeLessThan(2000);
|
||||
expect(newEnd).toBeLessThanOrEqual(NOW_MS + 1000);
|
||||
});
|
||||
|
||||
it('should return end-anchored range when new end would exceed now (Phase 2)', () => {
|
||||
// 22hr range ending at now - zoom to 1d (24hr) would push end past now
|
||||
// Next ladder step from 22hr is 1d
|
||||
const start22h = NOW_MS - 22 * MS_PER_HOUR;
|
||||
const end22h = NOW_MS;
|
||||
const result = getNextZoomOutRange(start22h, end22h) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBe('1d');
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd).toBe(NOW_MS); // End anchored at now
|
||||
expect(newStart).toBe(NOW_MS - 1 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return correct preset for each ladder step', () => {
|
||||
const presets: [number, number, string][] = [
|
||||
[15 * MS_PER_MIN, 0, '45m'],
|
||||
[45 * MS_PER_MIN, 0, '2h'],
|
||||
[2 * MS_PER_HOUR, 0, '7h'],
|
||||
[7 * MS_PER_HOUR, 0, '21h'],
|
||||
[21 * MS_PER_HOUR, 0, '1d'],
|
||||
[1 * MS_PER_DAY, 0, '2d'],
|
||||
[2 * MS_PER_DAY, 0, '3d'],
|
||||
[3 * MS_PER_DAY, 0, '1w'],
|
||||
[1 * MS_PER_WEEK, 0, '2w'],
|
||||
[2 * MS_PER_WEEK, 0, '1month'],
|
||||
];
|
||||
|
||||
presets.forEach(([durationMs, offset, expectedPreset]) => {
|
||||
const end = NOW_MS - offset;
|
||||
const start = end - durationMs;
|
||||
const result = getNextZoomOutRange(start, end);
|
||||
expect(result?.preset).toBe(expectedPreset);
|
||||
});
|
||||
});
|
||||
|
||||
it('isZoomOutDisabled returns true when duration >= 1 month', () => {
|
||||
expect(isZoomOutDisabled(30 * MS_PER_DAY)).toBe(true);
|
||||
expect(isZoomOutDisabled(31 * MS_PER_DAY)).toBe(true);
|
||||
expect(isZoomOutDisabled(29 * MS_PER_DAY)).toBe(false);
|
||||
expect(isZoomOutDisabled(15 * MS_PER_MIN)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return null when at 1 month (no zoom out beyond max)', () => {
|
||||
const start1m = NOW_MS - 30 * MS_PER_DAY;
|
||||
const end1m = NOW_MS;
|
||||
const result = getNextZoomOutRange(start1m, end1m);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should zoom out 3x from 5m range to 15m then continue with ladder', () => {
|
||||
// 5m range ending at now → 3x = 15m
|
||||
const start5m = NOW_MS - 5 * MS_PER_MIN;
|
||||
const end5m = NOW_MS;
|
||||
const result = getNextZoomOutRange(start5m, end5m) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBe('15m');
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd - newStart).toBe(15 * MS_PER_MIN);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
frontend/src/lib/zoomOutUtils.ts
Normal file
139
frontend/src/lib/zoomOutUtils.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Custom Time Picker zoom-out ladder:
|
||||
* - Until 1 day: 15m → 45m → 2hr → 7hr → 21hr
|
||||
* - Then fixed: 1d → 2d → 3d → 1w → 2w → 1m
|
||||
* - At 1 month: zoom out is disabled (max range)
|
||||
*/
|
||||
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const MS_PER_HOUR = 60 * MS_PER_MIN;
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
||||
|
||||
const ZOOM_OUT_LADDER_MS: number[] = [
|
||||
15 * MS_PER_MIN, // 15m
|
||||
45 * MS_PER_MIN, // 45m
|
||||
2 * MS_PER_HOUR, // 2hr
|
||||
7 * MS_PER_HOUR, // 7hr
|
||||
21 * MS_PER_HOUR, // 21hr
|
||||
1 * MS_PER_DAY, // 1d
|
||||
2 * MS_PER_DAY, // 2d
|
||||
3 * MS_PER_DAY, // 3d
|
||||
1 * MS_PER_WEEK, // 1w
|
||||
2 * MS_PER_WEEK, // 2w
|
||||
30 * MS_PER_DAY, // 1m
|
||||
];
|
||||
|
||||
const LADDER_LAST_INDEX = ZOOM_OUT_LADDER_MS.length - 1;
|
||||
const MAX_DURATION = ZOOM_OUT_LADDER_MS[LADDER_LAST_INDEX];
|
||||
const MIN_LADDER_DURATION_MS = ZOOM_OUT_LADDER_MS[0]; // 15m - below this we use 3x
|
||||
|
||||
export const MAX_ZOOM_OUT_DURATION_MS = MAX_DURATION;
|
||||
|
||||
/** Returns true when zoom out should be disabled (range at or beyond 1 month) */
|
||||
export function isZoomOutDisabled(durationMs: number): boolean {
|
||||
return durationMs >= MAX_ZOOM_OUT_DURATION_MS;
|
||||
}
|
||||
|
||||
/** Preset labels for ladder steps supported by GetMinMax (shows "Last 15 minutes" etc. instead of "Custom") */
|
||||
const PRESET_FOR_DURATION_MS: Record<number, Time | CustomTimeType> = {
|
||||
[15 * MS_PER_MIN]: '15m',
|
||||
[45 * MS_PER_MIN]: '45m',
|
||||
[2 * MS_PER_HOUR]: '2h',
|
||||
[7 * MS_PER_HOUR]: '7h',
|
||||
[21 * MS_PER_HOUR]: '21h',
|
||||
[1 * MS_PER_DAY]: '1d',
|
||||
[2 * MS_PER_DAY]: '2d',
|
||||
[3 * MS_PER_DAY]: '3d',
|
||||
[1 * MS_PER_WEEK]: '1w',
|
||||
[2 * MS_PER_WEEK]: '2w',
|
||||
[30 * MS_PER_DAY]: '1month',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the next duration in the zoom-out ladder for the given current duration.
|
||||
* Below 15m: zoom out 3x until we reach 15m, then continue with the ladder.
|
||||
* If at or past 1 month, returns MAX_DURATION (no zoom out - button is disabled).
|
||||
*/
|
||||
export function getNextDurationInLadder(durationMs: number): number {
|
||||
if (durationMs >= MAX_DURATION) {
|
||||
return MAX_DURATION; // No zoom out beyond 1 month
|
||||
}
|
||||
|
||||
// Below 15m: zoom out 3x until we reach 15m
|
||||
if (durationMs < MIN_LADDER_DURATION_MS) {
|
||||
const next = durationMs * 3;
|
||||
return Math.min(next, MIN_LADDER_DURATION_MS);
|
||||
}
|
||||
|
||||
// At or above 15m: use the fixed ladder
|
||||
for (let i = 0; i < ZOOM_OUT_LADDER_MS.length; i++) {
|
||||
if (ZOOM_OUT_LADDER_MS[i] > durationMs) {
|
||||
return ZOOM_OUT_LADDER_MS[i];
|
||||
}
|
||||
}
|
||||
|
||||
return MAX_DURATION;
|
||||
}
|
||||
|
||||
export interface ZoomOutResult {
|
||||
range: [number, number];
|
||||
/** Preset key (e.g. '15m') when range matches a preset - use for display instead of "Custom Date Range" */
|
||||
preset: Time | CustomTimeType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the next zoomed-out time range.
|
||||
* Phase 1 (center-anchored): While new end <= now, expand from center.
|
||||
* Phase 2 (end-anchored at now): When new end would exceed now, anchor end at now and move start backward.
|
||||
*
|
||||
* @returns ZoomOutResult with range and preset (or null if no change)
|
||||
*/
|
||||
export function getNextZoomOutRange(
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): ZoomOutResult | null {
|
||||
const nowMs = Date.now();
|
||||
const durationMs = endMs - startMs;
|
||||
|
||||
if (durationMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newDurationMs = getNextDurationInLadder(durationMs);
|
||||
|
||||
// No zoom out when already at max (1 month)
|
||||
if (newDurationMs <= durationMs) {
|
||||
return null;
|
||||
}
|
||||
const centerMs = startMs + durationMs / 2;
|
||||
const computedEndMs = centerMs + newDurationMs / 2;
|
||||
|
||||
let newStartMs: number;
|
||||
let newEndMs: number;
|
||||
|
||||
const isPhase1 = computedEndMs <= nowMs;
|
||||
if (isPhase1) {
|
||||
// Phase 1: center-anchored (historical range not ending at now)
|
||||
newStartMs = centerMs - newDurationMs / 2;
|
||||
newEndMs = computedEndMs;
|
||||
} else {
|
||||
// Phase 2: end-anchored at now
|
||||
newStartMs = nowMs - newDurationMs;
|
||||
newEndMs = nowMs;
|
||||
}
|
||||
|
||||
// Phase 2 only: use preset so GetMinMax produces "last X from now".
|
||||
// Phase 1: preset=null so the center-anchored range is preserved (GetMinMax would discard it).
|
||||
const preset = isPhase1 ? null : PRESET_FOR_DURATION_MS[newDurationMs] ?? null;
|
||||
|
||||
return {
|
||||
range: [Math.round(newStartMs), Math.round(newEndMs)],
|
||||
preset,
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,19 @@ import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, Cone, TowerControl } from 'lucide-react';
|
||||
|
||||
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||
import TraceDetailsV2 from './TraceDetailV2';
|
||||
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
function NewTraceDetail(props: any): JSX.Element {
|
||||
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">
|
||||
@@ -42,7 +50,7 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
key: 'trace-details',
|
||||
children: <TraceDetailsV3 />,
|
||||
children: <TraceDetailsV2 />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
.trace-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 16px;
|
||||
|
||||
.previous-btn {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
padding: 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
background: var(--bg-slate-500);
|
||||
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(--bg-slate-300);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
.drafting {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: #fff;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-slate-400);
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.trace-details-header {
|
||||
.previous-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-right: none;
|
||||
|
||||
.drafting {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: move to new css module name system
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button, Typography } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './TraceDetailsHeader.styles.scss';
|
||||
|
||||
function TraceDetailsHeader(): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
const isSpaNavigate =
|
||||
document.referrer &&
|
||||
new URL(document.referrer).origin === window.location.origin;
|
||||
if (isSpaNavigate) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="trace-details-header">
|
||||
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<div className="trace-name">
|
||||
<Typography.Text className="trace-id">Trace ID</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsHeader;
|
||||
@@ -1,237 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { DEFAULT_ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
|
||||
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { FlamegraphCanvasProps, SpanRect } from './types';
|
||||
import { formatDuration } from './utils';
|
||||
|
||||
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
const { spans, traceMetadata, firstSpanAtFetchLevel, onSpanClick } = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode(); //TODO: see if can be removed or use a new hook
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const spanRectsRef = useRef<SpanRect[]>([]);
|
||||
|
||||
const [viewStartTs, setViewStartTs] = useState<number>(
|
||||
traceMetadata.startTime,
|
||||
);
|
||||
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
|
||||
const [scrollTop, setScrollTop] = useState<number>(0);
|
||||
const [rowHeight, setRowHeight] = useState<number>(DEFAULT_ROW_HEIGHT);
|
||||
|
||||
// Mutable refs for zoom and drag hooks to read during rAF / mouse callbacks
|
||||
const viewStartRef = useRef(viewStartTs);
|
||||
const viewEndRef = useRef(viewEndTs);
|
||||
const rowHeightRef = useRef(rowHeight);
|
||||
const scrollTopRef = useRef(scrollTop);
|
||||
|
||||
useEffect(() => {
|
||||
viewStartRef.current = viewStartTs;
|
||||
}, [viewStartTs]);
|
||||
|
||||
useEffect(() => {
|
||||
viewEndRef.current = viewEndTs;
|
||||
}, [viewEndTs]);
|
||||
|
||||
useEffect(() => {
|
||||
rowHeightRef.current = rowHeight;
|
||||
}, [rowHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
//TODO: see if this can be removed as once loaded the view start and end ts will not change
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
viewStartRef.current = traceMetadata.startTime;
|
||||
viewEndRef.current = traceMetadata.endTime;
|
||||
}, [traceMetadata.startTime, traceMetadata.endTime]);
|
||||
|
||||
const totalHeight = spans.length * rowHeight;
|
||||
|
||||
const { isOverFlamegraphRef } = useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
handleMouseDown,
|
||||
handleMouseMove: handleDragMouseMove,
|
||||
handleMouseUp,
|
||||
handleDragMouseLeave,
|
||||
suppressClickRef,
|
||||
isDraggingRef,
|
||||
} = useFlamegraphDrag({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
totalHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
hoveredSpanId,
|
||||
handleHoverMouseMove,
|
||||
handleHoverMouseLeave,
|
||||
handleClick,
|
||||
tooltipContent,
|
||||
} = useFlamegraphHover({
|
||||
canvasRef,
|
||||
spanRectsRef,
|
||||
traceMetadata,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
isDraggingRef,
|
||||
suppressClickRef,
|
||||
onSpanClick,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
const { drawFlamegraph } = useFlamegraphDraw({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
rowHeight,
|
||||
selectedSpanId: firstSpanAtFetchLevel || undefined,
|
||||
hoveredSpanId: hoveredSpanId ?? '',
|
||||
isDarkMode,
|
||||
spanRectsRef,
|
||||
});
|
||||
|
||||
useScrollToSpan({
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
});
|
||||
|
||||
useCanvasSetup(canvasRef, containerRef, drawFlamegraph);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
handleDragMouseMove(e);
|
||||
handleHoverMouseMove(e);
|
||||
},
|
||||
[handleDragMouseMove, handleHoverMouseMove],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
isOverFlamegraphRef.current = false;
|
||||
handleDragMouseLeave();
|
||||
handleHoverMouseLeave();
|
||||
}, [isOverFlamegraphRef, handleDragMouseLeave, handleHoverMouseLeave]);
|
||||
|
||||
// todo: move to a separate component/utils file
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(30, 30, 30, 0.95)',
|
||||
color: '#fff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
marginBottom: 4,
|
||||
color: tooltipContent.spanColor,
|
||||
}}
|
||||
>
|
||||
{tooltipContent.spanName}
|
||||
</div>
|
||||
<div>Status: {tooltipContent.status}</div>
|
||||
<div>Start: {tooltipContent.startMs.toFixed(2)} ms</div>
|
||||
<div>Duration: {formatDuration(tooltipContent.durationMs * 1e6)}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
padding: '0 15px',
|
||||
}}
|
||||
>
|
||||
{tooltipElement}
|
||||
<TimelineV3
|
||||
startTimestamp={viewStartTs}
|
||||
endTimestamp={viewEndTs}
|
||||
offsetTimestamp={viewStartTs - traceMetadata.startTime}
|
||||
timelineHeight={10}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
isOverFlamegraphRef.current = true;
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphCanvas;
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
|
||||
//TODO: analyse if this is needed or not and move to separate file if needed else delete this enum.
|
||||
enum TraceFlamegraphState {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA = 'FETCHING_WITH_OLD_DATA',
|
||||
}
|
||||
|
||||
function TraceFlamegraph(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const { search } = useLocation();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(spanId: string): void => {
|
||||
setFirstSpanAtFetchLevel(spanId);
|
||||
const searchParams = new URLSearchParams(search);
|
||||
//tood: use from query params constants
|
||||
if (searchParams.get('spanId') !== spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}
|
||||
},
|
||||
[history, search],
|
||||
);
|
||||
|
||||
const { data, isFetching, error } = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
});
|
||||
|
||||
const flamegraphState = useMemo(() => {
|
||||
if (isFetching) {
|
||||
if (data?.payload?.spans && data.payload.spans.length > 0) {
|
||||
return TraceFlamegraphState.FETCHING_WITH_OLD_DATA;
|
||||
}
|
||||
return TraceFlamegraphState.LOADING;
|
||||
}
|
||||
if (error) {
|
||||
return TraceFlamegraphState.ERROR;
|
||||
}
|
||||
if (data?.payload?.spans && data.payload.spans.length === 0) {
|
||||
return TraceFlamegraphState.NO_DATA;
|
||||
}
|
||||
return TraceFlamegraphState.SUCCESS;
|
||||
}, [error, isFetching, data]);
|
||||
|
||||
const spans = useMemo(() => data?.payload?.spans || [], [
|
||||
data?.payload?.spans,
|
||||
]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (flamegraphState) {
|
||||
case TraceFlamegraphState.LOADING:
|
||||
return <div>Loading...</div>;
|
||||
case TraceFlamegraphState.ERROR:
|
||||
return <div>Error loading flamegraph</div>;
|
||||
case TraceFlamegraphState.NO_DATA:
|
||||
return <div>No data found for trace {traceId}</div>;
|
||||
case TraceFlamegraphState.SUCCESS:
|
||||
case TraceFlamegraphState.FETCHING_WITH_OLD_DATA:
|
||||
return (
|
||||
<FlamegraphCanvas
|
||||
spans={spans}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div>Fetching the trace...</div>;
|
||||
}
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
firstSpanAtFetchLevel,
|
||||
flamegraphState,
|
||||
spans,
|
||||
traceId,
|
||||
handleSpanClick,
|
||||
]);
|
||||
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
export default TraceFlamegraph;
|
||||
@@ -1,525 +0,0 @@
|
||||
import { DASHED_BORDER_LINE_DASH, MIN_WIDTH_FOR_NAME } from '../constants';
|
||||
import type { FlamegraphRowMetrics } from '../utils';
|
||||
import { getFlamegraphRowMetrics } from '../utils';
|
||||
import { drawEventDot, drawSpanBar } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (): { time: number; timeUnitName: string } => ({
|
||||
time: 50,
|
||||
timeUnitName: 'ms',
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Minimal 2D context for createStripePattern's internal canvas (jsdom getContext often returns null) */
|
||||
const mockPatternCanvasCtx = {
|
||||
beginPath: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
globalAlpha: 1,
|
||||
};
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
document.createElement = function (
|
||||
tagName: string,
|
||||
): ReturnType<typeof originalCreateElement> {
|
||||
const el = originalCreateElement(tagName);
|
||||
if (tagName.toLowerCase() === 'canvas') {
|
||||
(el as HTMLCanvasElement).getContext = (() =>
|
||||
mockPatternCanvasCtx as unknown) as HTMLCanvasElement['getContext'];
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
function createMockCtx(): jest.Mocked<CanvasRenderingContext2D> {
|
||||
return ({
|
||||
beginPath: jest.fn(),
|
||||
roundRect: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
translate: jest.fn(),
|
||||
rotate: jest.fn(),
|
||||
fillRect: jest.fn(),
|
||||
strokeRect: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
measureText: jest.fn(
|
||||
(text: string) => ({ width: text.length * 6 } as TextMetrics),
|
||||
),
|
||||
createPattern: jest.fn(() => ({} as CanvasPattern)),
|
||||
clip: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
lineWidth: 0,
|
||||
globalAlpha: 1,
|
||||
} as unknown) as jest.Mocked<CanvasRenderingContext2D>;
|
||||
}
|
||||
|
||||
const METRICS: FlamegraphRowMetrics = getFlamegraphRowMetrics(24);
|
||||
|
||||
describe('Canvas Draw Utils', () => {
|
||||
describe('drawSpanBar', () => {
|
||||
it('draws rect + fill for normal span (no selected/hovered)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, event: [] },
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#1890ff',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.beginPath).toHaveBeenCalled();
|
||||
expect(ctx.roundRect).toHaveBeenCalledWith(10, 1, 100, 22, 2);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).not.toHaveBeenCalled();
|
||||
expect(spanRectsArray).toHaveLength(1);
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 10,
|
||||
y: 1,
|
||||
width: 100,
|
||||
height: 22,
|
||||
level: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stripe pattern + dashed stroke + 2px when selected', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'sel', event: [] },
|
||||
x: 20,
|
||||
y: 0,
|
||||
width: 80,
|
||||
levelIndex: 1,
|
||||
spanRectsArray,
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'sel',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenCalledWith(DASHED_BORDER_LINE_DASH);
|
||||
expect(ctx.strokeStyle).toBe('#2F80ED');
|
||||
expect(ctx.lineWidth).toBe(2);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('uses stripe pattern + solid stroke + 1px when hovered (not selected)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'hov', event: [] },
|
||||
x: 30,
|
||||
y: 0,
|
||||
width: 60,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'hov',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).not.toHaveBeenCalled();
|
||||
expect(ctx.lineWidth).toBe(1);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pushes spanRectsArray with correct dimensions', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'rect-test', event: [] },
|
||||
x: 5,
|
||||
y: 24,
|
||||
width: 200,
|
||||
levelIndex: 2,
|
||||
spanRectsArray,
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 5,
|
||||
y: 25,
|
||||
width: 200,
|
||||
height: 22,
|
||||
level: 2,
|
||||
});
|
||||
expect(spanRectsArray[0].span.spanId).toBe('rect-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanLabel (via drawSpanBar)', () => {
|
||||
it('skips label when width < MIN_WIDTH_FOR_NAME', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).not.toHaveBeenCalled();
|
||||
expect(ctx.fillText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('draws name only when width >= MIN_WIDTH_FOR_NAME but < MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'foo', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).toHaveBeenCalled();
|
||||
expect(ctx.fillText).toHaveBeenCalled();
|
||||
expect(ctx.textAlign).toBe('left');
|
||||
});
|
||||
|
||||
it('draws name + duration when width >= MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'my-span', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'50ms',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'my-span',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText (via drawSpanBar)', () => {
|
||||
it('uses full text when it fits', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 4 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'short', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'short',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates text when it exceeds available width', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) =>
|
||||
({
|
||||
width: t.includes('...') ? 24 : t.length * 10,
|
||||
} as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'very-long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
const fillTextCalls = (ctx.fillText as jest.Mock).mock.calls;
|
||||
const nameArg = fillTextCalls.find((c) => c[0] !== '50ms')?.[0];
|
||||
expect(nameArg).toBeDefined();
|
||||
expect(nameArg).toMatch(/\.\.\.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawEventDot', () => {
|
||||
it('uses error styling when isError is true', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 50,
|
||||
y: 11,
|
||||
isError: true,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(50, 11);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.fillStyle).toBe('rgb(220, 38, 38)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(153, 27, 27)');
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses normal styling when isError is false', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: false,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(6, 182, 212)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)');
|
||||
});
|
||||
|
||||
it('uses dark mode colors for error', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: true,
|
||||
isDarkMode: true,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
|
||||
});
|
||||
|
||||
it('uses dark mode colors for non-error', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: false,
|
||||
isDarkMode: true,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(14, 165, 233)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(2, 132, 199)');
|
||||
});
|
||||
|
||||
it('calls save, translate, rotate, restore', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 10,
|
||||
y: 20,
|
||||
isError: false,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 4,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 20);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStripePattern (via drawSpanBar)', () => {
|
||||
it('uses pattern when createPattern returns non-null', () => {
|
||||
const ctx = createMockCtx();
|
||||
const mockPattern = {} as CanvasPattern;
|
||||
(ctx.createPattern as jest.Mock).mockReturnValue(mockPattern);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.fillStyle).toBe(mockPattern);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips fill when createPattern returns null', () => {
|
||||
const ctx = createMockCtx();
|
||||
(ctx.createPattern as jest.Mock).mockReturnValue(null);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.fill).not.toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanBar with events', () => {
|
||||
it('draws event dots for each span event', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanWithEvents = {
|
||||
...MOCK_SPAN,
|
||||
event: [
|
||||
{
|
||||
name: 'e1',
|
||||
timeUnixNano: 1_010_000_000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'e2',
|
||||
timeUnixNano: 1_025_000_000,
|
||||
attributeMap: {},
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: spanWithEvents,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.translate).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillRect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
/** Minimal FlamegraphSpan for unit tests */
|
||||
export const MOCK_SPAN: FlamegraphSpan = {
|
||||
timestamp: 1000,
|
||||
durationNano: 50_000_000, // 50ms
|
||||
spanId: 'span-1',
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'test-service',
|
||||
name: 'test-span',
|
||||
level: 0,
|
||||
event: [],
|
||||
};
|
||||
|
||||
/** Nested spans structure for findSpanById tests */
|
||||
export const MOCK_SPANS: FlamegraphSpan[][] = [
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'root',
|
||||
parentSpanId: '',
|
||||
level: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-a',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-b',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'grandchild',
|
||||
parentSpanId: 'child-a',
|
||||
level: 2,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export const MOCK_TRACE_METADATA = {
|
||||
startTime: 0,
|
||||
endTime: 1000,
|
||||
};
|
||||
@@ -1,196 +0,0 @@
|
||||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFlamegraphDrag } from '../hooks/useFlamegraphDrag';
|
||||
import { MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
} as DOMRect),
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function createMockContainer(): HTMLDivElement {
|
||||
const div = document.createElement('div');
|
||||
Object.defineProperty(div, 'clientHeight', { value: 400 });
|
||||
return div;
|
||||
}
|
||||
|
||||
const defaultArgs = {
|
||||
canvasRef: { current: createMockCanvas() },
|
||||
containerRef: { current: createMockContainer() },
|
||||
traceMetadata: MOCK_TRACE_METADATA,
|
||||
viewStartRef: { current: 0 },
|
||||
viewEndRef: { current: 1000 },
|
||||
setViewStartTs: jest.fn(),
|
||||
setViewEndTs: jest.fn(),
|
||||
scrollTopRef: { current: 0 },
|
||||
setScrollTop: jest.fn(),
|
||||
totalHeight: 1000,
|
||||
};
|
||||
|
||||
describe('useFlamegraphDrag', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
defaultArgs.viewStartRef.current = 0;
|
||||
defaultArgs.viewEndRef.current = 1000;
|
||||
defaultArgs.scrollTopRef.current = 0;
|
||||
});
|
||||
|
||||
it('starts drag state on mousedown', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-left button mousedown', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 1,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('updates pan/scroll on mousemove', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove(({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.setViewStartTs).toHaveBeenCalled();
|
||||
expect(defaultArgs.setViewEndTs).toHaveBeenCalled();
|
||||
expect(defaultArgs.setScrollTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not set suppressClickRef when movement is below threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove(({
|
||||
clientX: 102,
|
||||
clientY: 51,
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.suppressClickRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('sets suppressClickRef when drag exceeds threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove(({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.suppressClickRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('resets drag state on mouseup', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels drag on mouseleave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import type React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
|
||||
import type { SpanRect } from '../types';
|
||||
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
} as DOMRect),
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const spanRect: SpanRect = {
|
||||
span: { ...MOCK_SPAN, spanId: 'hover-span', name: 'test-span' },
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 22,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
const defaultArgs = {
|
||||
canvasRef: { current: createMockCanvas() },
|
||||
spanRectsRef: { current: [spanRect] },
|
||||
traceMetadata: MOCK_TRACE_METADATA,
|
||||
viewStartTs: MOCK_TRACE_METADATA.startTime,
|
||||
viewEndTs: MOCK_TRACE_METADATA.endTime,
|
||||
isDraggingRef: { current: false },
|
||||
suppressClickRef: { current: false },
|
||||
onSpanClick: jest.fn(),
|
||||
isDarkMode: false,
|
||||
};
|
||||
|
||||
describe('useFlamegraphHover', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
defaultArgs.spanRectsRef.current = [spanRect];
|
||||
defaultArgs.isDraggingRef.current = false;
|
||||
defaultArgs.suppressClickRef.current = false;
|
||||
});
|
||||
|
||||
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBe('hover-span');
|
||||
expect(result.current.tooltipContent).not.toBeNull();
|
||||
expect(result.current.tooltipContent?.spanName).toBe('test-span');
|
||||
expect(result.current.tooltipContent?.clientX).toBe(150);
|
||||
expect(result.current.tooltipContent?.clientY).toBe(61);
|
||||
});
|
||||
|
||||
it('clears hover when moving to empty area', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBe('hover-span');
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
expect(result.current.tooltipContent).toBeNull();
|
||||
});
|
||||
|
||||
it('clears hover on mouse leave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
expect(result.current.tooltipContent).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses click when suppressClickRef is set', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
defaultArgs.suppressClickRef.current = true;
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.onSpanClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSpanClick when clicking on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.onSpanClick).toHaveBeenCalledWith('hover-span');
|
||||
});
|
||||
|
||||
it('uses clientX/clientY for tooltip positioning', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 200,
|
||||
clientY: 60,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.tooltipContent?.clientX).toBe(200);
|
||||
expect(result.current.tooltipContent?.clientY).toBe(60);
|
||||
});
|
||||
|
||||
it('does not update hover during drag', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
defaultArgs.isDraggingRef.current = true;
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,279 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { DEFAULT_ROW_HEIGHT, MIN_VISIBLE_SPAN_MS } from '../constants';
|
||||
import { useFlamegraphZoom } from '../hooks/useFlamegraphZoom';
|
||||
import { MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
} as DOMRect),
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
describe('useFlamegraphZoom', () => {
|
||||
const traceMetadata = { ...MOCK_TRACE_METADATA };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('handleResetZoom restores traceMetadata.startTime/endTime', () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: 100 };
|
||||
const viewEndRef = { current: 500 };
|
||||
const rowHeightRef = { current: 30 };
|
||||
const canvasRef = { current: createMockCanvas() };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleResetZoom();
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalledWith(traceMetadata.startTime);
|
||||
expect(setViewEndTs).toHaveBeenCalledWith(traceMetadata.endTime);
|
||||
expect(setRowHeight).toHaveBeenCalledWith(DEFAULT_ROW_HEIGHT);
|
||||
expect(viewStartRef.current).toBe(traceMetadata.startTime);
|
||||
expect(viewEndRef.current).toBe(traceMetadata.endTime);
|
||||
expect(rowHeightRef.current).toBe(DEFAULT_ROW_HEIGHT);
|
||||
});
|
||||
|
||||
it('wheel zoom in decreases visible time range', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.endTime };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
const initialSpan = viewEndRef.current - viewStartRef.current;
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: -100,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeLessThan(initialSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it('wheel zoom out increases visible time range', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const halfSpan = (traceMetadata.endTime - traceMetadata.startTime) / 2;
|
||||
const viewStartRef = { current: traceMetadata.startTime + halfSpan * 0.25 };
|
||||
const viewEndRef = { current: traceMetadata.startTime + halfSpan * 0.75 };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
const initialSpan = viewEndRef.current - viewStartRef.current;
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: 100,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeGreaterThanOrEqual(initialSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it('clamps zoom to MIN_VISIBLE_SPAN_MS', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.startTime + 100 };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: 10000,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeGreaterThanOrEqual(MIN_VISIBLE_SPAN_MS);
|
||||
}
|
||||
});
|
||||
|
||||
it('clamps viewStart/viewEnd to trace bounds', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.endTime };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: -5000,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
expect(newStart).toBeGreaterThanOrEqual(traceMetadata.startTime);
|
||||
expect(newEnd).toBeLessThanOrEqual(traceMetadata.endTime);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns isOverFlamegraphRef', () => {
|
||||
const canvasRef = { current: createMockCanvas() };
|
||||
const { result } = renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef: { current: 0 },
|
||||
viewEndRef: { current: 1000 },
|
||||
rowHeightRef: { current: 24 },
|
||||
setViewStartTs: jest.fn(),
|
||||
setViewEndTs: jest.fn(),
|
||||
setRowHeight: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isOverFlamegraphRef).toBeDefined();
|
||||
expect(result.current.isOverFlamegraphRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useScrollToSpan } from '../hooks/useScrollToSpan';
|
||||
import { MOCK_SPANS, MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function TestWrapper({
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
}: {
|
||||
firstSpanAtFetchLevel: string;
|
||||
spans: typeof MOCK_SPANS;
|
||||
traceMetadata: typeof MOCK_TRACE_METADATA;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
setScrollTop: Dispatch<SetStateAction<number>>;
|
||||
}): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewStartRef = useRef(traceMetadata.startTime);
|
||||
const viewEndRef = useRef(traceMetadata.endTime);
|
||||
const scrollTopRef = useRef(0);
|
||||
|
||||
useScrollToSpan({
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight: 24,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
});
|
||||
|
||||
return <div ref={containerRef} data-testid="container" />;
|
||||
}
|
||||
|
||||
describe('useScrollToSpan', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when firstSpanAtFetchLevel is empty', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel=""
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when spans are empty', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="root"
|
||||
spans={[]}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when target span not found', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="nonexistent"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setters when target span found', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="grandchild"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId('container')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
expect(setScrollTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [viewStart] = setViewStartTs.mock.calls[0];
|
||||
const [viewEnd] = setViewEndTs.mock.calls[0];
|
||||
const [scrollTop] = setScrollTop.mock.calls[0];
|
||||
|
||||
expect(viewEnd - viewStart).toBeGreaterThan(0);
|
||||
expect(viewStart).toBeGreaterThanOrEqual(MOCK_TRACE_METADATA.startTime);
|
||||
expect(viewEnd).toBeLessThanOrEqual(MOCK_TRACE_METADATA.endTime);
|
||||
expect(scrollTop).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('centers span vertically (scrollTop centers span row)', async () => {
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="grandchild"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={jest.fn()}
|
||||
setViewEndTs={jest.fn()}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(setScrollTop).toHaveBeenCalled());
|
||||
|
||||
const [scrollTop] = setScrollTop.mock.calls[0];
|
||||
const levelIndex = 2;
|
||||
const rowHeight = 24;
|
||||
const viewportHeight = 400;
|
||||
const expectedCenter =
|
||||
levelIndex * rowHeight - viewportHeight / 2 + rowHeight / 2;
|
||||
expect(scrollTop).toBeCloseTo(Math.max(0, expectedCenter), -1);
|
||||
});
|
||||
|
||||
it('zooms horizontally to span with 2x duration padding', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="root"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [viewStart] = setViewStartTs.mock.calls[0];
|
||||
const [viewEnd] = setViewEndTs.mock.calls[0];
|
||||
const visibleWindow = viewEnd - viewStart;
|
||||
const rootSpan = MOCK_SPANS[0][0];
|
||||
const spanDurationMs = rootSpan.durationNano / 1e6;
|
||||
expect(visibleWindow).toBeGreaterThanOrEqual(Math.max(spanDurationMs * 2, 5));
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import {
|
||||
clamp,
|
||||
findSpanById,
|
||||
formatDuration,
|
||||
getFlamegraphRowMetrics,
|
||||
} from '../utils';
|
||||
import { MOCK_SPANS } from './testUtils';
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (
|
||||
valueMs: number,
|
||||
): { time: number; timeUnitName: string } => {
|
||||
if (valueMs === 0) {
|
||||
return { time: 0, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 1) {
|
||||
return { time: valueMs, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 1000) {
|
||||
return { time: valueMs, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 60_000) {
|
||||
return { time: valueMs / 1000, timeUnitName: 's' };
|
||||
}
|
||||
if (valueMs < 3_600_000) {
|
||||
return { time: valueMs / 60_000, timeUnitName: 'm' };
|
||||
}
|
||||
return { time: valueMs / 3_600_000, timeUnitName: 'hr' };
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Pure Math and Data Utils', () => {
|
||||
describe('clamp', () => {
|
||||
it('returns value when within range', () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
expect(clamp(-3, -5, 5)).toBe(-3);
|
||||
});
|
||||
|
||||
it('returns min when value is below min', () => {
|
||||
expect(clamp(-1, 0, 10)).toBe(0);
|
||||
expect(clamp(2, 5, 10)).toBe(5);
|
||||
});
|
||||
|
||||
it('returns max when value is above max', () => {
|
||||
expect(clamp(11, 0, 10)).toBe(10);
|
||||
expect(clamp(100, 0, 50)).toBe(50);
|
||||
});
|
||||
|
||||
it('handles min === max', () => {
|
||||
expect(clamp(5, 7, 7)).toBe(7);
|
||||
expect(clamp(7, 7, 7)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSpanById', () => {
|
||||
it('finds span in first level', () => {
|
||||
const result = findSpanById(MOCK_SPANS, 'root');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.span.spanId).toBe('root');
|
||||
expect(result?.levelIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('finds span in nested level', () => {
|
||||
const result = findSpanById(MOCK_SPANS, 'grandchild');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.span.spanId).toBe('grandchild');
|
||||
expect(result?.levelIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when span not found', () => {
|
||||
expect(findSpanById(MOCK_SPANS, 'nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty spans', () => {
|
||||
expect(findSpanById([], 'root')).toBeNull();
|
||||
expect(findSpanById([[], []], 'root')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlamegraphRowMetrics', () => {
|
||||
it('computes normal row height metrics (24px)', () => {
|
||||
const m = getFlamegraphRowMetrics(24);
|
||||
expect(m.ROW_HEIGHT).toBe(24);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(22);
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(1);
|
||||
expect(m.EVENT_DOT_SIZE).toBe(6);
|
||||
});
|
||||
|
||||
it('clamps span bar height to max for large row heights', () => {
|
||||
const m = getFlamegraphRowMetrics(100);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(22);
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(39);
|
||||
});
|
||||
|
||||
it('clamps span bar height to min for small row heights', () => {
|
||||
const m = getFlamegraphRowMetrics(6);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(8);
|
||||
// spanBarYOffset = floor((6-8)/2) = -1 when bar exceeds row height
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(-1);
|
||||
});
|
||||
|
||||
it('clamps event dot size within min/max', () => {
|
||||
const mSmall = getFlamegraphRowMetrics(6);
|
||||
expect(mSmall.EVENT_DOT_SIZE).toBe(4);
|
||||
|
||||
const mLarge = getFlamegraphRowMetrics(24);
|
||||
expect(mLarge.EVENT_DOT_SIZE).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('formats nanos as ms', () => {
|
||||
// 1e6 nanos = 1ms
|
||||
expect(formatDuration(1_000_000)).toBe('1ms');
|
||||
});
|
||||
|
||||
it('formats larger durations as s/m/hr', () => {
|
||||
// 2e9 nanos = 2000ms = 2s
|
||||
expect(formatDuration(2_000_000_000)).toBe('2s');
|
||||
});
|
||||
|
||||
it('formats zero duration', () => {
|
||||
expect(formatDuration(0)).toBe('0ms');
|
||||
});
|
||||
|
||||
it('formats very small values', () => {
|
||||
// 1000 nanos = 0.001ms → mock returns { time: 0.001, timeUnitName: 'ms' }
|
||||
expect(formatDuration(1000)).toBe('0ms');
|
||||
});
|
||||
|
||||
it('formats decimal seconds correctly', () => {
|
||||
expect(formatDuration(1_500_000_000)).toBe('1.5s');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { getSpanColor } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
const mockGenerateColor = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: (key: string, colorMap: Record<string, string>): string =>
|
||||
mockGenerateColor(key, colorMap),
|
||||
}));
|
||||
|
||||
describe('Presentation / Styling Utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGenerateColor.mockReturnValue('#2F80ED');
|
||||
});
|
||||
|
||||
describe('getSpanColor', () => {
|
||||
it('uses generated service color for normal span', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: false },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
MOCK_SPAN.serviceName,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(color).toBe('#1890ff');
|
||||
});
|
||||
|
||||
it('overrides with error color in light mode when span has error', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(220, 38, 38)');
|
||||
});
|
||||
|
||||
it('overrides with error color in dark mode when span has error', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: true,
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(239, 68, 68)');
|
||||
});
|
||||
|
||||
it('passes serviceName to generateColor', () => {
|
||||
getSpanColor({
|
||||
span: { ...MOCK_SPAN, serviceName: 'my-service' },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
'my-service',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
export const ROW_HEIGHT = 24;
|
||||
export const SPAN_BAR_HEIGHT = 22;
|
||||
export const SPAN_BAR_Y_OFFSET = Math.floor((ROW_HEIGHT - SPAN_BAR_HEIGHT) / 2);
|
||||
export const EVENT_DOT_SIZE = 6;
|
||||
|
||||
// Span bar sizing relative to row height (used by getFlamegraphRowMetrics)
|
||||
export const SPAN_BAR_HEIGHT_RATIO = SPAN_BAR_HEIGHT / ROW_HEIGHT;
|
||||
export const MIN_SPAN_BAR_HEIGHT = 8;
|
||||
export const MAX_SPAN_BAR_HEIGHT = SPAN_BAR_HEIGHT;
|
||||
|
||||
// Event dot sizing relative to span bar height
|
||||
export const EVENT_DOT_SIZE_RATIO = EVENT_DOT_SIZE / SPAN_BAR_HEIGHT;
|
||||
export const MIN_EVENT_DOT_SIZE = 4;
|
||||
export const MAX_EVENT_DOT_SIZE = EVENT_DOT_SIZE;
|
||||
|
||||
export const LABEL_FONT = '11px Inter, sans-serif';
|
||||
export const LABEL_PADDING_X = 8;
|
||||
export const MIN_WIDTH_FOR_NAME = 30;
|
||||
export const MIN_WIDTH_FOR_NAME_AND_DURATION = 80;
|
||||
|
||||
// Dynamic row height (vertical zoom) -- disabled for now (MIN === MAX)
|
||||
export const MIN_ROW_HEIGHT = 24;
|
||||
export const MAX_ROW_HEIGHT = 24;
|
||||
export const DEFAULT_ROW_HEIGHT = MIN_ROW_HEIGHT;
|
||||
|
||||
// Zoom intensity -- how fast zoom reacts to wheel/pinch delta
|
||||
export const PINCH_ZOOM_INTENSITY_H = 0.01;
|
||||
export const SCROLL_ZOOM_INTENSITY_H = 0.0015;
|
||||
export const PINCH_ZOOM_INTENSITY_V = 0.008;
|
||||
export const SCROLL_ZOOM_INTENSITY_V = 0.001;
|
||||
|
||||
// Minimum visible time span in ms (prevents zooming to sub-pixel)
|
||||
export const MIN_VISIBLE_SPAN_MS = 5;
|
||||
|
||||
// Selected span style (dashed border)
|
||||
export const DASHED_BORDER_LINE_DASH = [4, 2];
|
||||
@@ -1,55 +0,0 @@
|
||||
import { RefObject, useCallback, useEffect } from 'react';
|
||||
|
||||
export function useCanvasSetup(
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
containerRef: RefObject<HTMLDivElement>,
|
||||
onDraw: () => void,
|
||||
): void {
|
||||
const updateCanvasSize = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const viewportHeight = container.clientHeight;
|
||||
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${viewportHeight}px`;
|
||||
|
||||
const newWidth = Math.floor(rect.width * dpr);
|
||||
const newHeight = Math.floor(viewportHeight * dpr);
|
||||
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
onDraw();
|
||||
}
|
||||
}, [canvasRef, containerRef, onDraw]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateCanvasSize);
|
||||
resizeObserver.observe(container);
|
||||
updateCanvasSize();
|
||||
|
||||
// when dpr changes, update the canvas size
|
||||
const dprQuery = window.matchMedia('(resolution: 1dppx)');
|
||||
dprQuery.addEventListener('change', updateCanvasSize);
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
dprQuery.removeEventListener('change', updateCanvasSize);
|
||||
};
|
||||
}, [containerRef, updateCanvasSize]);
|
||||
|
||||
useEffect(() => {
|
||||
onDraw();
|
||||
}, [onDraw]);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { clamp } from '../utils';
|
||||
|
||||
interface UseFlamegraphDragArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
viewStartRef: MutableRefObject<number>;
|
||||
viewEndRef: MutableRefObject<number>;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
scrollTopRef: MutableRefObject<number>;
|
||||
setScrollTop: Dispatch<SetStateAction<number>>;
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
interface UseFlamegraphDragResult {
|
||||
handleMouseDown: (e: ReactMouseEvent) => void;
|
||||
handleMouseMove: (e: ReactMouseEvent) => void;
|
||||
handleMouseUp: () => void;
|
||||
handleDragMouseLeave: () => void;
|
||||
suppressClickRef: MutableRefObject<boolean>;
|
||||
isDraggingRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
export function useFlamegraphDrag(
|
||||
args: UseFlamegraphDragArgs,
|
||||
): UseFlamegraphDragResult {
|
||||
const {
|
||||
canvasRef,
|
||||
containerRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
totalHeight,
|
||||
} = args;
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragDistanceRef = useRef(0);
|
||||
const suppressClickRef = useRef(false);
|
||||
|
||||
const clampScrollTop = useCallback(
|
||||
(next: number): number => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
const viewportHeight = container.clientHeight;
|
||||
const maxScroll = Math.max(0, totalHeight - viewportHeight);
|
||||
return clamp(next, 0, maxScroll);
|
||||
},
|
||||
[containerRef, totalHeight],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event: ReactMouseEvent): void => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
isDraggingRef.current = true;
|
||||
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
},
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(event: ReactMouseEvent): void => {
|
||||
if (!isDraggingRef.current || !dragStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const deltaX = event.clientX - dragStartRef.current.x;
|
||||
const deltaY = event.clientY - dragStartRef.current.y;
|
||||
|
||||
dragDistanceRef.current = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// --- Horizontal pan ---
|
||||
const timeSpan = viewEndRef.current - viewStartRef.current;
|
||||
const deltaTime = (deltaX / rect.width) * timeSpan;
|
||||
|
||||
const newStart = viewStartRef.current - deltaTime;
|
||||
const clampedStart = clamp(
|
||||
newStart,
|
||||
traceMetadata.startTime,
|
||||
traceMetadata.endTime - timeSpan,
|
||||
);
|
||||
const clampedEnd = clampedStart + timeSpan;
|
||||
|
||||
viewStartRef.current = clampedStart;
|
||||
viewEndRef.current = clampedEnd;
|
||||
setViewStartTs(clampedStart);
|
||||
setViewEndTs(clampedEnd);
|
||||
|
||||
// --- Vertical scroll pan ---
|
||||
const nextScrollTop = clampScrollTop(scrollTopRef.current - deltaY);
|
||||
scrollTopRef.current = nextScrollTop;
|
||||
setScrollTop(nextScrollTop);
|
||||
|
||||
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
},
|
||||
[
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
clampScrollTop,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback((): void => {
|
||||
const wasDrag = dragDistanceRef.current > DRAG_THRESHOLD;
|
||||
suppressClickRef.current = wasDrag;
|
||||
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
}, [canvasRef]);
|
||||
|
||||
const handleDragMouseLeave = useCallback((): void => {
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
}, [canvasRef]);
|
||||
|
||||
return {
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleDragMouseLeave,
|
||||
suppressClickRef,
|
||||
isDraggingRef,
|
||||
};
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import React, { RefObject, useCallback, useRef } from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { SpanRect } from '../types';
|
||||
import {
|
||||
clamp,
|
||||
drawSpanBar,
|
||||
FlamegraphRowMetrics,
|
||||
getFlamegraphRowMetrics,
|
||||
getSpanColor,
|
||||
} from '../utils';
|
||||
|
||||
interface UseFlamegraphDrawArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
spans: FlamegraphSpan[][];
|
||||
viewStartTs: number;
|
||||
viewEndTs: number;
|
||||
scrollTop: number;
|
||||
rowHeight: number;
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
spanRectsRef?: React.MutableRefObject<SpanRect[]>;
|
||||
}
|
||||
|
||||
interface UseFlamegraphDrawResult {
|
||||
drawFlamegraph: () => void;
|
||||
spanRectsRef: RefObject<SpanRect[]>;
|
||||
}
|
||||
|
||||
const OVERSCAN_ROWS = 4;
|
||||
|
||||
interface DrawLevelArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
levelSpans: FlamegraphSpan[];
|
||||
levelIndex: number;
|
||||
y: number;
|
||||
viewStartTs: number;
|
||||
timeSpan: number;
|
||||
cssWidth: number;
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
spanRectsArray: SpanRect[];
|
||||
metrics: FlamegraphRowMetrics;
|
||||
}
|
||||
|
||||
function drawLevel(args: DrawLevelArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
levelSpans,
|
||||
levelIndex,
|
||||
y,
|
||||
viewStartTs,
|
||||
timeSpan,
|
||||
cssWidth,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
spanRectsArray,
|
||||
metrics,
|
||||
} = args;
|
||||
|
||||
const viewEndTs = viewStartTs + timeSpan;
|
||||
|
||||
for (let i = 0; i < levelSpans.length; i++) {
|
||||
const span = levelSpans[i];
|
||||
const spanStartMs = span.timestamp;
|
||||
const spanEndMs = span.timestamp + span.durationNano / 1e6;
|
||||
|
||||
// Time culling -- skip spans entirely outside the visible time window
|
||||
if (spanEndMs < viewStartTs || spanStartMs > viewEndTs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const leftOffset = ((spanStartMs - viewStartTs) / timeSpan) * cssWidth;
|
||||
const rightEdge = ((spanEndMs - viewStartTs) / timeSpan) * cssWidth;
|
||||
let width = rightEdge - leftOffset;
|
||||
|
||||
// Clamp to visible x-range
|
||||
if (leftOffset < 0) {
|
||||
width += leftOffset;
|
||||
if (width <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (rightEdge > cssWidth) {
|
||||
width = cssWidth - Math.max(0, leftOffset);
|
||||
if (width <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum 1px width so tiny spans remain visible
|
||||
width = clamp(width, 1, Infinity);
|
||||
|
||||
const color = getSpanColor({ span, isDarkMode });
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span,
|
||||
x: Math.max(0, leftOffset),
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
color,
|
||||
isDarkMode,
|
||||
metrics,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useFlamegraphDraw(
|
||||
args: UseFlamegraphDrawArgs,
|
||||
): UseFlamegraphDrawResult {
|
||||
const {
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
rowHeight,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
spanRectsRef: spanRectsRefProp,
|
||||
} = args;
|
||||
|
||||
const spanRectsRefInternal = useRef<SpanRect[]>([]);
|
||||
const spanRectsRef = spanRectsRefProp ?? spanRectsRefInternal;
|
||||
|
||||
const drawFlamegraph = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const timeSpan = viewEndTs - viewStartTs;
|
||||
if (timeSpan <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const metrics = getFlamegraphRowMetrics(rowHeight);
|
||||
|
||||
// ---- Vertical clipping window ----
|
||||
const viewportHeight = container.clientHeight;
|
||||
|
||||
//starts drawing OVERSCAN_ROWS(4) rows above the visible area.
|
||||
const firstLevel = Math.max(
|
||||
0,
|
||||
Math.floor(scrollTop / metrics.ROW_HEIGHT) - OVERSCAN_ROWS,
|
||||
);
|
||||
// adds 2*OVERSCAN_ROWS extra rows above and below the visible area.
|
||||
const visibleLevelCount =
|
||||
Math.ceil(viewportHeight / metrics.ROW_HEIGHT) + 2 * OVERSCAN_ROWS;
|
||||
|
||||
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
|
||||
|
||||
ctx.clearRect(0, 0, cssWidth, viewportHeight);
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
|
||||
// ---- Draw only visible levels ----
|
||||
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
|
||||
const levelSpans = spans[levelIndex];
|
||||
if (!levelSpans) {
|
||||
continue;
|
||||
}
|
||||
|
||||
drawLevel({
|
||||
ctx,
|
||||
levelSpans,
|
||||
levelIndex,
|
||||
y: levelIndex * metrics.ROW_HEIGHT - scrollTop,
|
||||
viewStartTs,
|
||||
timeSpan,
|
||||
cssWidth,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
spanRectsArray,
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
spanRectsRef.current = spanRectsArray;
|
||||
}, [
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spanRectsRef,
|
||||
spans,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
rowHeight,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
]);
|
||||
|
||||
// TODO: spanRectsRef is a flat array — hover scans all visible rects O(N).
|
||||
// Upgrade to per-level buckets: spanRects[levelIndex] = [...] so hover can
|
||||
// compute level from mouseY / ROW_HEIGHT and scan only that row.
|
||||
// Further: binary search within a level by x (spans are sorted by start time)
|
||||
// to reduce hover cost from O(N) to O(log N).
|
||||
return { drawFlamegraph, spanRectsRef };
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { SpanRect } from '../types';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { getSpanColor } from '../utils';
|
||||
|
||||
function getCanvasPointer(
|
||||
canvas: HTMLCanvasElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { cssX: number; cssY: number } | null {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const cssHeight = canvas.height / dpr;
|
||||
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
|
||||
const cssY = (clientY - rect.top) * (cssHeight / rect.height);
|
||||
return { cssX, cssY };
|
||||
}
|
||||
|
||||
function findSpanAtPosition(
|
||||
cssX: number,
|
||||
cssY: number,
|
||||
spanRects: SpanRect[],
|
||||
): FlamegraphSpan | null {
|
||||
for (let i = spanRects.length - 1; i >= 0; i--) {
|
||||
const r = spanRects[i];
|
||||
if (
|
||||
cssX >= r.x &&
|
||||
cssX <= r.x + r.width &&
|
||||
cssY >= r.y &&
|
||||
cssY <= r.y + r.height
|
||||
) {
|
||||
return r.span;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface TooltipContent {
|
||||
spanName: string;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
spanColor: string;
|
||||
}
|
||||
|
||||
interface UseFlamegraphHoverArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
spanRectsRef: MutableRefObject<SpanRect[]>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
viewStartTs: number;
|
||||
viewEndTs: number;
|
||||
isDraggingRef: MutableRefObject<boolean>;
|
||||
suppressClickRef: MutableRefObject<boolean>;
|
||||
onSpanClick: (spanId: string) => void;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
interface UseFlamegraphHoverResult {
|
||||
hoveredSpanId: string | null;
|
||||
setHoveredSpanId: Dispatch<SetStateAction<string | null>>;
|
||||
handleHoverMouseMove: (e: ReactMouseEvent) => void;
|
||||
handleHoverMouseLeave: () => void;
|
||||
handleClick: (e: ReactMouseEvent) => void;
|
||||
tooltipContent: TooltipContent | null;
|
||||
}
|
||||
|
||||
export function useFlamegraphHover(
|
||||
args: UseFlamegraphHoverArgs,
|
||||
): UseFlamegraphHoverResult {
|
||||
const {
|
||||
canvasRef,
|
||||
spanRectsRef,
|
||||
traceMetadata,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
isDraggingRef,
|
||||
suppressClickRef,
|
||||
onSpanClick,
|
||||
isDarkMode,
|
||||
} = args;
|
||||
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
|
||||
const [tooltipContent, setTooltipContent] = useState<TooltipContent | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isZoomed =
|
||||
viewStartTs !== traceMetadata.startTime ||
|
||||
viewEndTs !== traceMetadata.endTime;
|
||||
|
||||
const updateCursor = useCallback(
|
||||
(canvas: HTMLCanvasElement, span: FlamegraphSpan | null): void => {
|
||||
if (span) {
|
||||
canvas.style.cursor = 'pointer';
|
||||
} else if (isZoomed) {
|
||||
canvas.style.cursor = 'grab';
|
||||
} else {
|
||||
canvas.style.cursor = 'default';
|
||||
}
|
||||
},
|
||||
[isZoomed],
|
||||
);
|
||||
|
||||
const handleHoverMouseMove = useCallback(
|
||||
(e: ReactMouseEvent): void => {
|
||||
if (isDraggingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = findSpanAtPosition(
|
||||
pointer.cssX,
|
||||
pointer.cssY,
|
||||
spanRectsRef.current,
|
||||
);
|
||||
|
||||
if (span) {
|
||||
setHoveredSpanId(span.spanId);
|
||||
setTooltipContent({
|
||||
spanName: span.name || 'unknown',
|
||||
status: span.hasError ? 'error' : 'ok',
|
||||
startMs: span.timestamp - traceMetadata.startTime,
|
||||
durationMs: span.durationNano / 1e6,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
spanColor: getSpanColor({ span, isDarkMode }),
|
||||
});
|
||||
updateCursor(canvas, span);
|
||||
} else {
|
||||
setHoveredSpanId(null);
|
||||
setTooltipContent(null);
|
||||
updateCursor(canvas, null);
|
||||
}
|
||||
},
|
||||
[
|
||||
canvasRef,
|
||||
spanRectsRef,
|
||||
traceMetadata.startTime,
|
||||
isDraggingRef,
|
||||
updateCursor,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const handleHoverMouseLeave = useCallback((): void => {
|
||||
setHoveredSpanId(null);
|
||||
setTooltipContent(null);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
updateCursor(canvas, null);
|
||||
}
|
||||
}, [canvasRef, updateCursor]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: ReactMouseEvent): void => {
|
||||
if (suppressClickRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = findSpanAtPosition(
|
||||
pointer.cssX,
|
||||
pointer.cssY,
|
||||
spanRectsRef.current,
|
||||
);
|
||||
|
||||
if (span) {
|
||||
onSpanClick(span.spanId);
|
||||
}
|
||||
},
|
||||
[canvasRef, spanRectsRef, suppressClickRef, onSpanClick],
|
||||
);
|
||||
|
||||
return {
|
||||
hoveredSpanId,
|
||||
setHoveredSpanId,
|
||||
handleHoverMouseMove,
|
||||
handleHoverMouseLeave,
|
||||
handleClick,
|
||||
tooltipContent,
|
||||
};
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
MAX_ROW_HEIGHT,
|
||||
MIN_ROW_HEIGHT,
|
||||
MIN_VISIBLE_SPAN_MS,
|
||||
PINCH_ZOOM_INTENSITY_H,
|
||||
PINCH_ZOOM_INTENSITY_V,
|
||||
SCROLL_ZOOM_INTENSITY_H,
|
||||
SCROLL_ZOOM_INTENSITY_V,
|
||||
} from '../constants';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { clamp } from '../utils';
|
||||
|
||||
interface UseFlamegraphZoomArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
viewStartRef: MutableRefObject<number>;
|
||||
viewEndRef: MutableRefObject<number>;
|
||||
rowHeightRef: MutableRefObject<number>;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
setRowHeight: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
interface UseFlamegraphZoomResult {
|
||||
handleResetZoom: () => void;
|
||||
isOverFlamegraphRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
function getCanvasPointer(
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
clientX: number,
|
||||
): { cssX: number; cssWidth: number } | null {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
|
||||
|
||||
return { cssX, cssWidth };
|
||||
}
|
||||
|
||||
export function useFlamegraphZoom(
|
||||
args: UseFlamegraphZoomArgs,
|
||||
): UseFlamegraphZoomResult {
|
||||
const {
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
} = args;
|
||||
|
||||
const isOverFlamegraphRef = useRef(false);
|
||||
const wheelDeltaRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastCursorXRef = useRef(0);
|
||||
const lastCssWidthRef = useRef(1);
|
||||
const lastIsPinchRef = useRef(false);
|
||||
const lastWheelClientXRef = useRef<number | null>(null);
|
||||
|
||||
// Prevent browser zoom when pinching over the flamegraph
|
||||
useEffect(() => {
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
if (isOverFlamegraphRef.current && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('wheel', onWheel, {
|
||||
capture: true,
|
||||
} as EventListenerOptions);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyWheelZoom = useCallback(() => {
|
||||
rafRef.current = null;
|
||||
|
||||
const cssWidth = lastCssWidthRef.current || 1;
|
||||
const cursorX = lastCursorXRef.current;
|
||||
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
|
||||
|
||||
const oldStart = viewStartRef.current;
|
||||
const oldEnd = viewEndRef.current;
|
||||
const oldSpan = oldEnd - oldStart;
|
||||
|
||||
const deltaY = wheelDeltaRef.current;
|
||||
wheelDeltaRef.current = 0;
|
||||
if (deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomH = lastIsPinchRef.current
|
||||
? PINCH_ZOOM_INTENSITY_H
|
||||
: SCROLL_ZOOM_INTENSITY_H;
|
||||
const zoomV = lastIsPinchRef.current
|
||||
? PINCH_ZOOM_INTENSITY_V
|
||||
: SCROLL_ZOOM_INTENSITY_V;
|
||||
|
||||
const factorH = Math.exp(deltaY * zoomH);
|
||||
const factorV = Math.exp(deltaY * zoomV);
|
||||
|
||||
// --- Horizontal zoom ---
|
||||
const desiredSpan = oldSpan * factorH;
|
||||
const minSpanMs = Math.max(
|
||||
MIN_VISIBLE_SPAN_MS,
|
||||
oldSpan / Math.max(cssWidth, 1),
|
||||
);
|
||||
const clampedSpan = clamp(desiredSpan, minSpanMs, fullSpanMs);
|
||||
|
||||
const cursorRatio = clamp(cursorX / cssWidth, 0, 1);
|
||||
const anchorTs = oldStart + cursorRatio * oldSpan;
|
||||
|
||||
let nextStart = anchorTs - cursorRatio * clampedSpan;
|
||||
nextStart = clamp(
|
||||
nextStart,
|
||||
traceMetadata.startTime,
|
||||
traceMetadata.endTime - clampedSpan,
|
||||
);
|
||||
const nextEnd = nextStart + clampedSpan;
|
||||
|
||||
// --- Vertical zoom (row height) ---
|
||||
const desiredRow = rowHeightRef.current * (1 / factorV);
|
||||
const nextRow = clamp(desiredRow, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
|
||||
|
||||
// Write refs immediately so rapid wheel events read fresh values
|
||||
viewStartRef.current = nextStart;
|
||||
viewEndRef.current = nextEnd;
|
||||
rowHeightRef.current = nextRow;
|
||||
|
||||
setViewStartTs(nextStart);
|
||||
setViewEndTs(nextEnd);
|
||||
setRowHeight(nextRow);
|
||||
}, [
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
]);
|
||||
|
||||
// Native wheel listener on the canvas (passive: false for reliable preventDefault)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
e.preventDefault();
|
||||
|
||||
const pointer = getCanvasPointer(canvasRef, e.clientX);
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush accumulated delta if cursor moved significantly
|
||||
if (lastWheelClientXRef.current !== null) {
|
||||
const moved = Math.abs(e.clientX - lastWheelClientXRef.current);
|
||||
if (moved > 6) {
|
||||
wheelDeltaRef.current = 0;
|
||||
}
|
||||
}
|
||||
lastWheelClientXRef.current = e.clientX;
|
||||
|
||||
lastIsPinchRef.current = e.ctrlKey;
|
||||
lastCssWidthRef.current = pointer.cssWidth;
|
||||
lastCursorXRef.current = pointer.cssX;
|
||||
wheelDeltaRef.current += e.deltaY;
|
||||
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(applyWheelZoom);
|
||||
}
|
||||
};
|
||||
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
return (): void => {
|
||||
canvas.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}, [canvasRef, applyWheelZoom]);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
viewStartRef.current = traceMetadata.startTime;
|
||||
viewEndRef.current = traceMetadata.endTime;
|
||||
rowHeightRef.current = DEFAULT_ROW_HEIGHT;
|
||||
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
setRowHeight(DEFAULT_ROW_HEIGHT);
|
||||
}, [
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
]);
|
||||
|
||||
return { handleResetZoom, isOverFlamegraphRef };
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { MIN_VISIBLE_SPAN_MS } from '../constants';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { clamp, findSpanById, getFlamegraphRowMetrics } from '../utils';
|
||||
|
||||
interface UseScrollToSpanArgs {
|
||||
firstSpanAtFetchLevel: string;
|
||||
spans: FlamegraphSpan[][];
|
||||
traceMetadata: ITraceMetadata;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
viewStartRef: MutableRefObject<number>;
|
||||
viewEndRef: MutableRefObject<number>;
|
||||
scrollTopRef: MutableRefObject<number>;
|
||||
rowHeight: number;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
setScrollTop: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* When firstSpanAtFetchLevel (from URL spanId) changes, scroll and zoom the
|
||||
* flamegraph so the selected span is centered in view.
|
||||
*/
|
||||
export function useScrollToSpan(args: UseScrollToSpanArgs): void {
|
||||
const {
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
} = args;
|
||||
|
||||
useEffect(() => {
|
||||
if (!firstSpanAtFetchLevel || spans.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = findSpanById(spans, firstSpanAtFetchLevel);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { span, levelIndex } = result;
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getFlamegraphRowMetrics(rowHeight);
|
||||
const viewportHeight = container.clientHeight;
|
||||
const totalHeight = spans.length * metrics.ROW_HEIGHT;
|
||||
const maxScroll = Math.max(0, totalHeight - viewportHeight);
|
||||
|
||||
// Vertical: center the span's row in the viewport
|
||||
const targetScrollTop = clamp(
|
||||
levelIndex * metrics.ROW_HEIGHT -
|
||||
viewportHeight / 2 +
|
||||
metrics.ROW_HEIGHT / 2,
|
||||
0,
|
||||
maxScroll,
|
||||
);
|
||||
|
||||
// Horizontal: zoom to span with padding (2x span duration), center it
|
||||
const spanStartMs = span.timestamp;
|
||||
const spanEndMs = span.timestamp + span.durationNano / 1e6;
|
||||
const spanDurationMs = spanEndMs - spanStartMs;
|
||||
const spanCenterMs = (spanStartMs + spanEndMs) / 2;
|
||||
|
||||
const visibleWindowMs = Math.max(spanDurationMs * 2, MIN_VISIBLE_SPAN_MS);
|
||||
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
|
||||
const clampedWindow = clamp(visibleWindowMs, MIN_VISIBLE_SPAN_MS, fullSpanMs);
|
||||
|
||||
let targetViewStart = spanCenterMs - clampedWindow / 2;
|
||||
let targetViewEnd = spanCenterMs + clampedWindow / 2;
|
||||
|
||||
targetViewStart = clamp(
|
||||
targetViewStart,
|
||||
traceMetadata.startTime,
|
||||
traceMetadata.endTime - clampedWindow,
|
||||
);
|
||||
targetViewEnd = targetViewStart + clampedWindow;
|
||||
|
||||
// Apply immediately (instant jump)
|
||||
viewStartRef.current = targetViewStart;
|
||||
viewEndRef.current = targetViewEnd;
|
||||
scrollTopRef.current = targetScrollTop;
|
||||
|
||||
setViewStartTs(targetViewStart);
|
||||
setViewEndTs(targetViewEnd);
|
||||
setScrollTop(targetScrollTop);
|
||||
}, [
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
]);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
export interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface FlamegraphCanvasProps {
|
||||
spans: FlamegraphSpan[][];
|
||||
firstSpanAtFetchLevel: string;
|
||||
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
|
||||
onSpanClick: (spanId: string) => void;
|
||||
traceMetadata: ITraceMetadata;
|
||||
}
|
||||
|
||||
export interface SpanRect {
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import {
|
||||
DASHED_BORDER_LINE_DASH,
|
||||
EVENT_DOT_SIZE_RATIO,
|
||||
LABEL_FONT,
|
||||
LABEL_PADDING_X,
|
||||
MAX_EVENT_DOT_SIZE,
|
||||
MAX_SPAN_BAR_HEIGHT,
|
||||
MIN_EVENT_DOT_SIZE,
|
||||
MIN_SPAN_BAR_HEIGHT,
|
||||
MIN_WIDTH_FOR_NAME,
|
||||
MIN_WIDTH_FOR_NAME_AND_DURATION,
|
||||
SPAN_BAR_HEIGHT_RATIO,
|
||||
} from './constants';
|
||||
import { SpanRect } from './types';
|
||||
|
||||
export function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
/** Create diagonal stripe pattern for selected/hovered span (repeating-linear-gradient -45deg style). */
|
||||
function createStripePattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
color: string,
|
||||
): CanvasPattern | null {
|
||||
const size = 20;
|
||||
const patternCanvas = document.createElement('canvas');
|
||||
patternCanvas.width = size;
|
||||
patternCanvas.height = size;
|
||||
const pCtx = patternCanvas.getContext('2d');
|
||||
if (!pCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Diagonal stripes at -45deg: 10px transparent, 10px colored (0.04 opacity), repeat
|
||||
pCtx.globalAlpha = 0.04;
|
||||
pCtx.strokeStyle = color;
|
||||
pCtx.lineWidth = 10;
|
||||
pCtx.lineCap = 'butt';
|
||||
for (let i = -size; i < size * 2; i += size) {
|
||||
pCtx.beginPath();
|
||||
pCtx.moveTo(i + size, 0);
|
||||
pCtx.lineTo(i, size);
|
||||
pCtx.stroke();
|
||||
}
|
||||
pCtx.globalAlpha = 1;
|
||||
|
||||
return ctx.createPattern(patternCanvas, 'repeat');
|
||||
}
|
||||
|
||||
export function findSpanById(
|
||||
spans: FlamegraphSpan[][],
|
||||
spanId: string,
|
||||
): { span: FlamegraphSpan; levelIndex: number } | null {
|
||||
for (let levelIndex = 0; levelIndex < spans.length; levelIndex++) {
|
||||
const span = spans[levelIndex]?.find((s) => s.spanId === spanId);
|
||||
if (span) {
|
||||
return { span, levelIndex };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FlamegraphRowMetrics {
|
||||
ROW_HEIGHT: number;
|
||||
SPAN_BAR_HEIGHT: number;
|
||||
SPAN_BAR_Y_OFFSET: number;
|
||||
EVENT_DOT_SIZE: number;
|
||||
}
|
||||
|
||||
export function getFlamegraphRowMetrics(
|
||||
rowHeight: number,
|
||||
): FlamegraphRowMetrics {
|
||||
const spanBarHeight = clamp(
|
||||
Math.round(rowHeight * SPAN_BAR_HEIGHT_RATIO),
|
||||
MIN_SPAN_BAR_HEIGHT,
|
||||
MAX_SPAN_BAR_HEIGHT,
|
||||
);
|
||||
const spanBarYOffset = Math.floor((rowHeight - spanBarHeight) / 2);
|
||||
const eventDotSize = clamp(
|
||||
Math.round(spanBarHeight * EVENT_DOT_SIZE_RATIO),
|
||||
MIN_EVENT_DOT_SIZE,
|
||||
MAX_EVENT_DOT_SIZE,
|
||||
);
|
||||
|
||||
return {
|
||||
ROW_HEIGHT: rowHeight,
|
||||
SPAN_BAR_HEIGHT: spanBarHeight,
|
||||
SPAN_BAR_Y_OFFSET: spanBarYOffset,
|
||||
EVENT_DOT_SIZE: eventDotSize,
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSpanColorArgs {
|
||||
span: FlamegraphSpan;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export function getSpanColor(args: GetSpanColorArgs): string {
|
||||
const { span, isDarkMode } = args;
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
|
||||
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
interface DrawEventDotArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
x: number;
|
||||
y: number;
|
||||
isError: boolean;
|
||||
isDarkMode: boolean;
|
||||
eventDotSize: number;
|
||||
}
|
||||
|
||||
export function drawEventDot(args: DrawEventDotArgs): void {
|
||||
const { ctx, x, y, isError, isDarkMode, eventDotSize } = args;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
|
||||
if (isError) {
|
||||
ctx.fillStyle = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
ctx.strokeStyle = isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)';
|
||||
} else {
|
||||
ctx.fillStyle = isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)';
|
||||
ctx.strokeStyle = isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)';
|
||||
}
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
const half = eventDotSize / 2;
|
||||
ctx.fillRect(-half, -half, eventDotSize, eventDotSize);
|
||||
ctx.strokeRect(-half, -half, eventDotSize, eventDotSize);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
interface DrawSpanBarArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
levelIndex: number;
|
||||
spanRectsArray: SpanRect[];
|
||||
color: string;
|
||||
isDarkMode: boolean;
|
||||
metrics: FlamegraphRowMetrics;
|
||||
selectedSpanId?: string | null;
|
||||
hoveredSpanId?: string | null;
|
||||
}
|
||||
|
||||
export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
span,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
color,
|
||||
isDarkMode,
|
||||
metrics,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
} = args;
|
||||
|
||||
const spanY = y + metrics.SPAN_BAR_Y_OFFSET;
|
||||
const isSelected = selectedSpanId === span.spanId;
|
||||
const isHovered = hoveredSpanId === span.spanId;
|
||||
const isSelectedOrHovered = isSelected || isHovered;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, spanY, width, metrics.SPAN_BAR_HEIGHT, 2);
|
||||
|
||||
if (isSelectedOrHovered) {
|
||||
// Diagonal stripe pattern (repeating-linear-gradient -45deg style) + border in span color
|
||||
const pattern = createStripePattern(ctx, color);
|
||||
if (pattern) {
|
||||
ctx.fillStyle = pattern;
|
||||
ctx.fill();
|
||||
}
|
||||
if (isSelected) {
|
||||
ctx.setLineDash(DASHED_BORDER_LINE_DASH);
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
ctx.stroke();
|
||||
if (isSelected) {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
spanRectsArray.push({
|
||||
span,
|
||||
x,
|
||||
y: spanY,
|
||||
width,
|
||||
height: metrics.SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
});
|
||||
|
||||
span.event?.forEach((event) => {
|
||||
const spanDurationMs = span.durationNano / 1e6;
|
||||
if (spanDurationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
|
||||
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
|
||||
const eventX = x + (clampedOffset / 100) * width;
|
||||
const eventY = spanY + metrics.SPAN_BAR_HEIGHT / 2;
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: eventX,
|
||||
y: eventY,
|
||||
isError: event.isError,
|
||||
isDarkMode,
|
||||
eventDotSize: metrics.EVENT_DOT_SIZE,
|
||||
});
|
||||
});
|
||||
|
||||
drawSpanLabel({
|
||||
ctx,
|
||||
span,
|
||||
x,
|
||||
y: spanY,
|
||||
width,
|
||||
color,
|
||||
isSelectedOrHovered,
|
||||
isDarkMode,
|
||||
spanBarHeight: metrics.SPAN_BAR_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(durationNano: number): string {
|
||||
const durationMs = durationNano / 1e6;
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(durationMs);
|
||||
return `${parseFloat(time.toFixed(2))}${timeUnitName}`;
|
||||
}
|
||||
|
||||
interface DrawSpanLabelArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
color: string;
|
||||
isSelectedOrHovered: boolean;
|
||||
isDarkMode: boolean;
|
||||
spanBarHeight: number;
|
||||
}
|
||||
|
||||
function drawSpanLabel(args: DrawSpanLabelArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
span,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
color,
|
||||
isSelectedOrHovered,
|
||||
isDarkMode,
|
||||
spanBarHeight,
|
||||
} = args;
|
||||
|
||||
if (width < MIN_WIDTH_FOR_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = span.name;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Clip text to span bar bounds
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, spanBarHeight);
|
||||
ctx.clip();
|
||||
|
||||
ctx.font = LABEL_FONT;
|
||||
ctx.fillStyle = isSelectedOrHovered
|
||||
? color
|
||||
: isDarkMode
|
||||
? 'rgba(0, 0, 0, 0.9)'
|
||||
: 'rgba(255, 255, 255, 0.9)';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const textY = y + spanBarHeight / 2;
|
||||
const leftX = x + LABEL_PADDING_X;
|
||||
const rightX = x + width - LABEL_PADDING_X;
|
||||
const availableWidth = width - LABEL_PADDING_X * 2;
|
||||
|
||||
if (width >= MIN_WIDTH_FOR_NAME_AND_DURATION) {
|
||||
const duration = formatDuration(span.durationNano);
|
||||
const durationWidth = ctx.measureText(duration).width;
|
||||
const minGap = 6;
|
||||
const nameSpace = availableWidth - durationWidth - minGap;
|
||||
|
||||
// Duration right-aligned
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(duration, rightX, textY);
|
||||
|
||||
// Name left-aligned, truncated to fit remaining space
|
||||
if (nameSpace > 20) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(truncateText(ctx, name, nameSpace), leftX, textY);
|
||||
}
|
||||
} else {
|
||||
// Name only, truncated to fit
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(truncateText(ctx, name, availableWidth), leftX, textY);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function truncateText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
): string {
|
||||
const ellipsis = '...';
|
||||
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
||||
|
||||
if (ctx.measureText(text).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (ctx.measureText(text.slice(0, mid)).width + ellipsisWidth <= maxWidth) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return lo > 0 ? `${text.slice(0, lo)}${ellipsis}` : ellipsis;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@signozhq/resizable';
|
||||
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
|
||||
function TraceDetailsV3(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100vh - 90px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<TraceDetailsHeader />
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
autoSaveId="trace-details-v3-layout"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<ResizablePanel defaultSize={40} minSize={20} maxSize={80}>
|
||||
<TraceFlamegraph />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={60} minSize={20}>
|
||||
<div />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsV3;
|
||||
28
frontend/src/utils/metricsTimeStorageUtils.ts
Normal file
28
frontend/src/utils/metricsTimeStorageUtils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
/**
|
||||
* Updates the stored time duration for a route in localStorage.
|
||||
* Used by both DateTimeSelectionV2 (manual time pick) and useZoomOut (zoom out button).
|
||||
*
|
||||
* @param pathname - The route path (e.g. /infrastructure-monitoring/hosts)
|
||||
* @param value - The time value to store (preset string like '1w' or JSON string for custom range)
|
||||
*/
|
||||
export function persistTimeDurationForRoute(
|
||||
pathname: string,
|
||||
value: string,
|
||||
): void {
|
||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||
let preRoutesObject: Record<string, string> = {};
|
||||
try {
|
||||
preRoutesObject = preRoutes ? JSON.parse(preRoutes) : {};
|
||||
} catch {
|
||||
preRoutesObject = {};
|
||||
}
|
||||
const preRoute = { ...preRoutesObject, [pathname]: value };
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify(preRoute),
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"name": "typescript-plugin-css-modules"
|
||||
}
|
||||
],
|
||||
"types": ["vite/client", "node", "jest"]
|
||||
"types": ["vite/client", "node", "jest", "testing-library__jest-dom"]
|
||||
},
|
||||
"exclude": ["node_modules", "src/parser/*.ts", "src/parser/TraceOperatorParser/*.ts", "orval.config.ts"],
|
||||
"include": [
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.3.tgz#90749bde8b89cd41764224f5aac29cd4138f75ff"
|
||||
integrity sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==
|
||||
|
||||
"@alloc/quick-lru@^5.2.0":
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
|
||||
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
|
||||
|
||||
"@ampproject/remapping@^2.2.0":
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630"
|
||||
@@ -2549,7 +2554,14 @@
|
||||
"@codemirror/view" "^6.0.0"
|
||||
crelt "^1.0.5"
|
||||
|
||||
"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0":
|
||||
"@codemirror/state@6.5.4", "@codemirror/state@^6.1.4":
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.4.tgz#f5be4b8c0d2310180d5f15a9f641c21ca69faf19"
|
||||
integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==
|
||||
dependencies:
|
||||
"@marijn/find-cluster-break" "^1.0.0"
|
||||
|
||||
"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.4.0":
|
||||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.2.tgz#8eca3a64212a83367dc85475b7d78d5c9b7076c6"
|
||||
integrity sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==
|
||||
@@ -2566,13 +2578,13 @@
|
||||
"@codemirror/view" "^6.0.0"
|
||||
"@lezer/highlight" "^1.0.0"
|
||||
|
||||
"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0":
|
||||
version "6.36.6"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.36.6.tgz#735a6431caed0c2c7d26c645066b02f10e802812"
|
||||
integrity sha512-uxugGLet+Nzp0Jcit8Hn3LypM8ioMLKTsdf8FRoT3HWvZtb9GhaWMe0Cc15rz90Ljab4YFJiAulmIVB74OY0IQ==
|
||||
"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@~6.5.1":
|
||||
version "6.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.5.1.tgz#f4533cd796f0569508822d0012bee9a15dc4b3f9"
|
||||
integrity sha512-xBKP8N3AXOs06VcKvIuvIQoUlGs7Hb78ftJWahLaRX909jKPMgGxR5XjvrawzTTZMSTU3DzdjDNPwG6fPM/ypQ==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.5.0"
|
||||
style-mod "^4.1.0"
|
||||
"@codemirror/state" "^6.1.4"
|
||||
style-mod "^4.0.0"
|
||||
w3c-keyname "^2.2.4"
|
||||
|
||||
"@commander-js/extra-typings@^14.0.0":
|
||||
@@ -3762,6 +3774,19 @@
|
||||
"@types/yargs" "^17.0.8"
|
||||
chalk "^4.0.0"
|
||||
|
||||
"@jest/types@^30.3.0":
|
||||
version "30.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.3.0.tgz#cada800d323cb74945c24ac74615fdb312a6c85f"
|
||||
integrity sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==
|
||||
dependencies:
|
||||
"@jest/pattern" "30.0.1"
|
||||
"@jest/schemas" "30.0.5"
|
||||
"@types/istanbul-lib-coverage" "^2.0.6"
|
||||
"@types/istanbul-reports" "^3.0.4"
|
||||
"@types/node" "*"
|
||||
"@types/yargs" "^17.0.33"
|
||||
chalk "^4.1.2"
|
||||
|
||||
"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2":
|
||||
version "0.3.3"
|
||||
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz"
|
||||
@@ -4000,19 +4025,20 @@
|
||||
"@types/mdx" "^2.0.0"
|
||||
"@types/react" ">=16"
|
||||
|
||||
"@monaco-editor/loader@^1.3.3":
|
||||
version "1.3.3"
|
||||
resolved "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz"
|
||||
integrity sha512-6KKF4CTzcJiS8BJwtxtfyYt9shBiEv32ateQ9T4UVogwn4HM/uPo9iJd2Dmbkpz8CM6Y0PDUpjnZzCwC+eYo2Q==
|
||||
"@monaco-editor/loader@^1.2.0":
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.7.0.tgz#967aaa4601b19e913627688dfe8159d57549e793"
|
||||
integrity sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==
|
||||
dependencies:
|
||||
state-local "^1.0.6"
|
||||
|
||||
"@monaco-editor/react@^4.3.1":
|
||||
version "4.5.0"
|
||||
resolved "https://registry.npmjs.org/@monaco-editor/react/-/react-4.5.0.tgz"
|
||||
integrity sha512-VJMkp5Fe1+w8pLEq8tZPHZKu8zDXQIA1FtiDTSNccg1D3wg1YIZaH2es2Qpvop1k62g3c/YySRb3bnGXu2XwYQ==
|
||||
"@monaco-editor/react@~4.3.1":
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.3.1.tgz#d65bcbf174c39b6d4e7fec43d0cddda82b70a12a"
|
||||
integrity sha512-f+0BK1PP/W5I50hHHmwf11+Ea92E5H1VZXs+wvKplWUWOfyMa1VVwqkJrXjRvbcqHL+XdIGYWhWNdi4McEvnZg==
|
||||
dependencies:
|
||||
"@monaco-editor/loader" "^1.3.3"
|
||||
"@monaco-editor/loader" "^1.2.0"
|
||||
prop-types "^15.7.2"
|
||||
|
||||
"@mswjs/cookies@^0.2.2":
|
||||
version "0.2.2"
|
||||
@@ -6199,7 +6225,7 @@
|
||||
dependencies:
|
||||
"@types/geojson" "*"
|
||||
|
||||
"@types/d3-hierarchy@^1.1.6":
|
||||
"@types/d3-hierarchy@1.1.11", "@types/d3-hierarchy@^1.1.6":
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz#c3bd70d025621f73cb3319e97e08ae4c9051c791"
|
||||
integrity sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==
|
||||
@@ -6704,6 +6730,13 @@
|
||||
"@types/react" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/testing-library__jest-dom@^5.14.5":
|
||||
version "5.14.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz#0fb1e6a0278d87b6737db55af5967570b67cb466"
|
||||
integrity sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==
|
||||
dependencies:
|
||||
"@types/jest" "*"
|
||||
|
||||
"@types/testing-library__jest-dom@^5.9.1":
|
||||
version "5.14.5"
|
||||
resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz"
|
||||
@@ -7510,6 +7543,11 @@ antlr4@4.13.2:
|
||||
resolved "https://registry.yarnpkg.com/antlr4/-/antlr4-4.13.2.tgz#0d084ad0e32620482a9c3a0e2470c02e72e4006d"
|
||||
integrity sha512-QiVbZhyy4xAZ17UPEuG3YTOt8ZaoeOR1CvEAqrEsDBsOqINslaB147i9xqljZqoyf5S+EUlGStaj+t22LT9MOg==
|
||||
|
||||
any-promise@^1.0.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
|
||||
integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
|
||||
|
||||
anymatch@^3.0.3, anymatch@^3.1.3, anymatch@~3.1.2:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz"
|
||||
@@ -7523,6 +7561,11 @@ arg@^4.1.0:
|
||||
resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz"
|
||||
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
|
||||
|
||||
arg@^5.0.2:
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
||||
integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
|
||||
|
||||
argparse@^1.0.7:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz"
|
||||
@@ -8514,6 +8557,11 @@ camel-case@^4.1.2:
|
||||
pascal-case "^3.1.2"
|
||||
tslib "^2.0.3"
|
||||
|
||||
camelcase-css@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
|
||||
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
|
||||
|
||||
camelcase-keys@^6.2.2:
|
||||
version "6.2.2"
|
||||
resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz"
|
||||
@@ -8652,7 +8700,7 @@ chartjs-plugin-annotation@^1.4.0:
|
||||
resolved "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-1.4.0.tgz"
|
||||
integrity sha512-OC0eGoVvdxTtGGi8mV3Dr+G1YmMhtYYQWqGMb2uWcgcnyiBslaRKPofKwAYWPbh7ABnmQNsNDQLIKPH+XiaZLA==
|
||||
|
||||
chokidar@^3.4.2, chokidar@^3.5.3:
|
||||
chokidar@^3.4.2, chokidar@^3.5.3, chokidar@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
|
||||
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
|
||||
@@ -8922,6 +8970,11 @@ commander@^14.0.1:
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.2.tgz#b71fd37fe4069e4c3c7c13925252ada4eba14e8e"
|
||||
integrity sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==
|
||||
|
||||
commander@^4.0.0:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
||||
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
|
||||
|
||||
commander@^7.2.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
|
||||
@@ -9393,12 +9446,7 @@ d3-geo@3.1.0:
|
||||
dependencies:
|
||||
d3-array "2.5.0 - 3"
|
||||
|
||||
d3-hierarchy@3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6"
|
||||
integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==
|
||||
|
||||
d3-hierarchy@^1.1.4:
|
||||
d3-hierarchy@1.1.9, d3-hierarchy@^1.1.4:
|
||||
version "1.1.9"
|
||||
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
|
||||
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
|
||||
@@ -9781,6 +9829,11 @@ devlop@^1.0.0:
|
||||
dependencies:
|
||||
dequal "^2.0.0"
|
||||
|
||||
didyoumean@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
|
||||
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
|
||||
|
||||
diff-sequences@^29.4.3:
|
||||
version "29.4.3"
|
||||
resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz"
|
||||
@@ -9808,6 +9861,11 @@ direction@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/direction/-/direction-2.0.1.tgz#71800dd3c4fa102406502905d3866e65bdebb985"
|
||||
integrity sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==
|
||||
|
||||
dlv@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
|
||||
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
|
||||
|
||||
dnd-core@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"
|
||||
@@ -10804,7 +10862,7 @@ fast-diff@^1.1.2:
|
||||
resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz"
|
||||
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
|
||||
|
||||
fast-glob@^3.2.11, fast-glob@^3.2.7:
|
||||
fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.3.2:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
|
||||
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
|
||||
@@ -11339,6 +11397,13 @@ glob-parent@^5.1.2, glob-parent@~5.1.2:
|
||||
dependencies:
|
||||
is-glob "^4.0.1"
|
||||
|
||||
glob-parent@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
|
||||
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
|
||||
dependencies:
|
||||
is-glob "^4.0.3"
|
||||
|
||||
glob@^10.3.10:
|
||||
version "10.5.0"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
|
||||
@@ -11351,6 +11416,15 @@ glob@^10.3.10:
|
||||
package-json-from-dist "^1.0.0"
|
||||
path-scurry "^1.11.1"
|
||||
|
||||
glob@^13.0.6:
|
||||
version "13.0.6"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d"
|
||||
integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==
|
||||
dependencies:
|
||||
minimatch "^10.2.2"
|
||||
minipass "^7.1.3"
|
||||
path-scurry "^2.0.2"
|
||||
|
||||
glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz"
|
||||
@@ -13395,6 +13469,11 @@ jest@30.2.0:
|
||||
import-local "^3.2.0"
|
||||
jest-cli "30.2.0"
|
||||
|
||||
jiti@^1.21.7:
|
||||
version "1.21.7"
|
||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9"
|
||||
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
|
||||
|
||||
jiti@^2.6.1:
|
||||
version "2.6.1"
|
||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92"
|
||||
@@ -13779,6 +13858,11 @@ lilconfig@^2.0.5:
|
||||
resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz"
|
||||
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
|
||||
|
||||
lilconfig@^3.1.1, lilconfig@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
|
||||
integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
|
||||
|
||||
lines-and-columns@^1.1.6:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
|
||||
@@ -14036,6 +14120,11 @@ lru-cache@^10.2.0:
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
||||
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
||||
|
||||
lru-cache@^11.0.0:
|
||||
version "11.2.6"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.6.tgz#356bf8a29e88a7a2945507b31f6429a65a192c58"
|
||||
integrity sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==
|
||||
|
||||
lru-cache@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz"
|
||||
@@ -14921,6 +15010,13 @@ minimatch@3.1.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^10.2.2:
|
||||
version "10.2.4"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
|
||||
integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
|
||||
dependencies:
|
||||
brace-expansion "^5.0.2"
|
||||
|
||||
minimatch@^5.0.1:
|
||||
version "5.1.6"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
|
||||
@@ -14980,7 +15076,7 @@ minipass@^4.2.4:
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c"
|
||||
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
|
||||
|
||||
minipass@^7.1.2:
|
||||
minipass@^7.1.2, minipass@^7.1.3:
|
||||
version "7.1.3"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
|
||||
integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==
|
||||
@@ -15062,6 +15158,15 @@ mute-stream@0.0.8:
|
||||
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
|
||||
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
|
||||
|
||||
mz@^2.7.0:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
|
||||
integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
|
||||
dependencies:
|
||||
any-promise "^1.0.0"
|
||||
object-assign "^4.0.1"
|
||||
thenify-all "^1.0.0"
|
||||
|
||||
nano-css@^5.3.1:
|
||||
version "5.3.5"
|
||||
resolved "https://registry.npmjs.org/nano-css/-/nano-css-5.3.5.tgz"
|
||||
@@ -15343,11 +15448,16 @@ oas-validator@^5.0.8:
|
||||
should "^13.2.1"
|
||||
yaml "^1.10.0"
|
||||
|
||||
object-assign@^4.1.0, object-assign@^4.1.1:
|
||||
object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
||||
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
||||
|
||||
object-hash@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
|
||||
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
|
||||
|
||||
object-inspect@^1.12.2, object-inspect@^1.12.3, object-inspect@^1.9.0:
|
||||
version "1.12.3"
|
||||
resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz"
|
||||
@@ -15843,6 +15953,14 @@ path-scurry@^1.6.1:
|
||||
lru-cache "^9.1.1 || ^10.0.0"
|
||||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
|
||||
path-scurry@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85"
|
||||
integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==
|
||||
dependencies:
|
||||
lru-cache "^11.0.0"
|
||||
minipass "^7.1.2"
|
||||
|
||||
path-to-regexp@^1.7.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.9.0.tgz#5dc0753acbf8521ca2e0f137b4578b917b10cf24"
|
||||
@@ -15936,6 +16054,11 @@ pidtree@^0.5.0:
|
||||
resolved "https://registry.npmjs.org/pidtree/-/pidtree-0.5.0.tgz"
|
||||
integrity sha512-9nxspIM7OpZuhBxPg73Zvyq7j1QMPMPsGKTqRc2XOaFQauDvoNz9fM1Wdkjmeo7l9GXOZiRs97sPkuayl39wjA==
|
||||
|
||||
pify@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
|
||||
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
|
||||
|
||||
pify@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
|
||||
@@ -15946,16 +16069,16 @@ pify@^4.0.1:
|
||||
resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
|
||||
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
|
||||
|
||||
pirates@^4.0.1, pirates@^4.0.7:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22"
|
||||
integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==
|
||||
|
||||
pirates@^4.0.4:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz"
|
||||
integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==
|
||||
|
||||
pirates@^4.0.7:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22"
|
||||
integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==
|
||||
|
||||
pkg-dir@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz"
|
||||
@@ -16010,6 +16133,22 @@ possible-typed-array-names@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae"
|
||||
integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==
|
||||
|
||||
postcss-import@^15.1.0:
|
||||
version "15.1.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
|
||||
integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==
|
||||
dependencies:
|
||||
postcss-value-parser "^4.0.0"
|
||||
read-cache "^1.0.0"
|
||||
resolve "^1.1.7"
|
||||
|
||||
postcss-js@^4.0.1:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.1.0.tgz#003b63c6edde948766e40f3daf7e997ae43a5ce6"
|
||||
integrity sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==
|
||||
dependencies:
|
||||
camelcase-css "^2.0.1"
|
||||
|
||||
postcss-load-config@^3.1.4:
|
||||
version "3.1.4"
|
||||
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855"
|
||||
@@ -16018,6 +16157,13 @@ postcss-load-config@^3.1.4:
|
||||
lilconfig "^2.0.5"
|
||||
yaml "^1.10.2"
|
||||
|
||||
"postcss-load-config@^4.0.2 || ^5.0 || ^6.0":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096"
|
||||
integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==
|
||||
dependencies:
|
||||
lilconfig "^3.1.1"
|
||||
|
||||
postcss-modules-extract-imports@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d"
|
||||
@@ -16039,6 +16185,21 @@ postcss-modules-scope@^3.1.1:
|
||||
dependencies:
|
||||
postcss-selector-parser "^7.0.0"
|
||||
|
||||
postcss-nested@^6.2.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131"
|
||||
integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==
|
||||
dependencies:
|
||||
postcss-selector-parser "^6.1.1"
|
||||
|
||||
postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2:
|
||||
version "6.1.2"
|
||||
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de"
|
||||
integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
|
||||
dependencies:
|
||||
cssesc "^3.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
postcss-selector-parser@^7.0.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz#4d6af97eba65d73bc4d84bcb343e865d7dd16262"
|
||||
@@ -16047,7 +16208,7 @@ postcss-selector-parser@^7.0.0:
|
||||
cssesc "^3.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
|
||||
postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
|
||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||
@@ -16070,6 +16231,15 @@ postcss@^8.0.0:
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.2.0"
|
||||
|
||||
postcss@^8.4.47:
|
||||
version "8.5.8"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.8.tgz#6230ecc8fb02e7a0f6982e53990937857e13f399"
|
||||
integrity sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==
|
||||
dependencies:
|
||||
nanoid "^3.3.11"
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
posthog-js@1.298.0:
|
||||
version "1.298.0"
|
||||
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.298.0.tgz#54730988a753220aef54af0c4e69960633917450"
|
||||
@@ -17100,6 +17270,13 @@ react@18.2.0:
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
|
||||
read-cache@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
|
||||
integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==
|
||||
dependencies:
|
||||
pify "^2.3.0"
|
||||
|
||||
read-pkg-up@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz"
|
||||
@@ -17586,6 +17763,15 @@ resolve-protobuf-schema@^2.1.0:
|
||||
dependencies:
|
||||
protocol-buffers-schema "^3.3.1"
|
||||
|
||||
resolve@^1.1.7, resolve@^1.12.0, resolve@^1.22.11, resolve@^1.22.4, resolve@^1.22.8:
|
||||
version "1.22.11"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
|
||||
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
|
||||
dependencies:
|
||||
is-core-module "^2.16.1"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0:
|
||||
version "1.22.2"
|
||||
resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz"
|
||||
@@ -17595,15 +17781,6 @@ resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0:
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^1.12.0, resolve@^1.22.11, resolve@^1.22.4:
|
||||
version "1.22.11"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
|
||||
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
|
||||
dependencies:
|
||||
is-core-module "^2.16.1"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^2.0.0-next.5:
|
||||
version "2.0.0-next.5"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.5.tgz#6b0ec3107e671e52b68cd068ef327173b90dc03c"
|
||||
@@ -18626,7 +18803,7 @@ strtok3@^6.2.4:
|
||||
"@tokenizer/token" "^0.3.0"
|
||||
peek-readable "^4.1.0"
|
||||
|
||||
style-mod@^4.0.0, style-mod@^4.1.0:
|
||||
style-mod@^4.0.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67"
|
||||
integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==
|
||||
@@ -18680,6 +18857,19 @@ stylus@^0.62.0:
|
||||
sax "~1.3.0"
|
||||
source-map "^0.7.3"
|
||||
|
||||
sucrase@^3.35.0:
|
||||
version "3.35.1"
|
||||
resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1"
|
||||
integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==
|
||||
dependencies:
|
||||
"@jridgewell/gen-mapping" "^0.3.2"
|
||||
commander "^4.0.0"
|
||||
lines-and-columns "^1.1.6"
|
||||
mz "^2.7.0"
|
||||
pirates "^4.0.1"
|
||||
tinyglobby "^0.2.11"
|
||||
ts-interface-checker "^0.1.9"
|
||||
|
||||
supports-color@^5.3.0, supports-color@^5.5.0:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz"
|
||||
@@ -18802,6 +18992,34 @@ tailwindcss-animate@^1.0.7:
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
|
||||
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
|
||||
|
||||
tailwindcss@3:
|
||||
version "3.4.19"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.19.tgz#af2a0a4ae302d52ebe078b6775e799e132500ee2"
|
||||
integrity sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==
|
||||
dependencies:
|
||||
"@alloc/quick-lru" "^5.2.0"
|
||||
arg "^5.0.2"
|
||||
chokidar "^3.6.0"
|
||||
didyoumean "^1.2.2"
|
||||
dlv "^1.1.3"
|
||||
fast-glob "^3.3.2"
|
||||
glob-parent "^6.0.2"
|
||||
is-glob "^4.0.3"
|
||||
jiti "^1.21.7"
|
||||
lilconfig "^3.1.3"
|
||||
micromatch "^4.0.8"
|
||||
normalize-path "^3.0.0"
|
||||
object-hash "^3.0.0"
|
||||
picocolors "^1.1.1"
|
||||
postcss "^8.4.47"
|
||||
postcss-import "^15.1.0"
|
||||
postcss-js "^4.0.1"
|
||||
postcss-load-config "^4.0.2 || ^5.0 || ^6.0"
|
||||
postcss-nested "^6.2.0"
|
||||
postcss-selector-parser "^6.1.2"
|
||||
resolve "^1.22.8"
|
||||
sucrase "^3.35.0"
|
||||
|
||||
tapable@^2.2.1:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6"
|
||||
@@ -18836,6 +19054,20 @@ text-table@^0.2.0:
|
||||
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
|
||||
|
||||
thenify-all@^1.0.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
|
||||
integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
|
||||
dependencies:
|
||||
thenify ">= 3.1.0 < 4"
|
||||
|
||||
"thenify@>= 3.1.0 < 4":
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
|
||||
integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
|
||||
dependencies:
|
||||
any-promise "^1.0.0"
|
||||
|
||||
throttle-debounce@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz"
|
||||
@@ -18888,7 +19120,7 @@ tinyexec@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251"
|
||||
integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==
|
||||
|
||||
tinyglobby@^0.2.15:
|
||||
tinyglobby@^0.2.11, tinyglobby@^0.2.15:
|
||||
version "0.2.15"
|
||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
|
||||
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
|
||||
@@ -18990,6 +19222,11 @@ ts-easing@^0.2.0:
|
||||
resolved "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz"
|
||||
integrity sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==
|
||||
|
||||
ts-interface-checker@^0.1.9:
|
||||
version "0.1.13"
|
||||
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
|
||||
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
|
||||
|
||||
ts-jest@29.4.6:
|
||||
version "29.4.6"
|
||||
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.6.tgz#51cb7c133f227396818b71297ad7409bb77106e9"
|
||||
|
||||
@@ -75,6 +75,9 @@ def clickhouse(
|
||||
</cluster>
|
||||
</remote_servers>
|
||||
|
||||
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
|
||||
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
|
||||
|
||||
<distributed_ddl>
|
||||
<path>/clickhouse/task_queue/ddl</path>
|
||||
<profile>default</profile>
|
||||
@@ -117,17 +120,73 @@ def clickhouse(
|
||||
</clickhouse>
|
||||
"""
|
||||
|
||||
custom_function_config = """
|
||||
<functions>
|
||||
<function>
|
||||
<type>executable</type>
|
||||
<name>histogramQuantile</name>
|
||||
<return_type>Float64</return_type>
|
||||
<argument>
|
||||
<type>Array(Float64)</type>
|
||||
<name>buckets</name>
|
||||
</argument>
|
||||
<argument>
|
||||
<type>Array(Float64)</type>
|
||||
<name>counts</name>
|
||||
</argument>
|
||||
<argument>
|
||||
<type>Float64</type>
|
||||
<name>quantile</name>
|
||||
</argument>
|
||||
<format>CSV</format>
|
||||
<command>./histogramQuantile</command>
|
||||
</function>
|
||||
</functions>
|
||||
"""
|
||||
|
||||
tmp_dir = tmpfs("clickhouse")
|
||||
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
|
||||
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(cluster_config)
|
||||
|
||||
custom_function_file_path = os.path.join(tmp_dir, "custom-function.xml")
|
||||
with open(custom_function_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(custom_function_config)
|
||||
|
||||
container.with_volume_mapping(
|
||||
cluster_config_file_path, "/etc/clickhouse-server/config.d/cluster.xml"
|
||||
)
|
||||
container.with_volume_mapping(
|
||||
custom_function_file_path,
|
||||
"/etc/clickhouse-server/custom-function.xml",
|
||||
)
|
||||
container.with_network(network)
|
||||
container.start()
|
||||
|
||||
# Download and install the histogramQuantile binary
|
||||
wrapped = container.get_wrapped_container()
|
||||
exit_code, output = wrapped.exec_run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
(
|
||||
'version="v0.0.1" && '
|
||||
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
|
||||
'node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && '
|
||||
"cd /tmp && "
|
||||
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
|
||||
"tar -xzf histogram-quantile.tar.gz && "
|
||||
"mkdir -p /var/lib/clickhouse/user_scripts && "
|
||||
"mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile && "
|
||||
"chmod +x /var/lib/clickhouse/user_scripts/histogramQuantile"
|
||||
),
|
||||
],
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to install histogramQuantile binary: {output.decode()}"
|
||||
)
|
||||
|
||||
connection = clickhouse_connect.get_client(
|
||||
user=container.username,
|
||||
password=container.password,
|
||||
|
||||
@@ -372,3 +372,153 @@ def test_histogram_count_no_param(
|
||||
values[1]["value"] == first_values[le]
|
||||
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
|
||||
assert values[-1]["value"] == last_values[le]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"space_agg, zeroth_value, first_value, last_value",
|
||||
[
|
||||
("p50", 500, 818.182, 550.725),
|
||||
("p75", 750, 3000, 826.087),
|
||||
("p90", 900, 6400, 991.304),
|
||||
("p95", 950, 8000, 4200),
|
||||
("p99", 990, 8000, 8000),
|
||||
],
|
||||
)
|
||||
def test_histogram_percentile_for_all_services(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[List[Metrics]], None],
|
||||
space_agg: str,
|
||||
zeroth_value: float,
|
||||
first_value: float,
|
||||
last_value: float,
|
||||
) -> None:
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = f"test_{space_agg}_bucket"
|
||||
|
||||
metrics = Metrics.load_from_file(
|
||||
FILE,
|
||||
base_time=now - timedelta(minutes=60),
|
||||
metric_name_override=metric_name,
|
||||
)
|
||||
insert_metrics(metrics)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_builder_query(
|
||||
"A",
|
||||
metric_name,
|
||||
"doesnotreallymatter",
|
||||
space_agg,
|
||||
)
|
||||
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
data = response.json()
|
||||
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
|
||||
assert len(result_values) == 60
|
||||
assert result_values[0]["value"] == zeroth_value
|
||||
assert result_values[1]["value"] == first_value
|
||||
assert result_values[-1]["value"] == last_value
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"space_agg, first_value, last_value",
|
||||
[
|
||||
("p50", 818.182, 550.725),
|
||||
("p75", 3000, 826.087),
|
||||
("p90", 6400, 991.304),
|
||||
("p95", 8000, 4200),
|
||||
("p99", 8000, 8000),
|
||||
],
|
||||
)
|
||||
def test_histogram_percentile_for_cumulative_service(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[List[Metrics]], None],
|
||||
space_agg: str,
|
||||
first_value: float,
|
||||
last_value: float,
|
||||
) -> None:
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = f"test_{space_agg}_cumulative_bucket"
|
||||
|
||||
metrics = Metrics.load_from_file(
|
||||
FILE,
|
||||
base_time=now - timedelta(minutes=60),
|
||||
metric_name_override=metric_name,
|
||||
)
|
||||
insert_metrics(metrics)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_builder_query(
|
||||
"A",
|
||||
metric_name,
|
||||
"doesnotreallymatter",
|
||||
space_agg,
|
||||
filter_expression='service = "api"',
|
||||
)
|
||||
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
data = response.json()
|
||||
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
|
||||
assert len(result_values) == 59
|
||||
assert result_values[0]["value"] == first_value
|
||||
assert result_values[-1]["value"] == last_value
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"space_agg, zeroth_value, first_value, last_value",
|
||||
[
|
||||
("p50", 500, 818.182, 550.725),
|
||||
("p75", 750, 3000, 826.087),
|
||||
("p90", 900, 6400, 991.304),
|
||||
("p95", 950, 8000, 4200),
|
||||
("p99", 990, 8000, 8000),
|
||||
],
|
||||
)
|
||||
def test_histogram_percentile_for_delta_service(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[List[Metrics]], None],
|
||||
space_agg: str,
|
||||
zeroth_value: float,
|
||||
first_value: float,
|
||||
last_value: float,
|
||||
) -> None:
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = f"test_{space_agg}_bucket"
|
||||
|
||||
metrics = Metrics.load_from_file(
|
||||
FILE,
|
||||
base_time=now - timedelta(minutes=60),
|
||||
metric_name_override=metric_name,
|
||||
)
|
||||
insert_metrics(metrics)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_builder_query(
|
||||
"A",
|
||||
metric_name,
|
||||
"doesnotreallymatter",
|
||||
space_agg,
|
||||
filter_expression='service = "web"',
|
||||
)
|
||||
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
data = response.json()
|
||||
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
|
||||
assert len(result_values) == 60
|
||||
assert result_values[0]["value"] == zeroth_value
|
||||
assert result_values[1]["value"] == first_value
|
||||
assert result_values[-1]["value"] == last_value
|
||||
Reference in New Issue
Block a user