Compare commits

..

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
82e64bad90 fix(dashboards): variables can be undefined when create new dashboard 2026-04-30 13:21:40 -03:00
103 changed files with 4626 additions and 23772 deletions

View File

@@ -77,10 +77,6 @@ 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,17 +102,3 @@ jobs:
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
- name: node-install
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 && pnpm install
- name: generate-api-clients
run: |
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)

View File

@@ -25,10 +25,6 @@ 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

View File

@@ -41,10 +41,6 @@ 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

View File

@@ -52,16 +52,16 @@ jobs:
with:
PRIMUS_REF: main
JS_SRC: frontend
md-languages:
languages:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: checkout
- name: self-checkout
uses: actions/checkout@v4
- name: validate md languages
- name: run
run: bash frontend/scripts/validate-md-languages.sh
authz:
if: |
@@ -70,49 +70,55 @@ jobs:
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: Checkout code
- name: self-checkout
uses: actions/checkout@v5
- name: Set up Node.js
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install frontend dependencies
- name: deps-install
working-directory: ./frontend
run: |
pnpm install
- name: Install uv
yarn install
- name: uv-install
uses: astral-sh/setup-uv@v5
- name: Install Python dependencies
- name: uv-deps
working-directory: ./tests/integration
run: |
uv sync
- name: Start test environment
- name: setup-test
run: |
make py-test-setup
- name: Generate permissions.type.ts
- name: generate
working-directory: ./frontend
run: |
pnpm generate:permissions-type
- name: Teardown test environment
yarn generate:permissions-type
- name: teardown-test
if: always()
run: |
make py-test-teardown
- name: Check for changes
- name: validate
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: pnpm 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: npm run generate:permissions-type (from the frontend directory)"
exit 1
fi
openapi:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-frontend
run: cd frontend && yarn 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)

View File

@@ -1,21 +1,19 @@
# 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
pnpm install
command: pnpm dev
yarn install
command:
yarn dev
ports:
- port: 8080

View File

@@ -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 pnpm install && pnpm build
@cd $(JS_BUILD_CONTEXT) && CI=1 yarn install && yarn build
##############################################################
# docker commands

View File

@@ -3,9 +3,8 @@ FROM node:22-bookworm AS build
WORKDIR /opt/
COPY ./frontend/ ./
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 npm i -g pnpm
RUN CI=1 pnpm install
RUN CI=1 pnpm build
RUN CI=1 yarn install
RUN CI=1 yarn build
FROM golang:1.25-bookworm

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.121.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.121.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -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.121.1}
image: signoz/signoz:${VERSION:-v0.121.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -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.121.1}
image: signoz/signoz:${VERSION:-v0.121.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -17273,9 +17273,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- ADMIN
- tokenizer:
- VIEWER
- ADMIN
summary: Get host info from Zeus.
tags:
- zeus

View File

@@ -13,12 +13,13 @@ 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
- **Pnpm** - Our frontend package manager
- Follow the [installation guide](https://pnpm.io/installation)
- **Yarn** - Our frontend package manager
- Follow the [installation guide](https://yarnpkg.com/getting-started/install)
- **Docker** - For running Clickhouse and Postgres locally
- Get it from [docs.docker.com/get-docker](https://docs.docker.com/get-docker/)
@@ -94,7 +95,7 @@ This command:
2. Install dependencies:
```bash
pnpm install
yarn install
```
3. Create a `.env` file in this directory:
@@ -104,10 +105,10 @@ This command:
4. Start the development server:
```bash
pnpm dev
yarn dev
```
> 💡 **Tip**: `pnpm dev` will automatically rebuild when you make changes to the code
> 💡 **Tip**: `yarn dev` will automatically rebuild when you make changes to the code
Now you're all set to start developing! Happy coding! 🎉

View File

@@ -7,98 +7,96 @@ This guide explains how to add new data sources to the SigNoz onboarding flow. T
The configuration is located at:
```
frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json
frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts
```
## JSON Structure Overview
## Structure Overview
The configuration file is a JSON array containing data source objects. Each object represents a selectable option in the onboarding flow.
The configuration file exports a TypeScript array (`onboardingConfigWithLinks`) containing data source objects. Each object represents a selectable option in the onboarding flow. SVG logos are imported as ES modules at the top of the file.
## Data Source Object Keys
### 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` | Imported SVG URL **(SVG required)** (e.g., `import ec2Url from '@/assets/Logos/ec2.svg'`, then use `ec2Url`) |
### 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
The `question` object enables multi-step selection flows:
```json
{
"question": {
"desc": "What would you like to monitor?",
"type": "select",
"helpText": "Choose the telemetry type you want to collect.",
"helpLink": "/docs/azure-monitoring/overview/",
"helpLinkText": "Read the guide →",
"options": [
{
"key": "logging",
"label": "Logs",
"imgUrl": "/Logos/azure-vm.svg",
"link": "/docs/azure-monitoring/app-service/logging/"
},
{
"key": "metrics",
"label": "Metrics",
"imgUrl": "/Logos/azure-vm.svg",
"link": "/docs/azure-monitoring/app-service/metrics/"
},
{
"key": "tracing",
"label": "Traces",
"imgUrl": "/Logos/azure-vm.svg",
"link": "/docs/azure-monitoring/app-service/tracing/"
}
]
}
}
```ts
question: {
desc: 'What would you like to monitor?',
type: 'select',
helpText: 'Choose the telemetry type you want to collect.',
helpLink: '/docs/azure-monitoring/overview/',
helpLinkText: 'Read the guide →',
options: [
{
key: 'logging',
label: 'Logs',
imgUrl: azureVmUrl,
link: '/docs/azure-monitoring/app-service/logging/',
},
{
key: 'metrics',
label: 'Metrics',
imgUrl: azureVmUrl,
link: '/docs/azure-monitoring/app-service/metrics/',
},
{
key: 'tracing',
label: 'Traces',
imgUrl: azureVmUrl,
link: '/docs/azure-monitoring/app-service/tracing/',
},
],
},
```
### 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
@@ -106,152 +104,161 @@ Options can be simple (direct link) or nested (with another question):
### Simple Option (Direct Link)
```json
```ts
{
"key": "aws-ec2-logs",
"label": "Logs",
"imgUrl": "/Logos/ec2.svg",
"link": "/docs/userguide/collect_logs_from_file/"
}
key: 'aws-ec2-logs',
label: 'Logs',
imgUrl: ec2Url,
link: '/docs/userguide/collect_logs_from_file/',
},
```
### Option with Internal Redirect
```json
```ts
{
"key": "aws-ec2-metrics-one-click",
"label": "One Click AWS",
"imgUrl": "/Logos/ec2.svg",
"link": "/integrations?integration=aws-integration&service=ec2",
"internalRedirect": true
}
key: 'aws-ec2-metrics-one-click',
label: 'One Click AWS',
imgUrl: ec2Url,
link: '/integrations?integration=aws-integration&service=ec2',
internalRedirect: true,
},
```
> **Important**: Set `internalRedirect: true` only for internal app routes (like `/integrations?...`). Docs links should NOT have this flag.
### Nested Option (Multi-step Flow)
```json
```ts
{
"key": "aws-ec2-metrics",
"label": "Metrics",
"imgUrl": "/Logos/ec2.svg",
"question": {
"desc": "How would you like to set up monitoring?",
"helpText": "Choose your setup method.",
"options": [...]
}
}
key: 'aws-ec2-metrics',
label: 'Metrics',
imgUrl: ec2Url,
question: {
desc: 'How would you like to set up monitoring?',
helpText: 'Choose your setup method.',
options: [...],
},
},
```
## Examples
### Simple Data Source (Direct Link)
```json
```ts
import elbUrl from '@/assets/Logos/elb.svg';
// inside the onboardingConfigWithLinks array:
{
"dataSource": "aws-elb",
"label": "AWS ELB",
"tags": ["AWS"],
"module": "logs",
"relatedSearchKeywords": [
"aws",
"aws elb",
"elb logs",
"elastic load balancer"
dataSource: 'aws-elb',
label: 'AWS ELB',
tags: ['AWS'],
module: 'logs',
relatedSearchKeywords: [
'aws',
'aws elb',
'elb logs',
'elastic load balancer',
],
"imgUrl": "/Logos/elb.svg",
"link": "/docs/aws-monitoring/elb/"
}
imgUrl: elbUrl,
link: '/docs/aws-monitoring/elb/',
},
```
### Data Source with Single Question Level
```json
```ts
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
// inside the onboardingConfigWithLinks array:
{
"dataSource": "app-service",
"label": "App Service",
"imgUrl": "/Logos/azure-vm.svg",
"tags": ["Azure"],
"module": "apm",
"relatedSearchKeywords": ["azure", "app service"],
"question": {
"desc": "What telemetry data do you want to visualise?",
"type": "select",
"options": [
dataSource: 'app-service',
label: 'App Service',
imgUrl: azureVmUrl,
tags: ['Azure'],
module: 'apm',
relatedSearchKeywords: ['azure', 'app service'],
question: {
desc: 'What telemetry data do you want to visualise?',
type: 'select',
options: [
{
"key": "logging",
"label": "Logs",
"imgUrl": "/Logos/azure-vm.svg",
"link": "/docs/azure-monitoring/app-service/logging/"
key: 'logging',
label: 'Logs',
imgUrl: azureVmUrl,
link: '/docs/azure-monitoring/app-service/logging/',
},
{
"key": "metrics",
"label": "Metrics",
"imgUrl": "/Logos/azure-vm.svg",
"link": "/docs/azure-monitoring/app-service/metrics/"
key: 'metrics',
label: 'Metrics',
imgUrl: azureVmUrl,
link: '/docs/azure-monitoring/app-service/metrics/',
},
{
"key": "tracing",
"label": "Traces",
"imgUrl": "/Logos/azure-vm.svg",
"link": "/docs/azure-monitoring/app-service/tracing/"
}
]
}
}
key: 'tracing',
label: 'Traces',
imgUrl: azureVmUrl,
link: '/docs/azure-monitoring/app-service/tracing/',
},
],
},
},
```
### Data Source with Nested Questions (2-3 Levels)
```json
```ts
import ec2Url from '@/assets/Logos/ec2.svg';
// inside the onboardingConfigWithLinks array:
{
"dataSource": "aws-ec2",
"label": "AWS EC2",
"tags": ["AWS"],
"module": "logs",
"relatedSearchKeywords": ["aws", "aws ec2", "ec2 logs", "ec2 metrics"],
"imgUrl": "/Logos/ec2.svg",
"question": {
"desc": "What would you like to monitor for AWS EC2?",
"type": "select",
"helpText": "Choose the type of telemetry data you want to collect.",
"options": [
dataSource: 'aws-ec2',
label: 'AWS EC2',
tags: ['AWS'],
module: 'logs',
relatedSearchKeywords: ['aws', 'aws ec2', 'ec2 logs', 'ec2 metrics'],
imgUrl: ec2Url,
question: {
desc: 'What would you like to monitor for AWS EC2?',
type: 'select',
helpText: 'Choose the type of telemetry data you want to collect.',
options: [
{
"key": "aws-ec2-logs",
"label": "Logs",
"imgUrl": "/Logos/ec2.svg",
"link": "/docs/userguide/collect_logs_from_file/"
key: 'aws-ec2-logs',
label: 'Logs',
imgUrl: ec2Url,
link: '/docs/userguide/collect_logs_from_file/',
},
{
"key": "aws-ec2-metrics",
"label": "Metrics",
"imgUrl": "/Logos/ec2.svg",
"question": {
"desc": "How would you like to set up EC2 Metrics monitoring?",
"helpText": "One Click uses AWS CloudWatch integration. Manual setup uses OpenTelemetry.",
"helpLink": "/docs/aws-monitoring/one-click-vs-manual/",
"helpLinkText": "Read the comparison guide →",
"options": [
key: 'aws-ec2-metrics',
label: 'Metrics',
imgUrl: ec2Url,
question: {
desc: 'How would you like to set up EC2 Metrics monitoring?',
helpText: 'One Click uses AWS CloudWatch integration. Manual setup uses OpenTelemetry.',
helpLink: '/docs/aws-monitoring/one-click-vs-manual/',
helpLinkText: 'Read the comparison guide →',
options: [
{
"key": "aws-ec2-metrics-one-click",
"label": "One Click AWS",
"imgUrl": "/Logos/ec2.svg",
"link": "/integrations?integration=aws-integration&service=ec2",
"internalRedirect": true
key: 'aws-ec2-metrics-one-click',
label: 'One Click AWS',
imgUrl: ec2Url,
link: '/integrations?integration=aws-integration&service=ec2',
internalRedirect: true,
},
{
"key": "aws-ec2-metrics-manual",
"label": "Manual Setup",
"imgUrl": "/Logos/ec2.svg",
"link": "/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/"
}
]
}
}
]
}
}
key: 'aws-ec2-metrics-manual',
label: 'Manual Setup',
imgUrl: ec2Url,
link: '/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/',
},
],
},
},
],
},
},
```
## Best Practices
@@ -270,10 +277,16 @@ Options can be simple (direct link) or nested (with another question):
### 3. Logos
- Place logo files in `public/Logos/`
- Place logo files in `src/assets/Logos/`
- Use SVG format
- Reference as `"/Logos/your-logo.svg"`
- **Optimize new SVGs**: Run any newly downloaded SVGs through an optimizer like [SVGOMG (svgo)](https://svgomg.net/) or use `npx svgo public/Logos/your-logo.svg` to minimise their size before committing.
- Import the SVG at the top of the file and reference the imported variable:
```ts
import myServiceUrl from '@/assets/Logos/my-service.svg';
// then in the config object:
imgUrl: myServiceUrl,
```
- **Fetching Icons**: New icons can be easily fetched from [OpenBrand](https://openbrand.sh/). Use the pattern `https://openbrand.sh/?url=<TARGET_URL>`, where `<TARGET_URL>` is the URL-encoded link to the service's website. For example, to get Render's logo, use [https://openbrand.sh/?url=https%3A%2F%2Frender.com](https://openbrand.sh/?url=https%3A%2F%2Frender.com).
- **Optimize new SVGs**: Run any newly downloaded SVGs through an optimizer like [SVGOMG (svgo)](https://svgomg.net/) or use `npx svgo src/assets/Logos/your-logo.svg` to minimise their size before committing.
### 4. Links
@@ -289,9 +302,9 @@ Options can be simple (direct link) or nested (with another question):
## Adding a New Data Source
1. Add your data source object to the JSON array
2. Ensure the logo exists in `public/Logos/`
3. Test the flow locally with `pnpm dev`
1. Add the logo SVG to `src/assets/Logos/` and add a top-level import in the config file (e.g., `import myServiceUrl from '@/assets/Logos/my-service.svg'`)
2. Add your data source object to the `onboardingConfigWithLinks` array, referencing the imported variable for `imgUrl`
3. Test the flow locally with `yarn 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

View File

@@ -1,7 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd frontend && pnpm run commitlint --edit $1
cd frontend && yarn run commitlint --edit $1
branch="$(git rev-parse --abbrev-ref HEAD)"

View File

@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd frontend && pnpm lint-staged
cd frontend && yarn lint-staged

View File

@@ -1,4 +1 @@
registry = 'https://registry.npmjs.org/'
public-hoist-pattern[]=@commitlint*
public-hoist-pattern[]=commitlint
registry = 'https://registry.npmjs.org/'

View File

@@ -12,7 +12,7 @@
or
`docker build . -t tagname`
**Tag to remote url- Introduce versinoing later on**
**Tag to remote url- Introduce versioning later on**
```
docker tag signoz/frontend:latest 7296823551/signoz:latest
@@ -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. ```pnpm install```
1. ```pnpm dev```
1. ```yarn install```
1. ```yarn 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:
### `pnpm start`
### `yarn 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.
### `pnpm test`
### `yarn 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.
### `pnpm build`
### `yarn 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.
### `pnpm eject`
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant 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)
### `pnpm build` fails to minify
### `yarn 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)

View File

@@ -6,7 +6,6 @@
* 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';

View File

@@ -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 'pnpm generate:api'`,
`* regenerate with 'yarn generate:api'`,
...(info.title ? [info.title] : []),
...(info.version ? [`OpenAPI spec version: ${info.version}`] : []),
],

View File

@@ -18,7 +18,7 @@
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
"postinstall": "pnpm i18n:generate-hash && (is-ci || pnpm husky:configure) && node scripts/update-registry.cjs",
"postinstall": "yarn i18n:generate-hash && (is-ci || yarn husky:configure) && node scripts/update-registry.cjs",
"husky:configure": "cd .. && husky install frontend/.husky && cd frontend && chmod ug+x .husky/*",
"commitlint": "commitlint --edit $1",
"test": "jest",
@@ -36,8 +36,6 @@
"@ant-design/icons": "4.8.0",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/lang-javascript": "6.2.3",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.6",
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
@@ -108,7 +106,6 @@
"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",
@@ -170,8 +167,8 @@
"@babel/preset-env": "^7.22.14",
"@babel/preset-react": "^7.12.13",
"@babel/preset-typescript": "^7.21.4",
"@commitlint/cli": "20.4.4",
"@commitlint/config-conventional": "20.4.4",
"@commitlint/cli": "^20.4.2",
"@commitlint/config-conventional": "^20.4.2",
"@faker-js/faker": "9.3.0",
"@jest/globals": "30.2.0",
"@testing-library/jest-dom": "5.16.5",
@@ -181,12 +178,8 @@
"@types/crypto-js": "4.2.2",
"@types/dompurify": "^2.4.0",
"@types/event-source-polyfill": "^1.0.0",
"@types/d3-hierarchy": "1.1.11",
"@types/fontfaceobserver": "2.1.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@jest/types": "30.2.0",
"@types/lodash": "4.17.0",
"@types/lodash-es": "^4.17.4",
"@types/mini-css-extract-plugin": "^2.5.1",
"@types/node": "^16.10.3",
@@ -204,13 +197,10 @@
"@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/native-preview": "7.0.0-dev.20260430.1",
"autoprefixer": "10.4.19",
"babel-plugin-styled-components": "^1.12.0",
"eslint-plugin-sonarjs": "4.0.2",
"glob": "^13.0.6",
"husky": "^7.0.4",
"imagemin": "^8.0.1",
"imagemin-svgo": "^10.0.1",
@@ -241,6 +231,7 @@
"ts-jest": "29.4.6",
"ts-node": "^10.2.1",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",
"vite-plugin-checker": "0.12.0",
"vite-plugin-compression": "0.5.1",
"vite-plugin-image-optimizer": "2.0.3",
@@ -276,4 +267,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

22835
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -180,7 +180,7 @@ async function main() {
PERMISSIONS_TYPE_FILE,
);
log('Linting generated file...');
execSync(`cd frontend && pnpm oxlint ${relativePath}`, {
execSync(`cd frontend && yarn oxlint ${relativePath}`, {
cwd: rootDir,
stdio: 'inherit',
});

View File

@@ -16,7 +16,7 @@ echo "\n✅ Tag files renamed to index.ts"
# Format generated files
echo "\n\n---\nRunning prettier...\n"
if ! pnpm prettify src/api/generated; then
if ! yarn prettify src/api/generated; then
echo "Formatting failed!"
exit 1
fi
@@ -25,7 +25,7 @@ echo "\n✅ Formatting successful"
# Fix linting issues
echo "\n\n---\nRunning lint...\n"
if ! pnpm lint:generated; then
if ! yarn lint:generated; then
echo "Lint check failed! Please fix linting errors before proceeding."
exit 1
fi

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
export interface AlertmanagertypesChannelDTO {

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -1,7 +1,7 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';

View File

@@ -14,7 +14,6 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
import get from 'lodash/get';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -115,7 +114,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
}, []);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
return widget.query.builder.queryData[0].groupBy;
}, [widget.query]);
return (

View File

@@ -10,7 +10,6 @@ import { ContextMenu } from 'periscope/components/ContextMenu';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import get from 'lodash/get';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
@@ -106,7 +105,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
]);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
return widget.query.builder.queryData[0].groupBy;
}, [widget.query]);
return (

View File

@@ -37,10 +37,7 @@ import {
X,
} from 'lucide-react';
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -190,16 +187,19 @@ function K8sBaseDetails<T>({
);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const { startMs, endMs } = useMemo(() => {
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
return {
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
};
}, [getMinMaxTime, selectedTime]);
const { startMs, endMs } = useMemo(
() => ({
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
}),
[lastComputedMinMax],
);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[queryKeyPrefix, selectedItem, selectedTime],
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
);
const {

View File

@@ -16,10 +16,7 @@ import { InfraMonitoringEvents } from 'constants/events';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
@@ -114,6 +111,9 @@ export function K8sBaseList<T>({
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
@@ -127,6 +127,7 @@ export function K8sBaseList<T>({
JSON.stringify(groupBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
pageSize,

View File

@@ -11,10 +11,7 @@ import {
} from 'antd';
import { CornerDownRight } from 'lucide-react';
import { useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
@@ -118,6 +115,9 @@ export function K8sExpandedRow<T>({
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(selectedTime, [
@@ -126,7 +126,7 @@ export function K8sExpandedRow<T>({
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
]);
}, [selectedTime, record.key, queryFilters, orderBy]);
}, [getAutoRefreshQueryKey, selectedTime, record.key, queryFilters, orderBy]);
const { data, isFetching, isLoading, isError } = useQuery({
queryKey,

View File

@@ -240,7 +240,7 @@ function DashboardsList(): JSX.Element {
isLocked: !!e.locked || false,
lastUpdatedBy: e.updatedBy,
image: e.data.image || Base64Icons[0],
variables: e.data.variables,
variables: e.data.variables ?? {},
widgets: e.data.widgets,
layout: e.data.layout,
panelMap: e.data.panelMap,

View File

@@ -35,7 +35,7 @@ import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import { getAbsoluteUrl } from 'utils/basePath';
import { getAbsoluteUrl } from '@/utils/basePath';
import { LiveLogsListProps } from './types';

View File

@@ -26,13 +26,14 @@ function JSONView({ logData }: JSONViewProps): JSX.Element {
minimap: {
enabled: false,
},
fontWeight: '400',
fontWeight: 400,
// fontFamily: 'SF Mono',
fontFamily: 'Geist Mono',
fontSize: 13,
lineHeight: 18,
lineHeight: '18px',
colorDecorators: true,
scrollBeyondLastLine: false,
decorationsOverviewRuler: false,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',

View File

@@ -49,14 +49,15 @@ function Overview({
const options: EditorProps['options'] = {
automaticLayout: true,
readOnly: true,
height: '40vh',
wordWrap: isWrapWord ? 'on' : 'off',
minimap: {
enabled: false,
},
fontWeight: '400',
fontWeight: 400,
fontFamily: 'Geist Mono',
fontSize: 13,
lineHeight: 18,
lineHeight: '18px',
colorDecorators: true,
scrollBeyondLastLine: false,
scrollbar: {

View File

@@ -38,7 +38,7 @@ import APIError from 'types/api/error';
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import { getAbsoluteUrl } from '@/utils/basePath';
import NoLogs from '../NoLogs/NoLogs';
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';

View File

@@ -67,40 +67,4 @@ describe('AuthCard', () => {
expect(mockOnCreateServiceAccount).toHaveBeenCalledTimes(1);
});
it('shows URL for non-admin (all roles can fetch instance URL)', () => {
render(<AuthCard {...defaultProps} isAdmin={false} />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
});
describe('isLoadingInstanceUrl', () => {
it('shows a skeleton and hides the URL while loading', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
});
it('does not render the copy button while loading', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
expect(
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
).not.toBeInTheDocument();
});
it('shows the URL and copy button once loading is done', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl={false} />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
expect(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
).toBeInTheDocument();
});
});
});

View File

@@ -1,4 +1,3 @@
import { Skeleton } from 'antd';
import { Badge, Button } from '@signozhq/ui';
import { Info, KeyRound } from '@signozhq/icons';
import CopyIconButton from '../CopyIconButton';
@@ -8,7 +7,6 @@ import './AuthCard.styles.scss';
interface AuthCardProps {
isAdmin: boolean;
instanceUrl: string;
isLoadingInstanceUrl?: boolean;
onCopyInstanceUrl: () => void;
onCreateServiceAccount: () => void;
}
@@ -16,7 +14,6 @@ interface AuthCardProps {
function AuthCard({
isAdmin,
instanceUrl,
isLoadingInstanceUrl = false,
onCopyInstanceUrl,
onCreateServiceAccount,
}: AuthCardProps): JSX.Element {
@@ -35,18 +32,13 @@ function AuthCard({
<div className="mcp-auth-card__field">
<span className="mcp-auth-card__field-label">SigNoz Instance URL</span>
{isLoadingInstanceUrl ? (
<Skeleton.Input active size="small" />
) : (
<div className="mcp-auth-card__endpoint-value">
<span data-testid="mcp-instance-url">{instanceUrl}</span>
<CopyIconButton
ariaLabel="Copy SigNoz instance URL"
onCopy={onCopyInstanceUrl}
disabled={isLoadingInstanceUrl}
/>
</div>
)}
<div className="mcp-auth-card__endpoint-value">
<span data-testid="mcp-instance-url">{instanceUrl}</span>
<CopyIconButton
ariaLabel="Copy SigNoz instance URL"
onCopy={onCopyInstanceUrl}
/>
</div>
</div>
<div className="mcp-auth-card__field">

View File

@@ -6,8 +6,6 @@ const mockLogEvent = jest.fn();
const mockCopyToClipboard = jest.fn();
const mockHistoryPush = jest.fn();
const mockUseGetGlobalConfig = jest.fn();
const mockUseGetHosts = jest.fn();
const mockUseGetTenantLicense = jest.fn();
const mockToastSuccess = jest.fn();
const mockToastWarning = jest.fn();
@@ -21,14 +19,6 @@ jest.mock('api/generated/services/global', () => ({
mockUseGetGlobalConfig(...args),
}));
jest.mock('api/generated/services/zeus', () => ({
useGetHosts: (...args: unknown[]): unknown => mockUseGetHosts(...args),
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: (): unknown => mockUseGetTenantLicense(),
}));
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
@@ -57,23 +47,6 @@ jest.mock('utils/basePath', () => ({
}));
const MCP_URL = 'https://mcp.us.signoz.cloud/mcp';
const CUSTOM_HOST_URL = 'https://myteam.signoz.cloud';
const DEFAULT_HOST_URL = 'https://default.signoz.cloud';
function setupLicense({
isCloudUser = true,
isEnterpriseSelfHostedUser = false,
}: {
isCloudUser?: boolean;
isEnterpriseSelfHostedUser?: boolean;
} = {}): void {
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
isEnterpriseSelfHostedUser,
isCommunityUser: !isCloudUser && !isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser: false,
});
}
function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
mockUseGetGlobalConfig.mockReturnValue({
@@ -82,29 +55,7 @@ function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
});
}
function setupHosts({
hosts = [],
isLoading = false,
isError = false,
}: {
hosts?: { name?: string; url?: string; is_default?: boolean }[];
isLoading?: boolean;
isError?: boolean;
} = {}): void {
mockUseGetHosts.mockReturnValue({
data: isLoading || isError ? undefined : { data: { hosts } },
isLoading,
isError,
});
}
describe('MCPServerSettings', () => {
beforeEach(() => {
// Default: cloud user, hosts loaded but empty → instanceUrl falls back to getBaseUrl()
setupLicense();
setupHosts();
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -207,145 +158,4 @@ describe('MCPServerSettings', () => {
'Instance URL copied to clipboard',
);
});
describe('instance URL resolution', () => {
it('uses the active custom host URL when available', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({
hosts: [
{ name: 'default', url: DEFAULT_HOST_URL, is_default: true },
{ name: 'myteam', url: CUSTOM_HOST_URL, is_default: false },
],
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
CUSTOM_HOST_URL,
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith(CUSTOM_HOST_URL);
});
it('falls back to the default host URL when no custom host exists', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
DEFAULT_HOST_URL,
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith(DEFAULT_HOST_URL);
});
it('falls back to browser URL when hosts request errors', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isError: true });
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
});
it('shows URL skeleton while hosts are loading', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isLoading: true });
render(<MCPServerSettings />);
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
});
it('does not copy while hosts are still loading', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isLoading: true });
userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
).not.toBeInTheDocument();
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('disables the hosts query for non-cloud deployments', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
expect(callOptions?.query?.enabled).toBe(false);
});
it('uses browser URL immediately for enterprise self-hosted (no skeleton)', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
setupHosts({ isLoading: false });
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
expect(
document.querySelector('.ant-skeleton-input'),
).not.toBeInTheDocument();
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
});
it('enables the hosts query for all cloud users including viewers', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: true });
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
expect(callOptions?.query?.enabled).toBe(true);
});
it('shows instance URL for cloud viewer', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: true });
setupHosts({
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
});
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
expect(
document.querySelector('.ant-skeleton-input'),
).not.toBeInTheDocument();
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
DEFAULT_HOST_URL,
);
});
});
});

View File

@@ -1,13 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { useGetGlobalConfig } from 'api/generated/services/global';
import { useGetHosts } from 'api/generated/services/zeus';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import { getBaseUrl } from 'utils/basePath';
@@ -36,23 +34,7 @@ function MCPServerSettings(): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const isAdmin = user.role === USER_ROLES.ADMIN;
const { isCloudUser } = useGetTenantLicense();
const {
data: hostsData,
isLoading: isLoadingHosts,
isError: isHostsError,
} = useGetHosts({ query: { enabled: isCloudUser } });
const instanceUrl = useMemo(() => {
if (isLoadingHosts || isHostsError || !hostsData) {
return getBaseUrl();
}
const hosts = hostsData.data?.hosts ?? [];
const activeHost =
hosts.find((h) => !h.is_default) ?? hosts.find((h) => h.is_default);
return activeHost?.url ?? getBaseUrl();
}, [hostsData, isLoadingHosts, isHostsError]);
const instanceUrl = getBaseUrl();
const { data: globalConfig, isLoading: isConfigLoading } =
useGetGlobalConfig();
@@ -88,13 +70,10 @@ function MCPServerSettings(): JSX.Element {
}, []);
const handleCopyInstanceUrl = useCallback(() => {
if (isLoadingHosts) {
return;
}
copyToClipboard(instanceUrl);
toast.success('Instance URL copied to clipboard');
void logEvent(ANALYTICS.INSTANCE_URL_COPIED, {});
}, [copyToClipboard, instanceUrl, isLoadingHosts]);
}, [copyToClipboard, instanceUrl]);
const handleDocsLinkClick = useCallback((target: string) => {
void logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target });
@@ -153,7 +132,6 @@ function MCPServerSettings(): JSX.Element {
<AuthCard
isAdmin={isAdmin}
instanceUrl={instanceUrl}
isLoadingInstanceUrl={isLoadingHosts}
onCopyInstanceUrl={handleCopyInstanceUrl}
onCreateServiceAccount={handleCreateServiceAccount}
/>

View File

@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
import {
useGlobalTimeQueryInvalidate,
useIsGlobalTimeQueryRefreshing,
} from 'hooks/globalTime';
} from 'store/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -101,14 +101,14 @@ function DateTimeSelection({
if (modalInitialStartTime !== undefined) {
initialModalStartTime = modalInitialStartTime;
} else if (searchStartTime) {
initialModalStartTime = parseInt(searchStartTime, 10);
initialModalStartTime = Number.parseInt(searchStartTime, 10);
}
let initialModalEndTime = 0;
if (modalInitialEndTime !== undefined) {
initialModalEndTime = modalInitialEndTime;
} else if (searchEndTime) {
initialModalEndTime = parseInt(searchEndTime, 10);
initialModalEndTime = Number.parseInt(searchEndTime, 10);
}
const [modalStartTime, setModalStartTime] = useState<number>(
@@ -159,9 +159,11 @@ function DateTimeSelection({
const getTime = useCallback((): [number, number] | undefined => {
if (searchEndTime && searchStartTime) {
const startDate = dayjs(
new Date(parseInt(getTimeString(searchStartTime), 10)),
new Date(Number.parseInt(getTimeString(searchStartTime), 10)),
);
const endDate = dayjs(
new Date(Number.parseInt(getTimeString(searchEndTime), 10)),
);
const endDate = dayjs(new Date(parseInt(getTimeString(searchEndTime), 10)));
return [startDate.toDate().getTime() || 0, endDate.toDate().getTime() || 0];
}

View File

@@ -71,7 +71,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
const orders = Object.values(result.data.variables).map((v) => v.order);
const orders = Object.values(result.data.variables!).map((v) => v.order);
expect(orders).toContain(0);
expect(orders).toContain(1);
});
@@ -84,7 +84,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.order).toBe(5);
expect(result.data.variables!.v1.order).toBe(5);
});
it('assigns unique orders across multiple variables that all lack an order', () => {
@@ -97,7 +97,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
const orders = Object.values(result.data.variables).map((v) => v.order);
const orders = Object.values(result.data.variables!).map((v) => v.order);
// All three newly assigned orders must be distinct
expect(new Set(orders).size).toBe(3);
});
@@ -112,7 +112,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.id).toMatch(
expect(result.data.variables!.v1.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
@@ -125,7 +125,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.id).toBe('keep-me');
expect(result.data.variables!.v1.id).toBe('keep-me');
});
});
@@ -145,7 +145,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.defaultValue).toBe('hello');
expect(result.data.variables!.v1.defaultValue).toBe('hello');
});
it('does not overwrite an existing defaultValue', () => {
@@ -163,7 +163,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.defaultValue).toBe('keep');
expect(result.data.variables!.v1.defaultValue).toBe('keep');
});
});
@@ -178,7 +178,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('staging');
expect(result.data.variables!.v1.selectedValue).toBe('staging');
});
it('applies localStorage allSelected over DB value', () => {
@@ -196,7 +196,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.allSelected).toBe(true);
expect(result.data.variables!.v1.allSelected).toBe(true);
});
});
@@ -217,7 +217,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.allSelected).toBe(true);
expect(result.data.variables!.v1.allSelected).toBe(true);
});
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
@@ -237,8 +237,8 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('dev');
expect(result.data.variables.v1.allSelected).toBe(false);
expect(result.data.variables!.v1.selectedValue).toBe('dev');
expect(result.data.variables!.v1.allSelected).toBe(false);
});
it('does not set allSelected=false when showALLOption is false', () => {
@@ -258,8 +258,8 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('dev');
expect(result.data.variables.v1.allSelected).toBe(true);
expect(result.data.variables!.v1.selectedValue).toBe('dev');
expect(result.data.variables!.v1.allSelected).toBe(true);
});
it('normalizes array URL value to single value for single-select variable', () => {
@@ -277,7 +277,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('prod');
expect(result.data.variables!.v1.selectedValue).toBe('prod');
});
it('wraps single URL value in array for multi-select variable', () => {
@@ -292,7 +292,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toStrictEqual(['prod']);
expect(result.data.variables!.v1.selectedValue).toStrictEqual(['prod']);
});
it('looks up URL variable by variable id when name is absent', () => {
@@ -306,7 +306,7 @@ describe('useTransformDashboardVariables', () => {
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('fallback');
expect(result.data.variables!.v1.selectedValue).toBe('fallback');
});
});
@@ -327,11 +327,11 @@ describe('useTransformDashboardVariables', () => {
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
});
const originalValue = dashboard.data.variables.v1.selectedValue;
const originalValue = dashboard.data.variables!.v1.selectedValue;
transformDashboardVariables(dashboard);
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
expect(dashboard.data.variables!.v1.selectedValue).toBe(originalValue);
});
});
});

View File

@@ -22,11 +22,12 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
localStorageVariables: any,
): Dashboard => {
const updatedData = data;
if (data && localStorageVariables) {
const updatedVariables = data.data.variables;
const variables = data?.data?.variables;
if (data && localStorageVariables && variables) {
const updatedVariables = variables;
const variablesFromUrl = getUrlVariables();
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
Object.keys(variables).forEach((variable) => {
const variableData = variables[variable];
// values from url
const urlVariable = variableData?.name
@@ -34,7 +35,7 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
: variablesFromUrl[variableData.id];
let updatedVariable = {
...data.data.variables[variable],
...variables[variable],
...localStorageVariables[variableData.name as any],
};

View File

@@ -1,2 +0,0 @@
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';

View File

@@ -1,16 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
return useCallback(async () => {
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient]);
}

View File

@@ -1,13 +0,0 @@
import { useIsFetching } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
*/
export function useIsGlobalTimeQueryRefreshing(): boolean {
return (
useIsFetching({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
}) > 0
);
}

View File

@@ -85,7 +85,7 @@ function DashboardWidgetInternal({
setDashboardData(updatedDashboardData);
setDashboardVariablesStore({
dashboardId,
variables: updatedDashboardData.data.variables,
variables: updatedDashboardData.data.variables ?? {},
});
},
});

View File

@@ -36,7 +36,6 @@ function SettingsPage(): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isEditor = user.role === USER_ROLES.EDITOR;
const isViewer = user.role === USER_ROLES.VIEWER;
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
@@ -103,13 +102,6 @@ function SettingsPage(): JSX.Element {
: item.isEnabled,
}));
}
if (isViewer) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
}));
}
}
if (isEnterpriseSelfHostedUser) {
@@ -142,13 +134,6 @@ function SettingsPage(): JSX.Element {
: item.isEnabled,
}));
}
if (isViewer) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
}));
}
}
if (!isCloudUser && !isEnterpriseSelfHostedUser) {
@@ -181,7 +166,6 @@ function SettingsPage(): JSX.Element {
}, [
isAdmin,
isEditor,
isViewer,
isCloudUser,
isEnterpriseSelfHostedUser,
isFetchingActiveLicense,

View File

@@ -82,13 +82,12 @@ describe('SettingsPage nav sections', () => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
});
it('renders "mcp-server" element', () => {
expect(screen.getByTestId('mcp-server')).toBeInTheDocument();
});
it.each(['billing', 'roles', 'mcp-server'])(
'does not render "%s" element',
(id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
},
);
});
describe('Self-hosted Admin', () => {

View File

@@ -41,6 +41,20 @@ describe('dashboardVariablesStoreUtils', () => {
expect(result).toStrictEqual([]);
});
it('should return empty array when variables is undefined', () => {
const result = buildSortedVariablesArray(
undefined as unknown as IDashboardVariables,
);
expect(result).toStrictEqual([]);
});
it('should return empty array when variables is null', () => {
const result = buildSortedVariablesArray(
null as unknown as IDashboardVariables,
);
expect(result).toStrictEqual([]);
});
it('should create copies of variables (not references)', () => {
const original = createVariable({ name: 'a', order: 0 });
const variables: IDashboardVariables = { a: original };

View File

@@ -17,11 +17,11 @@ import {
* Build a sorted array of variables by their order property
*/
export function buildSortedVariablesArray(
variables: IDashboardVariables,
variables?: IDashboardVariables,
): IDashboardVariable[] {
const sortedVariablesArray: IDashboardVariable[] = [];
Object.values(variables).forEach((value) => {
Object.values(variables ?? {}).forEach((value) => {
sortedVariablesArray.push({ ...value });
});

View File

@@ -0,0 +1,71 @@
import {
// oxlint-disable-next-line no-restricted-imports
createContext,
ReactNode,
// oxlint-disable-next-line no-restricted-imports
useContext,
useState,
} from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import get from 'api/browser/localstorage/get';
import {
createGlobalTimeStore,
defaultGlobalTimeStore,
GlobalTimeStoreApi,
} from './globalTimeStore';
import { GlobalTimeProviderOptions, GlobalTimeSelectedTime } from './types';
import { usePersistence } from './usePersistence';
import { useQueryCacheSync } from './useQueryCacheSync';
import { useUrlSync } from './useUrlSync';
import { useComputedMinMaxSync } from 'store/globalTime/useComputedMinMaxSync';
export const GlobalTimeContext = createContext<GlobalTimeStoreApi | null>(null);
export function GlobalTimeProvider({
children,
name,
inheritGlobalTime = false,
initialTime,
enableUrlParams = false,
removeQueryParamsOnUnmount = false,
localStoragePersistKey,
refreshInterval: initialRefreshInterval,
}: GlobalTimeProviderOptions & { children: ReactNode }): JSX.Element {
const parentStore = useContext(GlobalTimeContext);
const globalStore = parentStore ?? defaultGlobalTimeStore;
const resolveInitialTime = (): GlobalTimeSelectedTime => {
if (inheritGlobalTime) {
return globalStore.getState().selectedTime;
}
if (localStoragePersistKey) {
const stored = get(localStoragePersistKey);
if (stored) {
return stored as GlobalTimeSelectedTime;
}
}
return initialTime ?? DEFAULT_TIME_RANGE;
};
// Create isolated store (stable reference)
const [store] = useState(() =>
createGlobalTimeStore({
name,
selectedTime: resolveInitialTime(),
refreshInterval: initialRefreshInterval ?? 0,
}),
);
useComputedMinMaxSync(store);
useQueryCacheSync(store);
useUrlSync(store, enableUrlParams, removeQueryParamsOnUnmount);
usePersistence(store, localStoragePersistKey);
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
}

View File

@@ -0,0 +1,693 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import set from 'api/browser/localstorage/set';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
jest.mock('api/browser/localstorage/set');
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
nuqsProps?: { searchParams?: string },
) => {
const queryClient = createTestQueryClient();
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams={nuqsProps?.searchParams}>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('GlobalTimeProvider', () => {
describe('name prop', () => {
it('should pass name to store when provided', () => {
const wrapper = createWrapper({ name: 'test-drawer' });
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
wrapper,
});
expect(result.current).toBe('test-drawer');
});
it('should have undefined name when not provided', () => {
const wrapper = createWrapper({});
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
wrapper,
});
expect(result.current).toBeUndefined();
});
});
describe('store isolation', () => {
it('should create isolated store for each provider', () => {
const wrapper1 = createWrapper({ initialTime: '1h' });
const wrapper2 = createWrapper({ initialTime: '15m' });
const { result: result1 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper1 },
);
const { result: result2 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper2 },
);
expect(result1.current).toBe('1h');
expect(result2.current).toBe('15m');
});
});
describe('inheritGlobalTime', () => {
it('should inherit time from parent store when inheritGlobalTime is true', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
});
// Should inherit '6h' from parent provider
expect(result.current).toBe('6h');
});
it('should use initialTime when inheritGlobalTime is false', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
});
// Should use its own initialTime, not parent's
expect(result.current).toBe('15m');
});
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="?relativeTime=1h">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
});
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should use inherited time when URL params are empty', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
});
// No URL params, should keep inherited value
expect(result.current).toBe('6h');
});
it('should prefer custom time URL params over inheritGlobalTime', async () => {
const queryClient = createTestQueryClient();
const startTime = 1700000000000;
const endTime = 1700003600000;
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime(), {
wrapper: NestedWrapper,
});
// URL custom time params should override inherited time
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
});
describe('URL sync', () => {
it('should read relativeTime from URL on mount', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should read custom time from URL on mount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use custom URL keys when provided', async () => {
const wrapper = createWrapper(
{
enableUrlParams: {
relativeTimeKey: 'modalTime',
},
},
{ searchParams: '?modalTime=3h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('3h');
});
});
it('should use custom startTimeKey and endTimeKey when provided', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{
enableUrlParams: {
startTimeKey: 'customStart',
endTimeKey: 'customEnd',
},
},
{ searchParams: `?customStart=${startTime}&customEnd=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should NOT read from URL when enableUrlParams is false', async () => {
const wrapper = createWrapper(
{ enableUrlParams: false, initialTime: '15m' },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Should use initialTime, not URL value
expect(result.current).toBe('15m');
});
it('should prefer startTime/endTime over relativeTime when both present in URL', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{
searchParams: `?relativeTime=15m&startTime=${startTime}&endTime=${endTime}`,
},
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should use startTime/endTime, not relativeTime
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use initialTime when URL has invalid time values', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true, initialTime: '15m' },
{ searchParams: '?startTime=invalid&endTime=also-invalid' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// parseAsInteger returns null for invalid values, so should fallback to initialTime
expect(result.current).toBe('15m');
});
it('should update store when custom time is set from URL with only startTime and endTime', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
// Verify selectedTime is a custom time range string
expect(result.current.selectedTime).toContain('||_||');
});
});
describe('removeQueryParamsOnUnmount', () => {
const createUnmountTestWrapper = (
getQueryString: () => string,
setQueryString: (qs: string) => void,
) => {
return function TestWrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={getQueryString()}
onUrlUpdate={(event): void => {
setQueryString(event.queryString);
}}
>
{children}
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
it('should remove URL params when provider unmounts with removeQueryParamsOnUnmount=true', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('relativeTime');
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should NOT remove URL params when provider unmounts with removeQueryParamsOnUnmount=false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount={false}>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick to ensure cleanup effects would have run
await waitFor(() => {
// URL params should still be present
expect(currentQueryString).toContain('relativeTime=1h');
});
});
it('should remove custom time URL params on unmount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime(), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('startTime');
expect(currentQueryString).toContain('endTime');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should remove custom URL key params on unmount', async () => {
let currentQueryString = 'modalTime=3h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams={{
relativeTimeKey: 'modalTime',
}}
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('modalTime=3h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('modalTime');
});
});
it('should NOT remove URL params when enableUrlParams is false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams={false} removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick
await waitFor(() => {
// URL params should still be present (enableUrlParams is false)
expect(currentQueryString).toContain('relativeTime=1h');
});
});
});
});
describe('localStorage persistence', () => {
const mockSet = set as jest.MockedFunction<typeof set>;
beforeEach(() => {
localStorage.clear();
mockSet.mockClear();
mockSet.mockReturnValue(true);
});
it('should read from localStorage on mount', () => {
localStorage.setItem('test-time-key', '6h');
const wrapper = createWrapper({ localStoragePersistKey: 'test-time-key' });
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('6h');
});
it('should write to localStorage on selectedTime change', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-persist-key',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('12h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-persist-key', '12h');
});
});
it('should NOT write to localStorage when persistKey is undefined', async () => {
const wrapper = createWrapper({ initialTime: '15m' });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('1h');
});
// Wait a tick to ensure any async operations complete
await waitFor(() => {
expect(result.current.selectedTime).toBe('1h');
});
expect(mockSet).not.toHaveBeenCalled();
});
it('should only write to localStorage when selectedTime changes, not other state', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
// Change refreshInterval (not selectedTime)
act(() => {
result.current.setRefreshInterval(5000);
});
// Wait to ensure subscription handler had a chance to run
await waitFor(() => {
expect(result.current.refreshInterval).toBe(5000);
});
// Should NOT have written to localStorage for refreshInterval change
expect(mockSet).not.toHaveBeenCalled();
// Now change selectedTime
act(() => {
result.current.setSelectedTime('1h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-key', '1h');
});
});
it('should fallback to initialTime when localStorage contains empty string', () => {
localStorage.setItem('test-key', '');
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Empty string is falsy, should use initialTime
expect(result.current).toBe('15m');
});
it('should write custom time range to localStorage', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-custom-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-custom-key', customTime);
});
});
});
describe('refreshInterval', () => {
it('should initialize with provided refreshInterval', () => {
const wrapper = createWrapper({ refreshInterval: 5000 });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.refreshInterval).toBe(5000);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,148 @@
import { act } from '@testing-library/react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import {
createGlobalTimeStore,
defaultGlobalTimeStore,
} from '../globalTimeStore';
import { createCustomTimeRange } from '../utils';
describe('createGlobalTimeStore', () => {
describe('factory function', () => {
it('should create independent store instances', () => {
const store1 = createGlobalTimeStore();
const store2 = createGlobalTimeStore();
store1.getState().setSelectedTime('1h');
expect(store1.getState().selectedTime).toBe('1h');
expect(store2.getState().selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should accept initial state', () => {
const store = createGlobalTimeStore({
selectedTime: '15m',
refreshInterval: 5000,
});
expect(store.getState().selectedTime).toBe('15m');
expect(store.getState().refreshInterval).toBe(5000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should compute isRefreshEnabled correctly for custom time', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({
selectedTime: customTime,
refreshInterval: 5000,
});
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
describe('defaultGlobalTimeStore', () => {
it('should be a singleton', () => {
expect(defaultGlobalTimeStore).toBeDefined();
expect(defaultGlobalTimeStore.getState().selectedTime).toBeDefined();
});
});
describe('setRefreshInterval', () => {
it('should update refresh interval and enable refresh', () => {
const store = createGlobalTimeStore();
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should disable refresh when interval is 0', () => {
const store = createGlobalTimeStore({ refreshInterval: 5000 });
act(() => {
store.getState().setRefreshInterval(0);
});
expect(store.getState().refreshInterval).toBe(0);
expect(store.getState().isRefreshEnabled).toBe(false);
});
it('should not enable refresh for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({ selectedTime: customTime });
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
describe('store name', () => {
it('should store name when provided', () => {
const store = createGlobalTimeStore({ name: 'drawer' });
expect(store.getState().name).toBe('drawer');
});
it('should have undefined name when not provided', () => {
const store = createGlobalTimeStore();
expect(store.getState().name).toBeUndefined();
});
});
describe('getAutoRefreshQueryKey', () => {
it('should generate key without name for unnamed store', () => {
const store = createGlobalTimeStore();
const key = store
.getState()
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(key).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
it('should generate key with name for named store', () => {
const store = createGlobalTimeStore({ name: 'drawer' });
const key = store
.getState()
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(key).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'drawer',
'MY_QUERY',
'param1',
'15m',
]);
});
it('should handle no query parts for named store', () => {
const store = createGlobalTimeStore({ name: 'test' });
const key = store.getState().getAutoRefreshQueryKey('1h');
expect(key).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'test',
'1h',
]);
});
it('should handle no query parts for unnamed store', () => {
const store = createGlobalTimeStore();
const key = store.getState().getAutoRefreshQueryKey('1h');
expect(key).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
});
});
});

View File

@@ -1,202 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeSelectedTime } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime: resultMin, maxTime: resultMax } =
result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return different values on subsequent calls for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
// Advance time by 1 second
act(() => {
jest.advanceTimersByTime(1000);
});
const second = result.current.getMinMaxTime();
// maxTime should be different (1 second later)
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,868 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { createGlobalTimeStore, useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeSelectedTime, GlobalTimeState } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
/**
* Creates an isolated store wrapper for testing.
* Each test gets its own store instance, avoiding test pollution.
*/
function createIsolatedWrapper(
initialState?: Partial<GlobalTimeState>,
): ({ children }: { children: ReactNode }) => JSX.Element {
const store = createGlobalTimeStore(initialState);
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
};
}
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
it('should have lastRefreshTimestamp as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastRefreshTimestamp).toBe(0);
});
it('should have lastComputedMinMax with default values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
it('should compute and store lastComputedMinMax when selectedTime changes', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// setSelectedTime computes values on init (createIsolatedWrapper uses createGlobalTimeStore)
// But initial store state has minTime/maxTime as 0 until first setSelectedTime is called
const initialMinMax = { ...result.current.lastComputedMinMax };
// Now switch to a custom time range
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
// lastComputedMinMax should be updated to the custom range values
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 1000000000,
maxTime: 2000000000,
});
expect(result.current.lastComputedMinMax).not.toStrictEqual(initialMinMax);
});
it('should return fresh custom time values after switching from relative time', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Compute and cache values for relative time
act(() => {
result.current.computeAndStoreMinMax();
});
const relativeMinMax = { ...result.current.lastComputedMinMax };
// Switch to custom time range
const customMinTime = 5000000000;
const customMaxTime = 6000000000;
const customTime = createCustomTimeRange(customMinTime, customMaxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
// getMinMaxTime should return the custom time values, not cached relative values
const returned = result.current.getMinMaxTime();
expect(returned.minTime).toBe(customMinTime);
expect(returned.maxTime).toBe(customMaxTime);
expect(returned).not.toStrictEqual(relativeMinMax);
jest.useRealTimers();
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime: resultMin, maxTime: resultMax } =
result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should return exact values, NOT rounded values
expect(minTime).toBe(minTimeWithSeconds);
expect(maxTime).toBe(maxTimeWithSeconds);
expect(minTime).not.toBe(minTimeRounded);
expect(maxTime).not.toBe(maxTimeRounded);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return same values on subsequent calls when refresh disabled (under minute boundary)', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0); // refresh disabled
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(59000);
});
const second = result.current.getMinMaxTime();
// With refresh disabled, should return cached lastComputedMinMax
expect(second.maxTime).toBe(first.maxTime);
expect(second.minTime).toBe(first.minTime);
});
it('should return different values on subsequent calls when refresh disabled after minute boundary', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0); // refresh disabled
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(60000);
});
// Without refresh enabled, getMinMaxTime returns cached values
// Need to call computeAndStoreMinMax to get new values
const second = result.current.getMinMaxTime();
expect(second.maxTime).toBe(first.maxTime);
// After computing, values should update
act(() => {
result.current.computeAndStoreMinMax();
});
const third = result.current.getMinMaxTime();
expect(third.maxTime).toBe(first.maxTime + 60000 * NANO_SECOND_MULTIPLIER);
expect(third.minTime).toBe(first.minTime + 60000 * NANO_SECOND_MULTIPLIER);
});
it('should return stored lastComputedMinMax when available', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
result.current.computeAndStoreMinMax();
});
const stored = { ...result.current.lastComputedMinMax };
// Advance time by 5 seconds
act(() => {
jest.advanceTimersByTime(5000);
});
// getMinMaxTime should return stored values, not fresh computation
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(stored);
});
describe('with isRefreshEnabled (isolated store)', () => {
it('should compute fresh values when isRefreshEnabled is true (5s rounding)', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// getMinMaxTime computes 5s-rounded values when refresh enabled
const initialMinMax = result.current.getMinMaxTime();
// Advance time by 5 seconds to cross 5s boundary
act(() => {
jest.advanceTimersByTime(5000);
});
// getMinMaxTime should return fresh values, not cached
const freshValues = result.current.getMinMaxTime();
expect(freshValues.maxTime).toBe(
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
);
expect(freshValues.minTime).toBe(
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastComputedMinMax when values change (5s rounding)', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values (uses 5s rounding when refresh enabled)
const initialMinMax = result.current.getMinMaxTime();
// Advance time by 5 seconds to cross 5s boundary
act(() => {
jest.advanceTimersByTime(5000);
});
// Call getMinMaxTime - should update lastComputedMinMax
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
);
expect(result.current.lastComputedMinMax.minTime).toBe(
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastRefreshTimestamp when values change', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call getMinMaxTime - should update timestamp
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
it('should NOT update lastComputedMinMax when values have not changed (same 5s window)', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values (triggers computation for 5s-rounded values)
result.current.getMinMaxTime();
const initialMinMax = { ...result.current.lastComputedMinMax };
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time but stay within same 5-second window
act(() => {
jest.advanceTimersByTime(4000);
});
// Call getMinMaxTime - should NOT update store (same 5s boundary)
act(() => {
result.current.getMinMaxTime();
});
// Values should be unchanged (no unnecessary re-renders)
expect(result.current.lastComputedMinMax).toStrictEqual(initialMinMax);
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should return cached values when isRefreshEnabled is false', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 0, // Refresh disabled
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const storedMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// getMinMaxTime should return cached values since refresh is disabled
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(storedMinMax);
expect(result.current.lastComputedMinMax).toStrictEqual(storedMinMax);
});
it('should return same values for custom time range regardless of time passing', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const wrapper = createIsolatedWrapper({
selectedTime: customTime,
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// isRefreshEnabled should be false for custom time ranges
expect(result.current.isRefreshEnabled).toBe(false);
// Custom time ranges always return the fixed values, not relative to "now"
const first = result.current.getMinMaxTime();
expect(first.minTime).toBe(minTime);
expect(first.maxTime).toBe(maxTime);
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Should still return the same fixed values (custom range doesn't drift)
const second = result.current.getMinMaxTime();
expect(second.minTime).toBe(minTime);
expect(second.maxTime).toBe(maxTime);
});
it('should handle multiple consecutive refetch intervals correctly (5s rounding)', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values
const initialMinMax = result.current.getMinMaxTime();
// Simulate 3 refetch intervals crossing 5-second boundaries
for (let i = 1; i <= 3; i++) {
act(() => {
jest.advanceTimersByTime(5000);
});
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + i * 5000 * NANO_SECOND_MULTIPLIER,
);
}
});
});
});
describe('computeAndStoreMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute and store min/max values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
act(() => {
result.current.computeAndStoreMinMax();
});
// maxTime should be the current time (no rounding when refresh disabled)
const expectedMaxTime =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
expect(result.current.lastComputedMinMax.minTime).toBe(
expectedMaxTime - fifteenMinutesNs,
);
});
it('should update lastRefreshTimestamp', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const beforeTimestamp = Date.now();
act(() => {
result.current.computeAndStoreMinMax();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThanOrEqual(
beforeTimestamp,
);
});
it('should return the computed values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
expect(returnedValue).toStrictEqual(result.current.lastComputedMinMax);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return exact values, NOT rounded values
expect(returnedValue?.minTime).toBe(minTimeWithSeconds);
expect(returnedValue?.maxTime).toBe(maxTimeWithSeconds);
expect(returnedValue?.minTime).not.toBe(minTimeRounded);
expect(returnedValue?.maxTime).not.toBe(maxTimeRounded);
// lastComputedMinMax should also have exact values
expect(result.current.lastComputedMinMax.minTime).toBe(minTimeWithSeconds);
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTimeWithSeconds);
});
});
describe('updateRefreshTimestamp', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should update lastRefreshTimestamp to current time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.updateRefreshTimestamp();
});
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
});
it('should not modify lastComputedMinMax', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.computeAndStoreMinMax();
});
const beforeMinMax = { ...result.current.lastComputedMinMax };
act(() => {
jest.advanceTimersByTime(5000);
result.current.updateRefreshTimestamp();
});
expect(result.current.lastComputedMinMax).toStrictEqual(beforeMinMax);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
describe('setSelectedTime (min/max computation)', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute and store min/max for relative time on setSelectedTime', () => {
const wrapper = createIsolatedWrapper();
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Initial state has 0 values
expect(result.current.lastComputedMinMax.maxTime).toBe(0);
act(() => {
result.current.setSelectedTime('15m');
});
// Should have computed values immediately
const expectedMaxTime =
new Date('2024-01-15T12:00:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
expect(result.current.lastComputedMinMax.minTime).toBe(
expectedMaxTime - fifteenMinutesNs,
);
});
it('should compute and store min/max for custom time on setSelectedTime', () => {
const wrapper = createIsolatedWrapper();
const { result } = renderHook(() => useGlobalTime(), { wrapper });
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
expect(result.current.lastComputedMinMax.minTime).toBe(minTime);
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTime);
});
it('should update lastRefreshTimestamp on setSelectedTime', () => {
const wrapper = createIsolatedWrapper();
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.lastRefreshTimestamp).toBe(0);
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
});
it('should skip update when same selectedTime and refreshInterval', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Set initial values
act(() => {
result.current.setSelectedTime('15m', 5000);
});
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time
act(() => {
jest.advanceTimersByTime(1000);
});
// Try to set same values again
act(() => {
result.current.setSelectedTime('15m', 5000);
});
// Should not have updated timestamp (no state change)
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
});
});
describe('computeAndStoreMinMax (refresh behavior)', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should skip computation and return lastComputedMinMax when refresh is enabled', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values via getMinMaxTime (which computes for refresh enabled)
const initialMinMax = result.current.getMinMaxTime();
// Advance time
act(() => {
jest.advanceTimersByTime(60000);
});
// computeAndStoreMinMax should skip computation when refresh is enabled
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return the current lastComputedMinMax, not fresh computation
expect(returnedValue).toStrictEqual(initialMinMax);
});
it('should compute fresh values when refresh is disabled', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 0, // Disabled
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// computeAndStoreMinMax should compute fresh values
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return new values
expect(returnedValue?.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
});
});

View File

@@ -0,0 +1,190 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { createGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from '../hooks';
import { useComputedMinMaxSync } from '../useComputedMinMaxSync';
import { createCustomTimeRange } from '../utils';
describe('useGlobalTime', () => {
it('should return full store state without selector', () => {
const { result } = renderHook(() => useGlobalTime());
expect(result.current.selectedTime).toBeDefined();
expect(result.current.setSelectedTime).toBeInstanceOf(Function);
});
it('should return selected value with selector', () => {
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime));
expect(typeof result.current).toBe('string');
});
it('should use context store when provided', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '1h' });
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('1h');
});
});
describe('useIsCustomTimeRange', () => {
it('should return false for relative time', () => {
const { result } = renderHook(() => useIsCustomTimeRange());
expect(result.current).toBe(false);
});
it('should return true for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const contextStore = createGlobalTimeStore({ selectedTime: customTime });
const { result } = renderHook(() => useIsCustomTimeRange(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toBe(true);
});
});
describe('useGlobalTimeStoreApi', () => {
it('should return store API', () => {
const { result } = renderHook(() => useGlobalTimeStoreApi());
expect(result.current.getState).toBeInstanceOf(Function);
expect(result.current.subscribe).toBeInstanceOf(Function);
});
});
describe('useLastComputedMinMax', () => {
it('should return lastComputedMinMax from store', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
// Compute the min/max first
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toStrictEqual(
contextStore.getState().lastComputedMinMax,
);
});
it('should update when store changes', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
const firstValue = { ...result.current };
// Change time and recompute
act(() => {
jest.advanceTimersByTime(60000); // Advance 1 minute
contextStore.getState().computeAndStoreMinMax();
});
expect(result.current).not.toStrictEqual(firstValue);
jest.useRealTimers();
});
});
describe('useComputedMinMaxSync', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute min/max on mount when store has zero values', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
expect(contextStore.getState().lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
renderHook(() => useComputedMinMaxSync(contextStore));
// Should have computed values now
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
});
it('should NOT recompute when store already has values', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
contextStore.getState().computeAndStoreMinMax();
const initialMinMax = { ...contextStore.getState().lastComputedMinMax };
const initialTimestamp = contextStore.getState().lastRefreshTimestamp;
jest.advanceTimersByTime(60000);
renderHook(() => useComputedMinMaxSync(contextStore));
// Should NOT have recomputed - values should be unchanged
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
initialMinMax,
);
expect(contextStore.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should only compute on mount, not on re-renders', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
const { rerender } = renderHook(() => useComputedMinMaxSync(contextStore));
const afterMountMinMax = { ...contextStore.getState().lastComputedMinMax };
const afterMountTimestamp = contextStore.getState().lastRefreshTimestamp;
jest.advanceTimersByTime(60000);
rerender();
// Should NOT have recomputed on re-render
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
afterMountMinMax,
);
expect(contextStore.getState().lastRefreshTimestamp).toBe(
afterMountTimestamp,
);
});
});

View File

@@ -0,0 +1,381 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { useGlobalTimeQueryInvalidate } from '../useGlobalTimeQueryInvalidate';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
queryClient: QueryClient,
) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('useGlobalTimeQueryInvalidate', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should return a function', () => {
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
expect(typeof result.current).toBe('function');
});
it('should call computeAndStoreMinMax before invalidating queries (refresh disabled)', async () => {
const wrapper = createWrapper(
{ initialTime: '15m', refreshInterval: 0 }, // refresh disabled so computeAndStoreMinMax computes fresh values
queryClient,
);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Initial computation - need to call computeAndStoreMinMax first
act(() => {
result.current.globalTime.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.globalTime.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call invalidate - should compute fresh values when refresh is disabled
await act(async () => {
await result.current.invalidate();
});
// lastComputedMinMax should have been updated
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should invalidate queries with AUTO_REFRESH_QUERY key', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({ data: 'test' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up a query with AUTO_REFRESH_QUERY key
const { result: queryResult } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
queryFn: mockQueryFn,
}),
{ wrapper },
);
// Wait for initial query to complete
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(mockQueryFn).toHaveBeenCalledTimes(1);
// Now render the invalidate hook and call it
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Query should have been refetched
await waitFor(() => {
expect(mockQueryFn).toHaveBeenCalledTimes(2);
});
});
it('should NOT invalidate queries without AUTO_REFRESH_QUERY key', async () => {
const autoRefreshQueryFn = jest.fn().mockResolvedValue({ data: 'auto' });
const regularQueryFn = jest.fn().mockResolvedValue({ data: 'regular' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up both types of queries
const { result: autoRefreshQuery } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto-query'],
queryFn: autoRefreshQueryFn,
}),
{ wrapper },
);
const { result: regularQuery } = renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: regularQueryFn,
}),
{ wrapper },
);
// Wait for initial queries to complete
await waitFor(() => {
expect(autoRefreshQuery.current.isSuccess).toBe(true);
expect(regularQuery.current.isSuccess).toBe(true);
});
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(1);
expect(regularQueryFn).toHaveBeenCalledTimes(1);
// Call invalidate
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Only auto-refresh query should be refetched
await waitFor(() => {
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(2);
});
// Regular query should NOT be refetched
expect(regularQueryFn).toHaveBeenCalledTimes(1);
});
it('should use exact custom time values (not rounded) when invalidating', async () => {
// Use timestamps that are NOT on minute boundaries
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
const wrapper = createWrapper({ initialTime: customTime }, queryClient);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Call invalidate
await act(async () => {
await result.current.invalidate();
});
// Verify custom time values are NOT rounded
expect(result.current.globalTime.lastComputedMinMax.minTime).toBe(
minTimeWithSeconds,
);
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
maxTimeWithSeconds,
);
});
it('should invalidate multiple AUTO_REFRESH_QUERY queries at once', async () => {
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
const queryFn3 = jest.fn().mockResolvedValue({ data: 'query3' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: queryFn1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: queryFn2,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query3'],
queryFn: queryFn3,
}),
{ wrapper },
);
// Wait for initial queries
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(1);
expect(queryFn2).toHaveBeenCalledTimes(1);
expect(queryFn3).toHaveBeenCalledTimes(1);
});
// Call invalidate
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// All queries should be refetched
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(2);
expect(queryFn2).toHaveBeenCalledTimes(2);
expect(queryFn3).toHaveBeenCalledTimes(2);
});
});
describe('scoped invalidation with store name', () => {
it('should only invalidate queries matching store name', async () => {
const namedQueryFn = jest.fn().mockResolvedValue({ data: 'named' });
const unnamedQueryFn = jest.fn().mockResolvedValue({ data: 'unnamed' });
const wrapper = createWrapper(
{ name: 'drawer', initialTime: '15m' },
queryClient,
);
// Query with matching name
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'named-query'],
queryFn: namedQueryFn,
}),
{ wrapper },
);
// Query without name (different store)
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'unnamed-query'],
queryFn: unnamedQueryFn,
}),
{ wrapper },
);
await waitFor(() => {
expect(namedQueryFn).toHaveBeenCalledTimes(1);
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
});
// Call invalidate
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// Only named query should be refetched
await waitFor(() => {
expect(namedQueryFn).toHaveBeenCalledTimes(2);
});
// Unnamed query should NOT be refetched
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
});
it('should invalidate all queries for unnamed store (backward compatible)', async () => {
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
// Unnamed store (no name prop)
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: queryFn1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: queryFn2,
}),
{ wrapper },
);
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(1);
expect(queryFn2).toHaveBeenCalledTimes(1);
});
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// Both should be refetched
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(2);
expect(queryFn2).toHaveBeenCalledTimes(2);
});
});
});
});

View File

@@ -0,0 +1,323 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { GlobalTimeProviderOptions } from '../types';
import { useIsGlobalTimeQueryRefreshing } from '../useIsGlobalTimeQueryRefreshing';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
queryClient: QueryClient,
): (({ children }: { children: ReactNode }) => JSX.Element) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
const createProviderWrapper = (
providerProps: GlobalTimeProviderOptions,
queryClient: QueryClient,
): (({ children }: { children: ReactNode }) => JSX.Element) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('useIsGlobalTimeQueryRefreshing', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
});
afterEach(() => {
queryClient.clear();
});
it('should return false when no queries are fetching', () => {
const wrapper = createWrapper(queryClient);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
expect(result.current).toBe(false);
});
it('should return true when AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start the auto-refresh query
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve the query
act(() => {
resolveQuery({ data: 'done' });
});
// Should be false after fetching completes
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should return false when non-AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start a regular query (not auto-refresh)
renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be false - not an auto-refresh query
expect(result.current).toBe(false);
// Cleanup
act(() => {
resolveQuery({ data: 'done' });
});
});
it('should return true when multiple AUTO_REFRESH_QUERY queries are fetching', async () => {
let resolveQuery1: (value: unknown) => void;
let resolveQuery2: (value: unknown) => void;
const queryPromise1 = new Promise((resolve) => {
resolveQuery1 = resolve;
});
const queryPromise2 = new Promise((resolve) => {
resolveQuery2 = resolve;
});
const wrapper = createWrapper(queryClient);
// Start multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: () => queryPromise1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: () => queryPromise2,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve first query
act(() => {
resolveQuery1({ data: 'done1' });
});
// Should still be true (second query still fetching)
await waitFor(() => {
expect(result.current).toBe(true);
});
// Resolve second query
act(() => {
resolveQuery2({ data: 'done2' });
});
// Should be false after all complete
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should only track AUTO_REFRESH_QUERY, not other queries', async () => {
let resolveAutoRefresh: (value: unknown) => void;
let resolveRegular: (value: unknown) => void;
const autoRefreshPromise = new Promise((resolve) => {
resolveAutoRefresh = resolve;
});
const regularPromise = new Promise((resolve) => {
resolveRegular = resolve;
});
const wrapper = createWrapper(queryClient);
// Start both types of queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto'],
queryFn: () => autoRefreshPromise,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: ['regular'],
queryFn: () => regularPromise,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true (auto-refresh is fetching)
expect(result.current).toBe(true);
// Resolve auto-refresh query
act(() => {
resolveAutoRefresh({ data: 'done' });
});
// Should be false even though regular query is still fetching
await waitFor(() => {
expect(result.current).toBe(false);
});
// Cleanup
act(() => {
resolveRegular({ data: 'done' });
});
});
describe('scoped refreshing check with store name', () => {
it('should return true only for queries matching store name', async () => {
let resolveNamedQuery: (value: unknown) => void;
const namedQueryPromise = new Promise((resolve) => {
resolveNamedQuery = resolve;
});
const wrapper = createProviderWrapper(
{ name: 'drawer', initialTime: '15m' },
queryClient,
);
// Start query with matching name
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
queryFn: () => namedQueryPromise,
}),
{ wrapper },
);
// Check refreshing status
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true - named query is fetching
expect(result.current).toBe(true);
// Resolve the query
act(() => {
resolveNamedQuery({ data: 'done' });
});
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should return false when only different store queries are fetching', async () => {
let resolveOtherQuery: (value: unknown) => void;
const otherQueryPromise = new Promise((resolve) => {
resolveOtherQuery = resolve;
});
const wrapper = createProviderWrapper(
{ name: 'drawer', initialTime: '15m' },
queryClient,
);
// Start query with different name (belongs to different store)
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
queryFn: () => otherQueryPromise,
}),
{ wrapper },
);
// Check refreshing status for 'drawer' store
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be false - the fetching query belongs to 'other-store', not 'drawer'
expect(result.current).toBe(false);
// Cleanup
act(() => {
resolveOtherQuery({ data: 'done' });
});
});
});
});

View File

@@ -0,0 +1,190 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { createGlobalTimeStore, GlobalTimeStoreApi } from '../globalTimeStore';
import { useQueryCacheSync } from '../useQueryCacheSync';
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function createWrapper(
queryClient: QueryClient,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe('useQueryCacheSync', () => {
let store: GlobalTimeStoreApi;
let queryClient: QueryClient;
beforeEach(() => {
store = createGlobalTimeStore();
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should update lastRefreshTimestamp when auto-refresh query succeeds', async () => {
// Initialize store
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
// Advance time
act(() => {
jest.advanceTimersByTime(5000);
});
// Render the hook
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a successful auto-refresh query
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
await waitFor(() => {
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
});
it('should not update timestamp for non-auto-refresh queries', async () => {
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a regular query (not auto-refresh)
await act(async () => {
await queryClient.fetchQuery({
queryKey: ['some-other-query'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
describe('store name filtering', () => {
it('should update timestamp for named store when matching query succeeds', async () => {
const store = createGlobalTimeStore({ name: 'drawer' });
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
act(() => {
jest.advanceTimersByTime(5000);
});
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Query with matching name
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
await waitFor(() => {
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
});
it('should NOT update timestamp for named store when different name query succeeds', async () => {
const store = createGlobalTimeStore({ name: 'drawer' });
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
act(() => {
jest.advanceTimersByTime(5000);
});
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Query with different name
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should NOT update timestamp for named store when unnamed query succeeds', async () => {
const store = createGlobalTimeStore({ name: 'drawer' });
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
act(() => {
jest.advanceTimersByTime(5000);
});
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Query without name (unnamed store format)
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
});
});

View File

@@ -1,10 +1,15 @@
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
computeRounded5sMinMax,
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
roundDownTo5Seconds,
} from '../utils';
describe('globalTime/utils', () => {
@@ -136,4 +141,184 @@ describe('globalTime/utils', () => {
expect(result.minTime).toBe(now - oneDayNs);
});
});
describe('roundDownTo5Seconds', () => {
it('should round down timestamp to 5-second boundary', () => {
// 12:30:47.123Z -> 12:30:45.000Z
const inputNano = 1705321847123 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321845000 * NANO_SECOND_MULTIPLIER;
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
});
it('should not change timestamp already at 5-second boundary', () => {
const inputNano = 1705321845000 * NANO_SECOND_MULTIPLIER; // 12:30:45.000
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
});
it('should round 12:30:04.999 down to 12:30:00.000', () => {
const inputNano = 1705321804999 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER;
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
});
it('should round 12:30:09.999 down to 12:30:05.000', () => {
const inputNano = 1705321809999 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321805000 * NANO_SECOND_MULTIPLIER;
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
});
it('should handle timestamp at exact 5-second intervals', () => {
// Test 5, 10, 15, 20, 25... second marks
const base = 1705321800000; // 12:30:00
for (let sec = 0; sec < 60; sec += 5) {
const inputNano = (base + sec * 1000) * NANO_SECOND_MULTIPLIER;
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
}
});
});
describe('computeRounded5sMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:47.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return maxTime rounded to 5-second boundary for relative time', () => {
const result = computeRounded5sMinMax('15m');
// maxTime should be rounded down to 12:30:45.000
const expectedMaxTime =
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(expectedMaxTime);
});
it('should compute minTime based on 5s-rounded maxTime', () => {
const result = computeRounded5sMinMax('15m');
const expectedMaxTime =
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.minTime).toBe(expectedMaxTime - fifteenMinutesNs);
});
it('should return unchanged values for custom time range', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const result = computeRounded5sMinMax(customTime);
expect(result.minTime).toBe(minTime);
expect(result.maxTime).toBe(maxTime);
});
it('should preserve duration for 1h relative time', () => {
const result = computeRounded5sMinMax('1h');
const oneHourNs = 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
const duration = result.maxTime - result.minTime;
expect(duration).toBe(oneHourNs);
});
});
describe('getAutoRefreshQueryKey', () => {
it('should prefix with AUTO_REFRESH_QUERY constant', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY');
expect(result[0]).toBe(REACT_QUERY_KEY.AUTO_REFRESH_QUERY);
});
it('should append selectedTime at end', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
it('should handle no additional query parts', () => {
const result = getAutoRefreshQueryKey('1h');
expect(result).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
});
it('should handle custom time range as selectedTime', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const result = getAutoRefreshQueryKey(customTime, 'METRICS');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'METRICS',
customTime,
]);
});
it('should handle object query parts', () => {
const params = { entityId: '123', filter: 'active' };
const result = getAutoRefreshQueryKey('15m', 'ENTITY', params);
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'ENTITY',
params,
'15m',
]);
});
});
describe('getAutoRefreshQueryKey deprecation', () => {
const originalEnv = process.env.NODE_ENV;
const originalWarn = console.warn;
beforeEach(() => {
console.warn = jest.fn();
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
console.warn = originalWarn;
});
it('should log deprecation warning in development', () => {
process.env.NODE_ENV = 'development';
getAutoRefreshQueryKey('15m', 'TEST');
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('deprecated'),
);
});
it('should NOT log deprecation warning in production', () => {
process.env.NODE_ENV = 'production';
getAutoRefreshQueryKey('15m', 'TEST');
expect(console.warn).not.toHaveBeenCalled();
});
it('should still return correct query key format', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
});
});

View File

@@ -1,32 +1,144 @@
import { createStore, StoreApi, useStore } from 'zustand';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { create } from 'zustand';
import {
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
ParsedTimeRange,
} from './types';
import { isCustomTimeRange, parseSelectedTime } from './utils';
import {
computeRounded5sMinMax,
isCustomTimeRange,
parseSelectedTime,
} from './utils';
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
export type IGlobalTimeStore = GlobalTimeStore;
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
selectedTime: DEFAULT_TIME_RANGE,
isRefreshEnabled: false,
refreshInterval: 0,
setSelectedTime: (selectedTime, refreshInterval): void => {
set((state) => {
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
const isCustom = isCustomTimeRange(selectedTime);
function computeIsRefreshEnabled(
selectedTime: GlobalTimeSelectedTime,
refreshInterval: number,
): boolean {
if (isCustomTimeRange(selectedTime)) {
return false;
}
return refreshInterval > 0;
}
return {
selectedTime,
refreshInterval: newRefreshInterval,
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
};
});
},
getMinMaxTime: (selectedTime): ParsedTimeRange => {
return parseSelectedTime(selectedTime || get().selectedTime);
},
}));
export function createGlobalTimeStore(
initialState?: Partial<GlobalTimeState>,
): GlobalTimeStoreApi {
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = initialState?.refreshInterval ?? 0;
const name = initialState?.name;
return createStore<GlobalTimeStore>((set, get) => ({
name,
selectedTime,
refreshInterval,
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: { minTime: 0, maxTime: 0 },
setSelectedTime: (
time: GlobalTimeSelectedTime,
newRefreshInterval?: number,
): void => {
const state = get();
const interval = newRefreshInterval ?? state.refreshInterval;
if (time === state.selectedTime && interval === state.refreshInterval) {
return;
}
const computedMinMax = parseSelectedTime(time);
set({
selectedTime: time,
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(time, interval),
lastComputedMinMax: computedMinMax,
lastRefreshTimestamp: Date.now(),
});
},
setRefreshInterval: (interval: number): void => {
set((state) => ({
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(state.selectedTime, interval),
}));
},
getMinMaxTime: (): ParsedTimeRange => {
const state = get();
if (isCustomTimeRange(state.selectedTime)) {
return parseSelectedTime(state.selectedTime);
}
if (state.isRefreshEnabled) {
const freshMinMax = computeRounded5sMinMax(state.selectedTime);
if (
freshMinMax.minTime !== state.lastComputedMinMax.minTime ||
freshMinMax.maxTime !== state.lastComputedMinMax.maxTime
) {
set({ lastComputedMinMax: freshMinMax, lastRefreshTimestamp: Date.now() });
}
return freshMinMax;
}
return state.lastComputedMinMax;
},
computeAndStoreMinMax: (): ParsedTimeRange => {
const state = get();
if (state.isRefreshEnabled) {
return state.lastComputedMinMax;
}
const computedMinMax = parseSelectedTime(state.selectedTime);
set({
lastComputedMinMax: computedMinMax,
lastRefreshTimestamp: Date.now(),
});
return computedMinMax;
},
updateRefreshTimestamp: (): void => {
set({ lastRefreshTimestamp: Date.now() });
},
getAutoRefreshQueryKey: (
selectedTime: GlobalTimeSelectedTime,
...queryParts: unknown[]
): unknown[] => {
const storeName = get().name;
if (storeName) {
return [
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
storeName,
...queryParts,
selectedTime,
];
}
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
},
}));
}
export const defaultGlobalTimeStore = createGlobalTimeStore();
export const useGlobalTimeStore = <T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
): T => {
return useStore(
defaultGlobalTimeStore,
selector ?? ((state) => state as unknown as T),
);
};

View File

@@ -0,0 +1,57 @@
// oxlint-disable-next-line no-restricted-imports
import { useContext } from 'react';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { GlobalTimeContext } from './GlobalTimeContext';
import { defaultGlobalTimeStore, GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeStore, ParsedTimeRange } from './types';
import { isCustomTimeRange } from './utils';
/**
* Access global time state with optional selector for performance.
*
* @example
* // Full state (re-renders on any change)
* const { selectedTime, setSelectedTime } = useGlobalTime();
*
* @example
* // With selector (re-renders only when selectedTime changes)
* const selectedTime = useGlobalTime(state => state.selectedTime);
*/
export function useGlobalTime<T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
equalityFn?: (a: T, b: T) => boolean,
): T {
const contextStore = useContext(GlobalTimeContext);
const store = contextStore ?? defaultGlobalTimeStore;
return useStoreWithEqualityFn(
store,
selector ?? ((state) => state as unknown as T),
equalityFn,
);
}
/**
* Check if currently using a custom time range.
*/
export function useIsCustomTimeRange(): boolean {
const selectedTime = useGlobalTime((state) => state.selectedTime);
return isCustomTimeRange(selectedTime);
}
/**
* Get the store API directly (for subscriptions or non-React contexts).
*/
export function useGlobalTimeStoreApi(): GlobalTimeStoreApi {
const contextStore = useContext(GlobalTimeContext);
return contextStore ?? defaultGlobalTimeStore;
}
/**
* Get the last computed min/max time values.
* Use this for display purposes to ensure consistency with query data.
*/
export function useLastComputedMinMax(): ParsedTimeRange {
return useGlobalTime((state) => state.lastComputedMinMax);
}

View File

@@ -1,9 +1,558 @@
export { useGlobalTimeStore } from './globalTimeStore';
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
/**
* # Global Time Store
*
* Centralized time management for the application with auto-refresh support.
*
* ## Quick Start
*
* ```tsx
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
*
* function MyComponent() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* const { data } = useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY', params),
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return fetchData({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* }
* ```
*
* ## Core Concepts
*
* ### Time Formats
*
* | Format | Example | Description |
* |--------|---------|-------------|
* | Relative | `'15m'`, `'1h'`, `'1d'` | Duration from now, supports auto-refresh |
* | Custom | `'1234567890||_||1234567899'` | Fixed range in nanoseconds, no auto-refresh |
*
* ### Time Units
*
* - Store values are in **nanoseconds**
* - Most APIs expect **seconds**
* - Convert to have seconds: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER / 1000)`
* - Convert to have ms: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER)`
*
* ## Integration Guide
*
* ### Step 1: Get Store State
*
* Use selectors for optimal re-render performance:
*
* ```tsx
* // Good - only re-renders when selectedTime changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
*
* // Avoid - re-renders on ANY store change
* const store = useGlobalTime();
* ```
*
* ### Step 2: Build Query Key
*
* Use the store's `getAutoRefreshQueryKey` to enable auto-refresh:
*
* ```tsx
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
*
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(
* selectedTime, // Required - triggers invalidation
* 'UNIQUE_KEY', // Your query identifier
* ...otherParams // Additional cache-busting params
* ),
* [getAutoRefreshQueryKey, selectedTime, ...deps]
* );
* ```
*
* **Note:** For named providers (with `name` prop), query keys are automatically
* scoped to that store, enabling isolated invalidation and refresh tracking.
*
* ### Step 3: Fetch Data
*
* **IMPORTANT**: Call `getMinMaxTime()` INSIDE `queryFn`:
*
* ```tsx
* const { data } = useQuery({
* queryKey,
* queryFn: () => {
* // Fresh time values computed here during auto-refresh
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return api.fetch({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ### Step 4: Add Refresh Button (Optional)
*
* ```tsx
* import {
* useGlobalTimeQueryInvalidate,
* useIsGlobalTimeQueryRefreshing,
* } from 'store/globalTime';
*
* function RefreshButton() {
* const invalidate = useGlobalTimeQueryInvalidate();
* const isRefreshing = useIsGlobalTimeQueryRefreshing();
*
* return (
* <button onClick={invalidate} disabled={isRefreshing}>
* {isRefreshing ? 'Refreshing...' : 'Refresh'}
* </button>
* );
* }
* ```
*
* ## Avoiding Stale Data
*
* ### Problem: Time Drift During Refresh
*
* If multiple queries compute time independently, they may use different values:
*
* ```tsx
* // BAD - each query gets different time
* queryFn: () => {
* const now = Date.now();
* return fetchData({ end: now, start: now - duration });
* }
* ```
*
* ### Solution: Use getMinMaxTime()
*
* `getMinMaxTime()` ensures all queries use consistent timestamps:
* - When auto-refresh is **disabled**: returns cached values from `computeAndStoreMinMax()`
* - When auto-refresh is **enabled**: computes fresh values (rounded to 5-second boundaries)
*
* Since values are rounded to 5-second boundaries, all queries calling `getMinMaxTime()`
* within the same 5-second window get identical timestamps.
*
* ```tsx
* // GOOD - all queries get same time
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* return fetchData({ start: minTime, end: maxTime });
* }
* ```
*
* ### How It Works
*
* **Manual refresh:**
* 1. User clicks refresh
* 2. `useGlobalTimeQueryInvalidate` calls `computeAndStoreMinMax()`
* 3. Fresh min/max stored in `lastComputedMinMax`
* 4. All queries re-run and call `getMinMaxTime()`
* 5. All get the SAME cached values
*
* **Auto-refresh (when `isRefreshEnabled = true`):**
* 1. React-query's `refetchInterval` triggers query re-execution
* 2. `getMinMaxTime()` computes fresh values (rounded to 5 seconds)
* 3. If values changed, updates `lastComputedMinMax` cache
* 4. All queries within same 5-second window get consistent values
*
* ## Auto-Refresh Setup
*
* Auto-refresh is enabled when:
* - `selectedTime` is a relative duration (e.g., `'15m'`)
* - `refreshInterval > 0`
*
* ```tsx
* // Auto-refresh configuration
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY'),
* queryFn: () => { ... },
* // Enable periodic refetch
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ## API Reference
*
* ### Hooks
*
* | Hook | Returns | Description |
* |------|---------|-------------|
* | `useGlobalTime(selector?)` | `T` | Access store state with optional selector |
* | `useGlobalTimeQueryInvalidate()` | `() => Promise<void>` | Invalidate all auto-refresh queries |
* | `useIsGlobalTimeQueryRefreshing()` | `boolean` | Check if any query is refreshing |
* | `useIsCustomTimeRange()` | `boolean` | Check if using fixed time range |
* | `useLastComputedMinMax()` | `ParsedTimeRange` | Get cached min/max values |
* | `useGlobalTimeStoreApi()` | `GlobalTimeStoreApi` | Get raw store API |
*
* ### Store Actions
*
* | Action | Description |
* |--------|-------------|
* | `setSelectedTime(time, interval?)` | Set time range and optional refresh interval (resets cache) |
* | `setRefreshInterval(ms)` | Set auto-refresh interval |
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
* | `getAutoRefreshQueryKey(time, ...parts)` | Build scoped query key for this store instance |
*
* ### Utilities
*
* | Function | Description |
* |----------|-------------|
* | `getAutoRefreshQueryKey(time, ...parts)` | **@deprecated** Use store action instead |
* | `parseSelectedTime(time)` | Parse time string to min/max (fresh computation) |
* | `isCustomTimeRange(time)` | Check if time is custom range format |
* | `createCustomTimeRange(min, max)` | Create custom range string |
*
* ### Constants
*
* | Constant | Value | Description |
* |----------|-------|-------------|
* | `NANO_SECOND_MULTIPLIER` | `1000000` | Convert ms to ns |
* | `CUSTOM_TIME_SEPARATOR` | `'||_||'` | Separator in custom range strings |
*
* ## Context & Composition
*
* ### Why Use Context?
*
* By default, `useGlobalTime()` uses a shared global store. Use `GlobalTimeProvider`
* to create isolated time state for specific UI sections (modals, drawers, etc.).
*
* ### Provider Options
*
* | Option | Type | Description |
* |--------|------|-------------|
* | `name` | `string` | Scope query keys to this store (enables isolated invalidation) |
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
* | `initialTime` | `string` | Initial time if not inheriting |
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
* | `localStoragePersistKey` | `string` | Persist time to localStorage |
* | `refreshInterval` | `number` | Initial auto-refresh interval (ms) |
*
* ### Example 1: Isolated Time in Modal
*
* A modal with its own time picker that doesn't affect the main page:
*
* ```tsx
* import { GlobalTimeProvider, useGlobalTime } from 'store/globalTime';
*
* function EntityDetailsModal({ entity, onClose }) {
* return (
* <Modal open onClose={onClose}>
* // Isolated time context - changes here don't affect parent
* <GlobalTimeProvider
* inheritGlobalTime // Start with parent's current time
* refreshInterval={0} // No auto-refresh in modal
* >
* <ModalContent entity={entity} />
* </GlobalTimeProvider>
* </Modal>
* );
* }
*
* function ModalContent({ entity }) {
* // This useGlobalTime reads from the modal's isolated store
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
*
* return (
* <>
* <DateTimePicker
* value={selectedTime}
* onChange={(time) => setSelectedTime(time)}
* />
* <EntityMetrics entity={entity} />
* <EntityLogs entity={entity} />
* </>
* );
* }
* ```
*
* ### Example 2: List Page with Detail Drawer
*
* Main list uses global time, drawer has independent time:
*
* ```tsx
* // Main list page - uses global time (no provider needed)
* function K8sPodsList() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const [selectedPod, setSelectedPod] = useState(null);
*
* return (
* <>
* <PageHeader>
* <DateTimeSelectionV3 /> // Controls global time
* </PageHeader>
*
* <PodsTable
* timeRange={selectedTime}
* onRowClick={setSelectedPod}
* />
*
* {selectedPod && (
* <PodDetailsDrawer
* pod={selectedPod}
* onClose={() => setSelectedPod(null)}
* />
* )}
* </>
* );
* }
*
* // Drawer with its own time context
* function PodDetailsDrawer({ pod, onClose }) {
* return (
* <Drawer open onClose={onClose}>
* <GlobalTimeProvider
* name="pod-drawer" // Scopes queries - only this drawer's queries are invalidated
* inheritGlobalTime // Start with list's time
* removeQueryParamsOnUnmount // Clean up URL when drawer closes
* enableUrlParams={{
* relativeTimeKey: 'drawerTime',
* startTimeKey: 'drawerStart',
* endTimeKey: 'drawerEnd',
* }}
* >
* <DrawerHeader>
* <DateTimeSelectionV3 /> // Controls drawer's time only
* </DrawerHeader>
*
* <Tabs>
* <Tab label="Metrics"><PodMetrics pod={pod} /></Tab>
* <Tab label="Logs"><PodLogs pod={pod} /></Tab>
* <Tab label="Events"><PodEvents pod={pod} /></Tab>
* </Tabs>
* </GlobalTimeProvider>
* </Drawer>
* );
* }
* ```
*
* ### Example 3: Nested Contexts
*
* Contexts can be nested - each level creates isolation:
*
* ```tsx
* // App level - global time
* function App() {
* return (
* <QueryClientProvider>
* // No provider here = uses defaultGlobalTimeStore
* <Dashboard />
* </QueryClientProvider>
* );
* }
*
* // Dashboard with comparison panel
* function Dashboard() {
* return (
* <div className="dashboard">
* // Main dashboard uses global time
* <MainCharts />
*
* // Comparison panel has its own time
* <GlobalTimeProvider initialTime="1h">
* <ComparisonPanel />
* </GlobalTimeProvider>
* </div>
* );
* }
*
* function ComparisonPanel() {
* // This reads from ComparisonPanel's isolated store (1h)
* // Not affected by global time changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* return <ComparisonCharts timeRange={selectedTime} />;
* }
* ```
*
* ### Example 4: URL Sync for Shareable Links
*
* Persist time selection to URL for shareable links:
*
* ```tsx
* function TracesExplorer() {
* return (
* <GlobalTimeProvider
* enableUrlParams={{
* relativeTimeKey: 'time', // ?time=15m
* startTimeKey: 'startTime', // ?startTime=1234567890
* endTimeKey: 'endTime', // ?endTime=1234567899
* }}
* initialTime="15m" // Fallback if URL has no time params
* >
* <TracesContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Example 5: localStorage Persistence
*
* Remember user's last selected time across sessions:
*
* ```tsx
* function MetricsExplorer() {
* return (
* <GlobalTimeProvider
* localStoragePersistKey="metrics-explorer-time"
* initialTime="1h" // Fallback for first visit
* >
* <MetricsContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Context Resolution Order
*
* When `useGlobalTime()` is called, it resolves the store in this order:
*
* 1. Nearest `GlobalTimeProvider` ancestor (if any)
* 2. `defaultGlobalTimeStore` (global singleton)
*
* ```
* App (no provider -> uses defaultGlobalTimeStore)
* |-- Dashboard
* |-- MainCharts (uses defaultGlobalTimeStore)
* |-- GlobalTimeProvider (isolated store A)
* |-- ComparisonPanel (uses store A)
* |-- GlobalTimeProvider (isolated store B)
* |-- NestedChart (uses store B)
* ```
*
* ### Scoped Query Keys with `name`
*
* The `name` prop enables isolated query invalidation. When a provider has a name,
* its queries are prefixed with that name, so invalidation only affects that store:
*
* ```tsx
* // Main page - unnamed store
* // Query keys: ['AUTO_REFRESH_QUERY', 'METRICS', ...]
* function MainDashboard() {
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* // ...
* }
*
* // Drawer - named store
* // Query keys: ['AUTO_REFRESH_QUERY', 'drawer', 'METRICS', ...]
* function DetailDrawer() {
* return (
* <GlobalTimeProvider name="drawer" inheritGlobalTime>
* <DrawerContent />
* </GlobalTimeProvider>
* );
* }
*
* function DrawerContent() {
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* const invalidate = useGlobalTimeQueryInvalidate();
* // invalidate() only refreshes queries with 'drawer' prefix
* }
* ```
*
* ## Complete Example
*
* ```tsx
* import { useMemo } from 'react';
* import { useQuery } from 'react-query';
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
*
* function MetricsPanel({ entityId }: { entityId: string }) {
* // 1. Get store state with selectors
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* // 2. Build query key (memoized) - automatically scoped if using named provider
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(selectedTime, 'METRICS', entityId),
* [getAutoRefreshQueryKey, selectedTime, entityId]
* );
*
* // 3. Query with auto-refresh
* const { data, isLoading } = useQuery({
* queryKey,
* queryFn: () => {
* // Get fresh time inside queryFn
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
*
* return fetchMetrics({ entityId, start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
*
* return <Chart data={data} loading={isLoading} />;
* }
* ```
*
* @module store/globalTime
*/
// Store
export {
createGlobalTimeStore,
defaultGlobalTimeStore,
useGlobalTimeStore,
} from './globalTimeStore';
export type { GlobalTimeStoreApi } from './globalTimeStore';
// Context & Provider
export { GlobalTimeContext, GlobalTimeProvider } from './GlobalTimeContext';
// Hooks
export {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from './hooks';
// Query hooks for auto-refresh
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
// Types
export type {
CustomTimeRange,
CustomTimeRangeSeparator,
GlobalTimeActions,
GlobalTimeProviderOptions,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
ParsedTimeRange,
} from './types';
// Utilities
export {
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
} from './utils';
// Internal hooks (for advanced use cases)
export { useQueryCacheSync } from './useQueryCacheSync';

View File

@@ -44,9 +44,80 @@ export interface IGlobalTimeStoreActions {
) => void;
/**
* Get the current min/max time values parsed from selectedTime.
* For durations, computes fresh values based on Date.now().
* For custom ranges, extracts the stored values.
* Get the current min/max time values.
* - Custom time ranges: returns exact parsed values
* - isRefreshEnabled true: computes 5s-rounded values and updates store
* - isRefreshEnabled false: returns lastComputedMinMax
*/
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
getMinMaxTime: () => ParsedTimeRange;
}
export interface GlobalTimeProviderOptions {
/**
* Optional name for the store instance.
* Used to scope query keys - only queries with this store's prefix
* will be tracked/invalidated by this store's hooks.
*/
name?: string;
/** Initialize from parent/global time */
inheritGlobalTime?: boolean;
/** Initial time if not inheriting */
initialTime?: GlobalTimeSelectedTime;
/** URL sync configuration. When false/omitted, no URL sync. */
enableUrlParams?:
| boolean
| {
relativeTimeKey?: string;
startTimeKey?: string;
endTimeKey?: string;
};
removeQueryParamsOnUnmount?: boolean;
localStoragePersistKey?: string;
refreshInterval?: number;
}
export interface GlobalTimeState {
/**
* Optional name for the store instance.
* Used to scope query keys for auto-refresh queries.
* Unnamed stores use the default prefix without a name.
*/
name?: string;
selectedTime: GlobalTimeSelectedTime;
refreshInterval: number;
isRefreshEnabled: boolean;
lastRefreshTimestamp: number;
lastComputedMinMax: ParsedTimeRange;
}
export interface GlobalTimeActions {
setSelectedTime: (
time: GlobalTimeSelectedTime,
refreshInterval?: number,
) => void;
setRefreshInterval: (interval: number) => void;
getMinMaxTime: () => ParsedTimeRange;
/**
* Compute fresh rounded min/max values, store them, and update refresh timestamp.
* Call this before invalidating queries to ensure all queries use the same time values.
*
* @returns The newly computed ParsedTimeRange
*/
computeAndStoreMinMax: () => ParsedTimeRange;
/**
* Update the refresh timestamp to current time.
* Called by QueryCache listener when auto-refresh queries complete.
*/
updateRefreshTimestamp: () => void;
/**
* Build query key for auto-refresh queries scoped to this store.
* Named stores: ['AUTO_REFRESH_QUERY', name, ...parts, selectedTime]
* Unnamed stores: ['AUTO_REFRESH_QUERY', ...parts, selectedTime]
*/
getAutoRefreshQueryKey: (
selectedTime: GlobalTimeSelectedTime,
...queryParts: unknown[]
) => unknown[];
}
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;

View File

@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { GlobalTimeStoreApi } from './globalTimeStore';
/**
* Used to initialize computed min/max on mount when store has no values yet.
* setSelectedTime now computes min/max on change, so subscription is no longer needed.
*
* @internal
*/
export function useComputedMinMaxSync(store: GlobalTimeStoreApi): void {
useEffect(() => {
const { lastComputedMinMax } = store.getState();
if (lastComputedMinMax.minTime === 0 && lastComputedMinMax.maxTime === 0) {
store.getState().computeAndStoreMinMax();
}
}, [store]);
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGlobalTime } from './hooks';
/**
* Use when you want to invalidate any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*
* This hook computes fresh time values before invalidating queries,
* ensuring all queries use the same min/max time during a refresh cycle.
*
* For named stores, only invalidates queries matching the store's name.
* For unnamed stores, invalidates all AUTO_REFRESH_QUERY queries.
*
* @public
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
const computeAndStoreMinMax = useGlobalTime((s) => s.computeAndStoreMinMax);
const name = useGlobalTime((s) => s.name);
return useCallback(async () => {
// Compute fresh time values BEFORE invalidating
// This ensures all queries that re-run will use the same time values
// If refresh is enabled, this will just be skipped
computeAndStoreMinMax();
// Build scoped query key prefix
const queryKey = name
? [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, name]
: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY];
return await queryClient.invalidateQueries({ queryKey });
}, [queryClient, computeAndStoreMinMax, name]);
}

View File

@@ -0,0 +1,21 @@
import { useIsFetching } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGlobalTime } from './hooks';
/**
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
*
* For named stores, only checks queries matching the store's name.
* For unnamed stores, checks all AUTO_REFRESH_QUERY queries.
*
* @public
*/
export function useIsGlobalTimeQueryRefreshing(): boolean {
const name = useGlobalTime((s) => s.name);
const queryKey = name
? [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, name]
: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY];
return useIsFetching({ queryKey }) > 0;
}

View File

@@ -0,0 +1,32 @@
import { useEffect } from 'react';
import set from 'api/browser/localstorage/set';
import { GlobalTimeStoreApi } from './globalTimeStore';
/**
* Used to keep the selected time persisted on localStorage
*
* @internal
*/
export function usePersistence(
store: GlobalTimeStoreApi,
persistKey: string | undefined,
): void {
useEffect(() => {
if (!persistKey) {
return;
}
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.selectedTime === previousSelectedTime) {
return;
}
previousSelectedTime = state.selectedTime;
set(persistKey, state.selectedTime);
});
}, [store, persistKey]);
}

View File

@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeStoreApi } from './globalTimeStore';
/**
* Used to keep lastRefreshTimestamp in sync after every react query refresh.
* For named stores, only tracks queries with matching store name.
* For unnamed stores, tracks all AUTO_REFRESH_QUERY queries (backward compatible).
*
* @internal
*/
export function useQueryCacheSync(store: GlobalTimeStoreApi): void {
const queryClient = useQueryClient();
useEffect(() => {
const queryCache = queryClient.getQueryCache();
const storeName = store.getState().name;
return queryCache.subscribe((event) => {
if (event?.type !== 'queryUpdated') {
return;
}
const action = event.action as { type?: string };
if (action?.type !== 'success') {
return;
}
const queryKey = event.query.queryKey;
if (!Array.isArray(queryKey)) {
return;
}
// this is created by getAutoRefreshQueryKey inside the store,
// to track usages of global time store and autoRefresh
if (queryKey[0] !== REACT_QUERY_KEY.AUTO_REFRESH_QUERY) {
return;
}
// Named store: only track queries with matching name at position [1]
if (storeName && queryKey[1] !== storeName) {
return;
}
// Unnamed store: track all AUTO_REFRESH_QUERY queries (backward compatible)
store.getState().updateRefreshTimestamp();
});
}, [queryClient, store]);
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useRef } from 'react';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import { GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeProviderOptions } from './types';
import {
createCustomTimeRange,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
} from './utils';
interface UrlSyncConfig {
relativeTimeKey: string;
startTimeKey: string;
endTimeKey: string;
}
/**
* Used to sync internal state with URL when URL params are enabled.
*
* @internal
*/
export function useUrlSync(
store: GlobalTimeStoreApi,
enableUrlParams: GlobalTimeProviderOptions['enableUrlParams'],
removeOnUnmount: boolean,
): void {
const isInitialMount = useRef(true);
const keys: UrlSyncConfig =
enableUrlParams && typeof enableUrlParams === 'object'
? {
relativeTimeKey: enableUrlParams.relativeTimeKey ?? 'relativeTime',
startTimeKey: enableUrlParams.startTimeKey ?? 'startTime',
endTimeKey: enableUrlParams.endTimeKey ?? 'endTime',
}
: {
relativeTimeKey: 'relativeTime',
startTimeKey: 'startTime',
endTimeKey: 'endTime',
};
const [urlState, setUrlState] = useQueryStates(
{
[keys.relativeTimeKey]: parseAsString,
[keys.startTimeKey]: parseAsInteger,
[keys.endTimeKey]: parseAsInteger,
},
{ history: 'replace' },
);
useEffect(() => {
if (!enableUrlParams || !isInitialMount.current) {
return;
}
isInitialMount.current = false;
const relativeTime = urlState[keys.relativeTimeKey];
const startTime = urlState[keys.startTimeKey];
const endTime = urlState[keys.endTimeKey];
if (typeof startTime === 'number' && typeof endTime === 'number') {
const customTime = createCustomTimeRange(
startTime * NANO_SECOND_MULTIPLIER,
endTime * NANO_SECOND_MULTIPLIER,
);
store.getState().setSelectedTime(customTime);
} else if (
typeof relativeTime === 'string' &&
isValidShortHandDateTimeFormat(relativeTime)
) {
store.getState().setSelectedTime(relativeTime as Time);
}
}, [
urlState,
keys?.startTimeKey,
keys?.endTimeKey,
keys?.relativeTimeKey,
store,
enableUrlParams,
]);
useEffect(() => {
if (!enableUrlParams) {
return;
}
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.selectedTime === previousSelectedTime) {
return;
}
previousSelectedTime = state.selectedTime;
if (isCustomTimeRange(state.selectedTime)) {
const parsed = parseCustomTimeRange(state.selectedTime);
if (parsed) {
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: Math.floor(parsed.minTime / NANO_SECOND_MULTIPLIER),
[keys.endTimeKey]: Math.floor(parsed.maxTime / NANO_SECOND_MULTIPLIER),
});
}
} else {
void setUrlState({
[keys.relativeTimeKey]: state.selectedTime,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
}
});
}, [
store,
keys?.startTimeKey,
keys?.endTimeKey,
keys?.relativeTimeKey,
setUrlState,
enableUrlParams,
]);
useEffect(() => {
if (!enableUrlParams || !removeOnUnmount) {
return;
}
return (): void => {
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
};
}, [
removeOnUnmount,
keys?.relativeTimeKey,
keys?.startTimeKey,
keys?.endTimeKey,
setUrlState,
enableUrlParams,
]);
}

View File

@@ -44,8 +44,8 @@ export function parseCustomTimeRange(
}
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
const minTime = parseInt(minStr, 10);
const maxTime = parseInt(maxStr, 10);
const minTime = Number.parseInt(minStr, 10);
const maxTime = Number.parseInt(maxStr, 10);
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
return null;
@@ -79,11 +79,60 @@ export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
}
/**
* Use to build your react-query key for auto-refresh queries
* @deprecated Use store.getAutoRefreshQueryKey() instead.
* Access via: const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
*
* This function only works with the default (unnamed) store prefix.
* For named stores, use the store method to get properly scoped query keys.
*/
export function getAutoRefreshQueryKey(
selectedTime: GlobalTimeSelectedTime,
...queryParts: unknown[]
): unknown[] {
if (process.env.NODE_ENV === 'development') {
console.warn(
'[globalTime] getAutoRefreshQueryKey from utils is deprecated. ' +
'Use useGlobalTime((s) => s.getAutoRefreshQueryKey) instead.',
);
}
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
}
/**
* Round timestamp down to the nearest 5-second boundary.
* Used for tighter sync during auto-refresh scenarios.
*
* @param timestampNano - Timestamp in nanoseconds
* @returns Timestamp rounded down to 5-second boundary in nanoseconds
*/
export function roundDownTo5Seconds(timestampNano: number): number {
const msPerInterval = 5 * 1000;
const timestampMs = Math.floor(timestampNano / NANO_SECOND_MULTIPLIER);
const roundedMs = Math.floor(timestampMs / msPerInterval) * msPerInterval;
return roundedMs * NANO_SECOND_MULTIPLIER;
}
/**
* Compute min/max time with maxTime rounded down to 5-second boundary.
* Used when isRefreshEnabled is true for tighter time sync.
*
* @param selectedTime - The selected time (relative like '15m' or custom range)
* @returns ParsedTimeRange with 5-second rounded maxTime for relative times
*/
export function computeRounded5sMinMax(selectedTime: string): ParsedTimeRange {
if (isCustomTimeRange(selectedTime)) {
return parseSelectedTime(selectedTime);
}
const nowNano = Date.now() * NANO_SECOND_MULTIPLIER;
const roundedMaxTime = roundDownTo5Seconds(nowNano);
const { minTime: originalMin, maxTime: originalMax } =
getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
const durationNano = originalMax - originalMin;
return {
minTime: roundedMaxTime - durationNano,
maxTime: roundedMaxTime,
};
}

View File

@@ -95,7 +95,7 @@ export interface DashboardData {
title: string;
layout?: Layout[];
panelMap?: Record<string, { widgets: Layout[]; collapsed: boolean }>;
variables: Record<string, IDashboardVariable>;
variables?: Record<string, IDashboardVariable>;
version?: string;
image?: string;
}

Some files were not shown because too many files have changed in this diff Show More