mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-05 08:30:26 +01:00
Compare commits
14 Commits
chore/rule
...
nv/11280
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f7950f13b | ||
|
|
81eadac3a8 | ||
|
|
eec1c45e3f | ||
|
|
ce26458b9f | ||
|
|
7932917f5f | ||
|
|
4cf8125372 | ||
|
|
959e32b6f3 | ||
|
|
d3304af0ed | ||
|
|
2110c04927 | ||
|
|
0e3f644b13 | ||
|
|
01565e58e8 | ||
|
|
0f17899ded | ||
|
|
1f4a2ed8e8 | ||
|
|
77e7798779 |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -188,3 +188,7 @@ go.mod @therealpandey
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -69,6 +69,8 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -70,6 +70,8 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/gor-signoz.yaml
vendored
2
.github/workflows/gor-signoz.yaml
vendored
@@ -35,6 +35,8 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> .env
|
||||
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
|
||||
- name: node-setup
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
|
||||
@@ -870,14 +870,6 @@ components:
|
||||
- timestampMillis
|
||||
- data
|
||||
type: object
|
||||
CloudintegrationtypesAssets:
|
||||
properties:
|
||||
dashboards:
|
||||
items:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesDashboard'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
CloudintegrationtypesAzureAccountConfig:
|
||||
properties:
|
||||
deploymentRegion:
|
||||
@@ -1025,17 +1017,6 @@ components:
|
||||
- ingestionUrl
|
||||
- ingestionKey
|
||||
type: object
|
||||
CloudintegrationtypesDashboard:
|
||||
properties:
|
||||
definition:
|
||||
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
CloudintegrationtypesDataCollected:
|
||||
properties:
|
||||
logs:
|
||||
@@ -1209,7 +1190,7 @@ components:
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
assets:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAssets'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesServiceAssets'
|
||||
cloudIntegrationService:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
|
||||
dataCollected:
|
||||
@@ -1222,8 +1203,6 @@ components:
|
||||
type: string
|
||||
supportedSignals:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
|
||||
telemetryCollectionStrategy:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
@@ -1234,9 +1213,17 @@ components:
|
||||
- assets
|
||||
- supportedSignals
|
||||
- dataCollected
|
||||
- telemetryCollectionStrategy
|
||||
- cloudIntegrationService
|
||||
type: object
|
||||
CloudintegrationtypesServiceAssets:
|
||||
properties:
|
||||
dashboards:
|
||||
items:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesServiceDashboard'
|
||||
type: array
|
||||
required:
|
||||
- dashboards
|
||||
type: object
|
||||
CloudintegrationtypesServiceConfig:
|
||||
properties:
|
||||
aws:
|
||||
@@ -1244,6 +1231,18 @@ components:
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
|
||||
type: object
|
||||
CloudintegrationtypesServiceDashboard:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
integrationDashboard:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesStorableIntegrationDashboard'
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- title
|
||||
- description
|
||||
type: object
|
||||
CloudintegrationtypesServiceID:
|
||||
enum:
|
||||
- alb
|
||||
@@ -1278,6 +1277,30 @@ components:
|
||||
- icon
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesStorableIntegrationDashboard:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
dashboardId:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- dashboardId
|
||||
- provider
|
||||
- slug
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
CloudintegrationtypesSupportedSignals:
|
||||
properties:
|
||||
logs:
|
||||
@@ -1285,13 +1308,6 @@ components:
|
||||
metrics:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesTelemetryCollectionStrategy:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccount:
|
||||
properties:
|
||||
config:
|
||||
@@ -8087,6 +8103,64 @@ paths:
|
||||
summary: Update account
|
||||
tags:
|
||||
- cloudintegration
|
||||
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the services metadata for the specified account
|
||||
and cloud provider
|
||||
operationId: ListAccountServicesMetadata
|
||||
parameters:
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGettableServicesMetadata'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List account services metadata
|
||||
tags:
|
||||
- cloudintegration
|
||||
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
|
||||
put:
|
||||
deprecated: false
|
||||
|
||||
@@ -355,26 +355,32 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
|
||||
|
||||
var integrationService *cloudintegrationtypes.CloudIntegrationService
|
||||
|
||||
if !cloudIntegrationID.IsZero() {
|
||||
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if storedService != nil {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
}
|
||||
|
||||
if err := module.enrichDashboardIDs(ctx, orgID, provider, serviceID, serviceDefinition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cloudIntegrationID.IsZero() {
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
|
||||
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if storedService == nil {
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
|
||||
}
|
||||
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
|
||||
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
|
||||
integrationDashboards, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, integrationService, integrationDashboards), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
@@ -583,20 +589,3 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrichDashboardIDs replaces the raw dashboard name in each Dashboard.ID with the provisioned UUID.
|
||||
// TODO: remove this hack and send idiomatic response to client.
|
||||
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
for i, d := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
|
||||
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
serviceDefinition.Assets.Dashboards[i].ID = row.DashboardID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -291,8 +291,6 @@
|
||||
// Prevents the usage of specific antd components in favor of our lib
|
||||
"signoz/no-signozhq-ui-barrel": "error",
|
||||
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
|
||||
"signoz/no-css-module-bracket-access": "warn",
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -2,33 +2,9 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/no-deep-nesting.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/no-id-selectors.js'),
|
||||
path.join(
|
||||
__dirname,
|
||||
'stylelint-rules/css-modules/no-bare-element-selectors.js',
|
||||
),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/prefer-css-variables.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/class-name-pattern.js'),
|
||||
],
|
||||
plugins: [path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js')],
|
||||
customSyntax: 'postcss-scss',
|
||||
rules: {
|
||||
// Applies to all SCSS files
|
||||
'local/no-unsupported-asset-url': true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// CSS module-specific rules
|
||||
files: ['**/*.module.scss'],
|
||||
rules: {
|
||||
'local/no-deep-nesting': [true, { severity: 'warning' }],
|
||||
'local/no-id-selectors': true,
|
||||
'local/no-bare-element-selectors': true,
|
||||
'local/prefer-css-variables': [true, { severity: 'warning' }],
|
||||
'local/class-name-pattern': [true, { severity: 'warning' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -23,8 +23,6 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Always add data-testid or testId (if supported) to critical/behavioral components like inputs, buttons, etc...
|
||||
- When creating test, these IDs should be used instead of finding by role.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
# CSS Modules Guide for AI Agents
|
||||
|
||||
## Config (vite.config.ts)
|
||||
|
||||
```ts
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** `camelCaseOnly` exports ONLY camelCase keys. Original kebab-case NOT accessible.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| CSS Class | JS Access | Works? |
|
||||
|-----------|-----------|--------|
|
||||
| `.alertHistory` | `styles.alertHistory` | Yes |
|
||||
| `.alert-history` | `styles.alertHistory` | Yes |
|
||||
| `.alert-history` | `styles['alert-history']` | NO - undefined |
|
||||
|
||||
## Bad Patterns
|
||||
|
||||
### Class Naming
|
||||
|
||||
```scss
|
||||
// BAD: Bracket access won't work
|
||||
.my-class { }
|
||||
// Then in JS: styles['my-class'] -> undefined
|
||||
|
||||
// BAD: Collision - both become same key
|
||||
.alertHistory { }
|
||||
.alert-history { } // -> styles.alertHistory (conflicts)
|
||||
|
||||
// BAD: Underscore inconsistency
|
||||
.my_class { } // -> styles.myClass (confusing)
|
||||
|
||||
// GOOD: Direct camelCase
|
||||
.alertHistory { }
|
||||
.statsCard { }
|
||||
```
|
||||
|
||||
### Nesting
|
||||
|
||||
```scss
|
||||
// BAD: Deep nesting - specificity wars, hard to override
|
||||
.container {
|
||||
.wrapper {
|
||||
.inner {
|
||||
.content { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BAD: Nesting creates separate classes you might not expect
|
||||
.button {
|
||||
.icon { } // -> styles.icon (separate class, not scoped under .button)
|
||||
}
|
||||
|
||||
// GOOD: Flat structure
|
||||
.container { }
|
||||
.containerWrapper { }
|
||||
.containerContent { }
|
||||
|
||||
// GOOD: Nesting only for pseudo/states
|
||||
.button {
|
||||
&:hover { }
|
||||
&:disabled { }
|
||||
&::before { }
|
||||
}
|
||||
```
|
||||
|
||||
### Global Escapes
|
||||
|
||||
```scss
|
||||
// BAD: Overusing global
|
||||
:global {
|
||||
.everything { }
|
||||
.in-here { }
|
||||
.is-global { }
|
||||
}
|
||||
|
||||
// BAD: Global without necessity
|
||||
:global(.myComponent) { } // defeats purpose of modules
|
||||
|
||||
// GOOD: Targeted global for third-party overrides
|
||||
.container {
|
||||
:global(.ant-modal-content) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Selectors
|
||||
|
||||
```scss
|
||||
// BAD: ID selectors - not reusable
|
||||
#myComponent { }
|
||||
|
||||
// BAD: Element selectors without scope
|
||||
div { } // affects ALL divs in component
|
||||
|
||||
// BAD: Complex selectors
|
||||
.container > div + span ~ p { }
|
||||
|
||||
// GOOD: Class-only selectors
|
||||
.container { }
|
||||
.title { }
|
||||
```
|
||||
|
||||
### Variables & Values
|
||||
|
||||
```scss
|
||||
// BAD: Hardcoded colors
|
||||
.button {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
// BAD: Magic numbers
|
||||
.container {
|
||||
padding: 17px;
|
||||
margin-left: 43px;
|
||||
}
|
||||
|
||||
// GOOD: Semantic tokens (theme-aware)
|
||||
.button {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// GOOD: Spacing system
|
||||
.container {
|
||||
padding: var(--spacing-4);
|
||||
margin-left: var(--spacing-5);
|
||||
}
|
||||
```
|
||||
|
||||
## Design Tokens (@signozhq/design-tokens)
|
||||
|
||||
Prefer semantic tokens over hardcoded values.
|
||||
|
||||
You can read the ./node_modules/@signozhq/design-tokens/dist/style.css to find complete list of available tokens.
|
||||
|
||||
### Spacing
|
||||
|
||||
```scss
|
||||
// Spacing scale (index -> px):
|
||||
// --spacing-0=0 --spacing-1=2 --spacing-2=4 --spacing-3=6 --spacing-4=8
|
||||
// --spacing-5=10 --spacing-6=12 --spacing-7=14 --spacing-8=16 --spacing-10=20
|
||||
// --spacing-12=24 --spacing-16=32 --spacing-20=40 --spacing-24=48 --spacing-32=64
|
||||
// --spacing-40=80 --spacing-48=96 --spacing-56=112 --spacing-64=128
|
||||
// (index != px; --spacing-2 is 4px, not 2px)
|
||||
.container {
|
||||
padding: var(--spacing-4); // 8px
|
||||
gap: var(--spacing-6); // 12px
|
||||
margin-bottom: var(--spacing-8); // 16px
|
||||
}
|
||||
|
||||
// Also available: --padding-* and --margin-* (rem-based)
|
||||
// --padding-1 = 0.25rem, --padding-4 = 1rem, etc.
|
||||
```
|
||||
|
||||
### Typography
|
||||
|
||||
```scss
|
||||
// Font sizes (preferred)
|
||||
.title {
|
||||
font-size: var(--periscope-font-size-large); // 18px
|
||||
font-size: var(--periscope-font-size-medium); // 16px
|
||||
font-size: var(--periscope-font-size-base); // 13px
|
||||
font-size: var(--periscope-font-size-small); // 11px
|
||||
}
|
||||
|
||||
// Alternative scale (rem-based)
|
||||
.heading {
|
||||
font-size: var(--font-size-xl); // 1.25rem
|
||||
font-size: var(--font-size-lg); // 1.125rem
|
||||
font-size: var(--font-size-base); // 1rem
|
||||
font-size: var(--font-size-sm); // 0.875rem
|
||||
}
|
||||
|
||||
// Font weights
|
||||
.bold {
|
||||
font-weight: var(--font-weight-semibold); // 600
|
||||
font-weight: var(--font-weight-medium); // 500
|
||||
font-weight: var(--font-weight-normal); // 400
|
||||
}
|
||||
|
||||
// Line heights
|
||||
.text {
|
||||
line-height: var(--line-height-20); // 20px
|
||||
line-height: var(--line-height-24); // 24px
|
||||
}
|
||||
```
|
||||
|
||||
### Colors (Prefer Semantic Tokens)
|
||||
|
||||
Use L1/L2/L3 semantic tokens - they handle light/dark theme automatically.
|
||||
|
||||
```scss
|
||||
// BAD: Primitive tokens (fixed value across themes, won't swap on theme change)
|
||||
.card {
|
||||
background: var(--bg-ink-400);
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
// GOOD: L1/L2/L3 tokens (theme-aware - swap automatically light/dark)
|
||||
.card {
|
||||
background: var(--l1-background); // base layer
|
||||
color: var(--l1-foreground); // primary text
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--l2-background); // elevated surface
|
||||
color: var(--l2-foreground); // secondary text
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.nested {
|
||||
background: var(--l3-background); // nested/inset
|
||||
color: var(--l3-foreground); // tertiary text
|
||||
}
|
||||
|
||||
// Hover states
|
||||
.card:hover {
|
||||
background: var(--l1-background-hover);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
// Semantic action colors (also theme-aware)
|
||||
.primary {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: var(--danger-background);
|
||||
color: var(--danger-foreground);
|
||||
}
|
||||
|
||||
.success {
|
||||
background: var(--success-background);
|
||||
color: var(--success-foreground);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--warning-background);
|
||||
color: var(--warning-foreground);
|
||||
}
|
||||
|
||||
// Accent colors (for highlights, badges, etc.)
|
||||
.accent {
|
||||
background: var(--accent-primary); // robin blue
|
||||
background: var(--accent-forest); // green
|
||||
background: var(--accent-cherry); // red
|
||||
background: var(--accent-amber); // yellow
|
||||
}
|
||||
```
|
||||
|
||||
**Token hierarchy:**
|
||||
- Primitive tokens (`--bg-*`, `--text-*`, etc.) have fixed values across themes.
|
||||
- Semantic tokens (L1/L2/L3, `--primary-*`, `--danger-*`, etc.) automatically swap based on theme.
|
||||
- L1 = base/root layer
|
||||
- L2 = elevated surfaces (cards, panels)
|
||||
- L3 = nested/inset elements
|
||||
|
||||
## Overriding @signozhq/ui Components
|
||||
|
||||
Components expose CSS variables for customization.
|
||||
|
||||
You can ensure they exist by looking at ./node_modules/@signozhq/ui/dist.
|
||||
Never write a override without confirm it exists.
|
||||
|
||||
Override via:
|
||||
|
||||
### Method 1: CSS Variables (Preferred)
|
||||
|
||||
Each component exposes `--<component>-<property>` variables:
|
||||
|
||||
```scss
|
||||
// Override Button
|
||||
.customButton {
|
||||
--button-background: var(--success-background);
|
||||
--button-border-radius: var(--radius-2);
|
||||
--button-padding: var(--spacing-4) var(--spacing-8);
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Override Input
|
||||
.customInput {
|
||||
--input-height: 2.5rem;
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-padding: var(--spacing-2) var(--spacing-6);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// Override nested parts
|
||||
.customInput {
|
||||
--input-prefix-padding: 0 var(--spacing-4) 0 var(--spacing-6);
|
||||
--input-suffix-color: var(--accent-primary);
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Data Attributes
|
||||
|
||||
Components use data attributes for variants/states. Target them for state-specific overrides:
|
||||
|
||||
```scss
|
||||
// Target variant
|
||||
.wrapper :global([data-variant="outlined"]) {
|
||||
--button-border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
// Target size
|
||||
.wrapper :global([data-size="sm"]) {
|
||||
--button-font-size: var(--periscope-font-size-small);
|
||||
}
|
||||
|
||||
// Target color
|
||||
.wrapper :global([data-color="destructive"]) {
|
||||
--button-background: var(--danger-background);
|
||||
}
|
||||
|
||||
// Target state (Radix patterns)
|
||||
.popover :global([data-state="open"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip :global([data-side="top"]) {
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
```
|
||||
|
||||
### Common Component CSS Variables
|
||||
|
||||
**Button:**
|
||||
- `--button-background`, `--button-border-radius`, `--button-padding`
|
||||
- `--button-font-size`, `--button-height`, `--button-gap`
|
||||
- `--button-hover-background`, `--button-disabled-opacity`
|
||||
|
||||
**Input:**
|
||||
- `--input-height`, `--input-border-color`, `--input-background`
|
||||
- `--input-padding`, `--input-font-size`, `--input-placeholder-color`
|
||||
- `--input-focus-outline-color`, `--input-hover-border-color`
|
||||
- `--input-prefix-*`, `--input-suffix-*` for adornments
|
||||
|
||||
**General pattern:** `--<component>-<property>` or `--<component>-<state>-<property>`
|
||||
|
||||
## Good Patterns
|
||||
|
||||
### Structure
|
||||
|
||||
```scss
|
||||
// Flat, descriptive, component-scoped
|
||||
.alertHistory { }
|
||||
.alertHistoryHeader { }
|
||||
.alertHistoryContent { }
|
||||
.alertHistoryFooter { }
|
||||
|
||||
// State modifiers as separate classes
|
||||
.alertHistory { }
|
||||
.alertHistoryLoading { }
|
||||
.alertHistoryEmpty { }
|
||||
.alertHistoryError { }
|
||||
```
|
||||
|
||||
### Composition
|
||||
|
||||
```scss
|
||||
// GOOD: Composing styles
|
||||
.baseButton {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
composes: baseButton;
|
||||
background: var(--primary-background);
|
||||
}
|
||||
```
|
||||
|
||||
### Pseudo Elements
|
||||
|
||||
```scss
|
||||
.button {
|
||||
// States
|
||||
&:hover { opacity: 0.9; }
|
||||
&:focus { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
// Pseudo elements
|
||||
&::before { content: ''; }
|
||||
&::after { content: ''; }
|
||||
}
|
||||
```
|
||||
|
||||
### Media Queries
|
||||
|
||||
```scss
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JS Import Patterns
|
||||
|
||||
```tsx
|
||||
// GOOD
|
||||
import styles from './Component.module.scss';
|
||||
|
||||
<div className={styles.container}>
|
||||
<span className={styles.title}>Title</span>
|
||||
</div>
|
||||
|
||||
// GOOD: Conditional classes
|
||||
<div className={`${styles.button} ${isActive ? styles.buttonActive : ''}`}>
|
||||
|
||||
// GOOD: With clsx/classnames
|
||||
<div className={clsx(styles.button, { [styles.buttonActive]: isActive })}>
|
||||
|
||||
// BAD: Bracket access (may be undefined)
|
||||
<div className={styles['button-active']}> // undefined if CSS has .button-active
|
||||
|
||||
// BAD: String interpolation for class names
|
||||
<div className={`${styles.button}-active`}> // won't work
|
||||
```
|
||||
|
||||
## Checklist Before Committing
|
||||
|
||||
- [ ] All class names use camelCase in CSS
|
||||
- [ ] No bracket access (`styles['...']`) in JS unless verified
|
||||
- [ ] No deep class nesting (max 3 class levels; pseudo-classes/elements and parent-reference selectors like `&.active`, `&#bar` are not counted)
|
||||
- [ ] No hardcoded colors - use `--l1/l2/l3-*` semantic tokens (not `--bg-*` primitives)
|
||||
- [ ] No magic numbers - use `--spacing-*` tokens
|
||||
- [ ] Typography uses `--periscope-font-size-*` or `--font-size-*` tokens
|
||||
- [ ] @signozhq/ui overrides use CSS variables, not direct class overrides
|
||||
- [ ] Global escapes only for third-party overrides
|
||||
- [ ] No ID selectors
|
||||
- [ ] No bare element selectors
|
||||
|
||||
## Lint Rules
|
||||
|
||||
### JS/TS (oxlint)
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|------|----------|---------|
|
||||
| `signoz/no-css-module-bracket-access` | warn | `styles['kebab-case']`, dynamic access |
|
||||
|
||||
### CSS/SCSS (stylelint)
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|------|----------|---------|
|
||||
| `local/no-deep-nesting` | warning | class nesting >3 levels (pseudo-classes/elements and parent-reference selectors `&.foo`, `&#bar` not counted; configurable via `maxDepth` secondary option) |
|
||||
| `local/no-id-selectors` | error | `#id` selectors |
|
||||
| `local/no-bare-element-selectors` | error | root-level `div`, `span` etc |
|
||||
| `local/prefer-css-variables` | warning | hardcoded colors |
|
||||
| `local/class-name-pattern` | warning | kebab-case, snake_case, PascalCase |
|
||||
|
||||
Run: `pnpm lint:styles` to check CSS modules.
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Rule: no-css-module-bracket-access
|
||||
*
|
||||
* Prevents bracket access on CSS module imports that may fail with camelCaseOnly config.
|
||||
*
|
||||
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case class names are
|
||||
* converted to camelCase and the original key is NOT exported.
|
||||
*
|
||||
* This rule catches patterns like:
|
||||
* styles['my-class'] // BAD - undefined if CSS has .my-class
|
||||
* styles['myClass'] // OK but prefer dot notation
|
||||
* styles.myClass // GOOD
|
||||
*
|
||||
* Catches:
|
||||
* - Bracket access with kebab-case strings (always fails)
|
||||
* - Bracket access with any string literal (warn - prefer dot notation)
|
||||
* - Dynamic bracket access (warn - risky)
|
||||
*/
|
||||
|
||||
const CSS_MODULE_IMPORT_NAMES = new Set([
|
||||
'styles',
|
||||
'classes',
|
||||
'css',
|
||||
'classNames',
|
||||
]);
|
||||
|
||||
function looksLikeCssModuleImport(name) {
|
||||
// Common patterns: styles, componentStyles, alertHistoryStyles
|
||||
return (
|
||||
CSS_MODULE_IMPORT_NAMES.has(name) ||
|
||||
name.endsWith('Styles') ||
|
||||
name.endsWith('Classes') ||
|
||||
name.endsWith('Css')
|
||||
);
|
||||
}
|
||||
|
||||
function isKebabCase(str) {
|
||||
return str.includes('-');
|
||||
}
|
||||
|
||||
function isSnakeCase(str) {
|
||||
return str.includes('_');
|
||||
}
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
// Only check bracket notation: styles['...']
|
||||
if (!node.computed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const object = node.object;
|
||||
if (object.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this looks like a CSS module import
|
||||
if (!looksLikeCssModuleImport(object.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const property = node.property;
|
||||
|
||||
// Dynamic access: styles[variable]
|
||||
if (property.type === 'Identifier') {
|
||||
context.report({
|
||||
node,
|
||||
message: `Dynamic CSS module access '${object.name}[${property.name}]' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify the key exists.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Template literal: styles[\`...\`]
|
||||
if (property.type === 'TemplateLiteral') {
|
||||
context.report({
|
||||
node,
|
||||
message: `Template literal CSS module access is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Prefer dot notation.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric / boolean / null literal: styles[0]. Not a class lookup; ignore.
|
||||
if (property.type === 'Literal' && typeof property.value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// String literal: styles['...']
|
||||
if (property.type === 'Literal' && typeof property.value === 'string') {
|
||||
const className = property.value;
|
||||
|
||||
// Kebab-case will definitely fail
|
||||
if (isKebabCase(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `CSS module class '${className}' uses kebab-case which won't work with 'camelCaseOnly' config. Use '${object.name}.${toCamelCase(className)}' instead.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Snake_case is suspicious
|
||||
if (isSnakeCase(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `CSS module class '${className}' uses snake_case which may not work as expected. Prefer camelCase: '${object.name}.${toCamelCase(className)}'.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid camelCase but using bracket notation - prefer dot
|
||||
if (/^[a-z][a-zA-Z0-9]*$/.test(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Prefer dot notation: '${object.name}.${className}' instead of '${object.name}['${className}']'.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Catch-all for other dynamic expressions:
|
||||
// styles['prefix' + suffix] (BinaryExpression)
|
||||
// styles[isActive && 'foo'] (LogicalExpression)
|
||||
// styles[isActive ? 'a' : 'b'] (ConditionalExpression)
|
||||
// styles[fn()] (CallExpression)
|
||||
context.report({
|
||||
node,
|
||||
message: `Dynamic CSS module access on '${object.name}' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify each key resolves to an exported camelCase class.`,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function toCamelCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((part, i) =>
|
||||
i === 0
|
||||
? part.toLowerCase()
|
||||
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs'
|
||||
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -24,6 +23,5 @@ export default {
|
||||
'no-raw-absolute-path': noRawAbsolutePath,
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -351,19 +351,18 @@ function App(): JSX.Element {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tunnel: process.env.TUNNEL_URL,
|
||||
environment: 'production',
|
||||
environment: process.env.ENVIRONMENT,
|
||||
release: process.env.VERSION,
|
||||
integrations: [
|
||||
// Kept for the `transaction` tag used in routing, even though
|
||||
// tracing is disabled. Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: false,
|
||||
}),
|
||||
],
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
|
||||
tracePropagationTargets: [],
|
||||
// Session Replay
|
||||
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
beforeSend(event) {
|
||||
|
||||
@@ -36,6 +36,8 @@ import type {
|
||||
GetService200,
|
||||
GetServiceParams,
|
||||
GetServicePathParameters,
|
||||
ListAccountServicesMetadata200,
|
||||
ListAccountServicesMetadataPathParameters,
|
||||
ListAccounts200,
|
||||
ListAccountsPathParameters,
|
||||
ListServicesMetadata200,
|
||||
@@ -631,6 +633,116 @@ export const useUpdateAccount = <
|
||||
> => {
|
||||
return useMutation(getUpdateAccountMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists the services metadata for the specified account and cloud provider
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
export const listAccountServicesMetadata = (
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListAccountServicesMetadata200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListAccountServicesMetadataQueryKey = ({
|
||||
cloudProvider,
|
||||
id,
|
||||
}: ListAccountServicesMetadataPathParameters) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListAccountServicesMetadataQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getListAccountServicesMetadataQueryKey({ cloudProvider, id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>
|
||||
> = ({ signal }) => listAccountServicesMetadata({ cloudProvider, id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(cloudProvider && id),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListAccountServicesMetadataQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>
|
||||
>;
|
||||
export type ListAccountServicesMetadataQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
|
||||
export function useListAccountServicesMetadata<
|
||||
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListAccountServicesMetadataQueryOptions(
|
||||
{ cloudProvider, id },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
export const invalidateListAccountServicesMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListAccountServicesMetadataQueryKey({ cloudProvider, id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint updates a service for the specified cloud provider
|
||||
* @summary Update service
|
||||
|
||||
@@ -2457,33 +2457,6 @@ export interface CloudintegrationtypesAccountDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesDashboardDTO {
|
||||
definition?: DashboardtypesStorableDashboardDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAssetsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2866,6 +2839,54 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
providerAccountId?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
provider: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
integrationDashboard?: CloudintegrationtypesStorableIntegrationDashboardDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceAssetsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
dashboards: CloudintegrationtypesServiceDashboardDTO[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -2877,13 +2898,8 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
metrics?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
|
||||
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
|
||||
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDTO {
|
||||
assets: CloudintegrationtypesAssetsDTO;
|
||||
assets: CloudintegrationtypesServiceAssetsDTO;
|
||||
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO | null;
|
||||
dataCollected: CloudintegrationtypesDataCollectedDTO;
|
||||
/**
|
||||
@@ -2899,7 +2915,6 @@ export interface CloudintegrationtypesServiceDTO {
|
||||
*/
|
||||
overview: string;
|
||||
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
|
||||
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3705,6 +3720,10 @@ export interface DashboardtypesCustomVariableSpecDTO {
|
||||
customValue: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesSourceDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
@@ -8676,6 +8695,18 @@ export type UpdateAccountPathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
};
|
||||
export type ListAccountServicesMetadataPathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
};
|
||||
export type ListAccountServicesMetadata200 = {
|
||||
data: CloudintegrationtypesGettableServicesMetadataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { ApiV3Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { getWaterfallV4 } from 'api/generated/services/tracedetail';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
GetTraceV4PayloadProps,
|
||||
GetTraceV4SuccessResponse,
|
||||
SpanV3,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const getTraceV3 = async (
|
||||
props: GetTraceV3PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
|
||||
const getTraceV4 = async (
|
||||
props: GetTraceV4PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
@@ -19,31 +18,37 @@ const getTraceV3 = async (
|
||||
props.selectedSpanId &&
|
||||
!uncollapsedSpans.includes(props.selectedSpanId)
|
||||
) {
|
||||
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
|
||||
// Backend only uses the uncollapsedSpans list (unlike V2 which also interprets
|
||||
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
|
||||
uncollapsedSpans.push(props.selectedSpanId);
|
||||
}
|
||||
const postData: GetTraceV3PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
};
|
||||
const response = await axios.post<GetTraceV3SuccessResponse>(
|
||||
`/traces/${props.traceId}/waterfall`,
|
||||
omit(postData, 'traceId'),
|
||||
const response = await getWaterfallV4(
|
||||
{ traceID: props.traceId },
|
||||
{
|
||||
selectedSpanId: props.selectedSpanId,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
// V3 API wraps response in { status, data }
|
||||
const rawPayload = (response.data as any).data || response.data;
|
||||
// Generated client unwraps the axios response; .data is the waterfall payload.
|
||||
// Wire spans carry time_unix; SpanV3's timestamp + 'service.name' are derived below.
|
||||
type WireSpan = Omit<SpanV3, 'timestamp' | 'service.name'> & {
|
||||
time_unix: number;
|
||||
};
|
||||
const rawPayload = response.data as unknown as Omit<
|
||||
GetTraceV4SuccessResponse,
|
||||
'spans'
|
||||
> & { spans: WireSpan[] | null };
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
timestamp: span.time_unix,
|
||||
}));
|
||||
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
|
||||
// Convert by using the first span's timestamp as the base if there's a mismatch.
|
||||
let { startTimestampMillis, endTimestampMillis } = rawPayload;
|
||||
@@ -70,4 +75,4 @@ const getTraceV3 = async (
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV3;
|
||||
export default getTraceV4;
|
||||
@@ -33,7 +33,8 @@ export const REACT_QUERY_KEY = {
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
|
||||
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
|
||||
@@ -8,16 +8,16 @@ import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Skeleton } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
getListServicesMetadataQueryKey,
|
||||
getListAccountServicesMetadataQueryKey,
|
||||
invalidateGetService,
|
||||
invalidateListServicesMetadata,
|
||||
invalidateListAccountServicesMetadata,
|
||||
useGetService,
|
||||
useUpdateService,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesServiceConfigDTO,
|
||||
CloudintegrationtypesServiceDTO,
|
||||
ListServicesMetadata200,
|
||||
ListAccountServicesMetadata200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import CloudServiceDataCollected from 'components/CloudIntegrations/CloudServiceDataCollected/CloudServiceDataCollected';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
@@ -240,16 +240,12 @@ function ServiceDetails({
|
||||
// instead of waiting for the refetch to complete.
|
||||
reset(nextFormValues);
|
||||
|
||||
const servicesListQueryKey = getListServicesMetadataQueryKey(
|
||||
{
|
||||
cloudProvider: type,
|
||||
},
|
||||
{
|
||||
cloud_integration_id: cloudAccountId,
|
||||
},
|
||||
);
|
||||
const servicesListQueryKey = getListAccountServicesMetadataQueryKey({
|
||||
cloudProvider: type,
|
||||
id: cloudAccountId,
|
||||
});
|
||||
|
||||
queryClient.setQueryData<ListServicesMetadata200 | undefined>(
|
||||
queryClient.setQueryData<ListAccountServicesMetadata200 | undefined>(
|
||||
servicesListQueryKey,
|
||||
(prev) => {
|
||||
if (!prev?.data?.services?.length) {
|
||||
@@ -283,15 +279,10 @@ function ServiceDetails({
|
||||
},
|
||||
);
|
||||
|
||||
invalidateListServicesMetadata(
|
||||
queryClient,
|
||||
{
|
||||
cloudProvider: type,
|
||||
},
|
||||
{
|
||||
cloud_integration_id: cloudAccountId,
|
||||
},
|
||||
);
|
||||
invalidateListAccountServicesMetadata(queryClient, {
|
||||
cloudProvider: type,
|
||||
id: cloudAccountId,
|
||||
});
|
||||
|
||||
logEvent(`${type} Integration: Service settings saved`, {
|
||||
cloudAccountId,
|
||||
|
||||
@@ -55,7 +55,6 @@ const buildServiceDetailsResponse = (
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetryCollectionStrategy: { aws: {} },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { CloudintegrationtypesServiceDashboardDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
const DISABLED_TOOLTIP =
|
||||
'Enable metrics collection for this service to view this dashboard.';
|
||||
|
||||
function DashboardCard({
|
||||
dashboard,
|
||||
isInteractive,
|
||||
}: {
|
||||
dashboard: CloudintegrationtypesServiceDashboardDTO;
|
||||
isInteractive: boolean;
|
||||
}): JSX.Element {
|
||||
const dashboardId = dashboard.integrationDashboard?.dashboardId;
|
||||
const isClickable = Boolean(dashboardId) && isInteractive;
|
||||
const dashboardUrl = dashboardId ? `/dashboard/${dashboardId}` : '';
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const interactiveProps = isClickable
|
||||
? {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onClick: (event: MouseEvent<HTMLDivElement>): void => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(dashboardUrl);
|
||||
return;
|
||||
}
|
||||
safeNavigate(dashboardUrl);
|
||||
},
|
||||
onKeyDown: (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
safeNavigate(dashboardUrl);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
const card = (
|
||||
<div
|
||||
className={`aws-service-dashboard-item ${
|
||||
isClickable
|
||||
? 'aws-service-dashboard-item-clickable'
|
||||
: 'aws-service-dashboard-item-disabled'
|
||||
} `}
|
||||
{...interactiveProps}
|
||||
>
|
||||
<div className="aws-service-dashboard-item-content">
|
||||
<div className="aws-service-dashboard-item-title">{dashboard.title}</div>
|
||||
<div className="aws-service-dashboard-item-description">
|
||||
{dashboard.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dashboardId) {
|
||||
return <TooltipSimple title={DISABLED_TOOLTIP}>{card}</TooltipSimple>;
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
export default DashboardCard;
|
||||
@@ -53,6 +53,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.aws-service-dashboard-item-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.aws-service-dashboard-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
CloudintegrationtypesDashboardDTO,
|
||||
CloudintegrationtypesServiceDashboardDTO,
|
||||
CloudintegrationtypesServiceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import DashboardCard from './DashboardCard';
|
||||
import './ServiceDashboards.styles.scss';
|
||||
|
||||
function ServiceDashboards({
|
||||
@@ -16,7 +14,6 @@ function ServiceDashboards({
|
||||
isInteractive?: boolean;
|
||||
}): JSX.Element {
|
||||
const dashboards = service?.assets?.dashboards || [];
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
if (!dashboards.length) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -25,68 +22,20 @@ function ServiceDashboards({
|
||||
<div className="aws-service-dashboards">
|
||||
<div className="aws-service-dashboards-title">Dashboards</div>
|
||||
<div className="aws-service-dashboards-items">
|
||||
{dashboards.map((dashboard: CloudintegrationtypesDashboardDTO) => {
|
||||
if (!dashboard.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dashboardUrl = `/dashboard/${dashboard.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dashboard.id}
|
||||
className={`aws-service-dashboard-item ${
|
||||
isInteractive ? 'aws-service-dashboard-item-clickable' : ''
|
||||
}`}
|
||||
role={isInteractive ? 'button' : undefined}
|
||||
tabIndex={isInteractive ? 0 : -1}
|
||||
onClick={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
window.open(
|
||||
withBasePath(dashboardUrl),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
return;
|
||||
}
|
||||
safeNavigate(dashboardUrl);
|
||||
}}
|
||||
onAuxClick={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.button === 1) {
|
||||
window.open(
|
||||
withBasePath(dashboardUrl),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
safeNavigate(dashboardUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="aws-service-dashboard-item-content">
|
||||
<div className="aws-service-dashboard-item-title">
|
||||
{dashboard.title}
|
||||
</div>
|
||||
<div className="aws-service-dashboard-item-description">
|
||||
{dashboard.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dashboards.map(
|
||||
(dashboard: CloudintegrationtypesServiceDashboardDTO, index: number) => {
|
||||
const key =
|
||||
dashboard.integrationDashboard?.dashboardId ||
|
||||
`${dashboard.title}-${index}`;
|
||||
return (
|
||||
<DashboardCard
|
||||
key={key}
|
||||
dashboard={dashboard}
|
||||
isInteractive={isInteractive}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { Skeleton } from 'antd';
|
||||
import { useListServicesMetadata } from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
useListAccounts,
|
||||
useListAccountServicesMetadata,
|
||||
useListServicesMetadata,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import type { CloudintegrationtypesServiceMetadataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { IntegrationType } from 'container/Integrations/types';
|
||||
@@ -20,17 +24,33 @@ function ServicesList({
|
||||
}: ServicesListProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const navigate = useNavigate();
|
||||
const hasValidCloudAccountId = Boolean(cloudAccountId);
|
||||
const serviceQueryParams = hasValidCloudAccountId
|
||||
? { cloud_integration_id: cloudAccountId }
|
||||
: undefined;
|
||||
const isAccountConnected = Boolean(cloudAccountId);
|
||||
const { data: listAccountsResponse, isLoading: isAccountsLoading } =
|
||||
useListAccounts({ cloudProvider: type });
|
||||
const hasConnectedAccounts =
|
||||
(listAccountsResponse?.data?.accounts?.length ?? 0) > 0;
|
||||
|
||||
const { data: servicesMetadata, isLoading } = useListServicesMetadata(
|
||||
{
|
||||
cloudProvider: type,
|
||||
},
|
||||
serviceQueryParams,
|
||||
);
|
||||
const { data: accountServicesMetadata, isLoading: isAccountServicesLoading } =
|
||||
useListAccountServicesMetadata(
|
||||
{ cloudProvider: type, id: cloudAccountId },
|
||||
{ query: { enabled: isAccountConnected } },
|
||||
);
|
||||
|
||||
const {
|
||||
data: providerServicesMetadata,
|
||||
isLoading: isProviderServicesLoading,
|
||||
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
|
||||
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
|
||||
});
|
||||
|
||||
const servicesMetadata = hasConnectedAccounts
|
||||
? accountServicesMetadata
|
||||
: providerServicesMetadata;
|
||||
const isLoading =
|
||||
isAccountsLoading ||
|
||||
(hasConnectedAccounts
|
||||
? isAccountServicesLoading || !isAccountConnected
|
||||
: isProviderServicesLoading);
|
||||
|
||||
const awsServices = useMemo(
|
||||
() => servicesMetadata?.data?.services ?? [],
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getTraceAggregations } from 'api/generated/services/tracedetail';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import useGetTraceAggregations from '../useGetTraceAggregations';
|
||||
|
||||
jest.mock('api/generated/services/tracedetail', () => ({
|
||||
__esModule: true,
|
||||
getTraceAggregations: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'success', data: { aggregations: [] } }),
|
||||
}));
|
||||
|
||||
const mockApi = getTraceAggregations as jest.Mock;
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
const aggregations = [
|
||||
{ field: { name: 'service.name' }, aggregation: 'execution_time_percentage' },
|
||||
] as never;
|
||||
|
||||
describe('useGetTraceAggregations', () => {
|
||||
beforeEach(() => mockApi.mockClear());
|
||||
|
||||
it('fetches when enabled with a traceId and aggregations', async () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: true }),
|
||||
{ wrapper },
|
||||
);
|
||||
await waitFor(() => expect(mockApi).toHaveBeenCalledTimes(1));
|
||||
expect(mockApi).toHaveBeenCalledWith({ traceID: 't1' }, { aggregations });
|
||||
});
|
||||
|
||||
it('does not fetch when disabled', () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: false }),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(mockApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch without a traceId', () => {
|
||||
renderHook(
|
||||
() => useGetTraceAggregations({ traceId: '', aggregations, enabled: true }),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(mockApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch with no aggregations requested', () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useGetTraceAggregations({ traceId: 't1', aggregations: [], enabled: true }),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(mockApi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
34
frontend/src/hooks/trace/useGetTraceAggregations.tsx
Normal file
34
frontend/src/hooks/trace/useGetTraceAggregations.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
GetTraceAggregations200,
|
||||
SpantypesSpanAggregationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getTraceAggregations } from 'api/generated/services/tracedetail';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
interface UseGetTraceAggregationsProps {
|
||||
traceId: string;
|
||||
aggregations: SpantypesSpanAggregationDTO[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type UseGetTraceAggregations = UseQueryResult<GetTraceAggregations200>;
|
||||
|
||||
/**
|
||||
* Fetches trace aggregations on demand — gate via `enabled` so the request
|
||||
* fires only when the Analytics panel is open. The query key includes the
|
||||
* requested fields, so changing the color-by field refetches.
|
||||
*/
|
||||
const useGetTraceAggregations = ({
|
||||
traceId,
|
||||
aggregations,
|
||||
enabled,
|
||||
}: UseGetTraceAggregationsProps): UseGetTraceAggregations =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceAggregations({ traceID: traceId }, { aggregations }),
|
||||
queryKey: [REACT_QUERY_KEY.GET_TRACE_AGGREGATIONS, traceId, aggregations],
|
||||
enabled: enabled && !!traceId && aggregations.length > 0,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export default useGetTraceAggregations;
|
||||
@@ -1,30 +1,29 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceV3 from 'api/trace/getTraceV3';
|
||||
import getTraceV4 from 'api/trace/getTraceV4';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
GetTraceV4PayloadProps,
|
||||
GetTraceV4SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
|
||||
const useGetTraceV4 = (props: GetTraceV4PayloadProps): UseTraceV4 =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceV3(props),
|
||||
queryFn: () => getTraceV4(props),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
|
||||
REACT_QUERY_KEY.GET_TRACE_V4_WATERFALL,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
props.aggregations,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseTraceV3 = UseQueryResult<
|
||||
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
|
||||
type UseTraceV4 = UseQueryResult<
|
||||
SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceV3;
|
||||
export default useGetTraceV4;
|
||||
84
frontend/src/lib/__tests__/getLabelName.test.ts
Normal file
84
frontend/src/lib/__tests__/getLabelName.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
|
||||
describe('getLabelName', () => {
|
||||
describe('with a legend template', () => {
|
||||
it('substitutes a single variable that exists on the series', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend' },
|
||||
'A',
|
||||
'{{service.name}}',
|
||||
);
|
||||
expect(result).toBe('frontend');
|
||||
});
|
||||
|
||||
it('substitutes a template with surrounding literal text', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend' },
|
||||
'A',
|
||||
'rate for {{service.name}}',
|
||||
);
|
||||
expect(result).toBe('rate for frontend');
|
||||
});
|
||||
|
||||
it('substitutes multiple variables when all are present', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend', 'http.target': 'GET /api' },
|
||||
'A',
|
||||
'{{service.name}} / {{http.target}}',
|
||||
);
|
||||
expect(result).toBe('frontend / GET /api');
|
||||
});
|
||||
|
||||
it('falls back to query name when a referenced variable is missing', () => {
|
||||
const result = getLabelName(
|
||||
{ 'http.target': 'GET /api' },
|
||||
'F1',
|
||||
'{{service.name}}',
|
||||
);
|
||||
expect(result).toBe('F1');
|
||||
});
|
||||
|
||||
it('falls back to query name even if literal text would still render', () => {
|
||||
const result = getLabelName(
|
||||
{ 'http.target': 'GET /api' },
|
||||
'F1',
|
||||
'label = {{label}}',
|
||||
);
|
||||
expect(result).toBe('F1');
|
||||
});
|
||||
|
||||
it('falls back to query name when any of multiple variables is missing', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend' },
|
||||
'F1',
|
||||
'{{service.name}} / {{http.target}}',
|
||||
);
|
||||
expect(result).toBe('F1');
|
||||
});
|
||||
|
||||
it('treats a null label value as missing', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': null } as unknown as Record<string, string>,
|
||||
'F1',
|
||||
'{{service.name}}',
|
||||
);
|
||||
expect(result).toBe('F1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a legend template', () => {
|
||||
it('returns key="value" pairs for plain labels', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend', 'http.target': 'GET /api' },
|
||||
'A',
|
||||
'',
|
||||
);
|
||||
expect(result).toBe('{service.name="frontend",http.target="GET /api"}');
|
||||
});
|
||||
|
||||
it('returns query name when labels are empty', () => {
|
||||
const result = getLabelName({}, 'A', '');
|
||||
expect(result).toBe('A');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,17 @@ const getLabelName = (
|
||||
|
||||
const results = variables.map((variable) => metric[variable]);
|
||||
|
||||
// Fall back to query name if any `{{var}}` references a label that
|
||||
// isn't on this series — avoids rendering "undefined" in the legend.
|
||||
const hasMissingVariable = variables.some(
|
||||
(variable, index) =>
|
||||
legends.includes(`{{${variable}}}`) &&
|
||||
(results[index] === undefined || results[index] === null),
|
||||
);
|
||||
if (hasMissingVariable) {
|
||||
return query;
|
||||
}
|
||||
|
||||
let endResult = legends;
|
||||
|
||||
variables.forEach((e, index) => {
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import cx from 'classnames';
|
||||
import { DetailsHeader } from 'components/DetailsPanel';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
|
||||
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import {
|
||||
SpantypesSpanAggregationDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { useTraceStore } from '../../stores/traceStore';
|
||||
import {
|
||||
AGGREGATIONS,
|
||||
getAggregationMap as findAggregationMap,
|
||||
} from '../../utils/aggregations';
|
||||
import AnalyticsTabContent, { AnalyticsRow } from './AnalyticsTabContent';
|
||||
|
||||
import styles from './AnalyticsPanel.module.scss';
|
||||
|
||||
@@ -35,10 +42,31 @@ function AnalyticsPanel({
|
||||
onClose,
|
||||
onTabChange,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const aggregations = useTraceStore((s) => s.aggregations);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const colorByFieldName = colorByField.name;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Fetch exec-time % + span count for the current color-by field only, and
|
||||
// only while the panel is open. Changing the field refetches via the key.
|
||||
const aggregationsRequest = useMemo<SpantypesSpanAggregationDTO[]>(() => {
|
||||
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
|
||||
// the literal-union vs enum nominal types differ
|
||||
const field = colorByField as unknown as TelemetrytypesTelemetryFieldKeyDTO;
|
||||
return [
|
||||
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
|
||||
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
|
||||
];
|
||||
}, [colorByField]);
|
||||
|
||||
const { data, isLoading, isError } = useGetTraceAggregations({
|
||||
traceId: traceId || '',
|
||||
aggregations: aggregationsRequest,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const aggregations = data?.data.aggregations;
|
||||
|
||||
const execTimePct = useMemo(
|
||||
() =>
|
||||
findAggregationMap(
|
||||
@@ -55,38 +83,39 @@ function AnalyticsPanel({
|
||||
[aggregations, colorByFieldName],
|
||||
);
|
||||
|
||||
const execTimeRows = useMemo(() => {
|
||||
const execTimeRows = useMemo<AnalyticsRow[]>(() => {
|
||||
if (!execTimePct) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(execTimePct)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([group, percentage]) => {
|
||||
const pair = generateColorPair(group);
|
||||
return {
|
||||
group,
|
||||
percentage,
|
||||
color: isDarkMode ? pair.color : pair.colorDark,
|
||||
widthPct: Math.min(percentage, 100),
|
||||
label: `${percentage.toFixed(2)}%`,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.percentage - a.percentage);
|
||||
});
|
||||
}, [execTimePct, isDarkMode]);
|
||||
|
||||
const spanCountRows = useMemo(() => {
|
||||
const spanCountRows = useMemo<AnalyticsRow[]>(() => {
|
||||
if (!spanCounts) {
|
||||
return [];
|
||||
}
|
||||
const max = Math.max(...Object.values(spanCounts), 1);
|
||||
return Object.entries(spanCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([group, count]) => {
|
||||
const pair = generateColorPair(group);
|
||||
return {
|
||||
group,
|
||||
count,
|
||||
max,
|
||||
color: isDarkMode ? pair.color : pair.colorDark,
|
||||
widthPct: (count / max) * 100,
|
||||
label: String(count),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
}, [spanCounts, isDarkMode]);
|
||||
|
||||
if (!isOpen) {
|
||||
@@ -132,65 +161,23 @@ function AnalyticsPanel({
|
||||
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="exec-time">
|
||||
<div className={styles.list}>
|
||||
{execTimeRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.group}-dot`}
|
||||
className={styles.dot}
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span key={`${row.group}-name`} className={styles.serviceName}>
|
||||
{row.group}
|
||||
</span>
|
||||
<div key={`${row.group}-bar`} className={styles.barCell}>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={styles.barFill}
|
||||
style={{
|
||||
width: `${Math.min(row.percentage, 100)}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={cx(styles.value, styles.valueWide)}>
|
||||
{row.percentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<AnalyticsTabContent
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
fieldName={colorByFieldName}
|
||||
rows={execTimeRows}
|
||||
valueVariant="wide"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="spans">
|
||||
<div className={styles.list}>
|
||||
{spanCountRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.group}-dot`}
|
||||
className={styles.dot}
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span key={`${row.group}-name`} className={styles.serviceName}>
|
||||
{row.group}
|
||||
</span>
|
||||
<div key={`${row.group}-bar`} className={styles.barCell}>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={styles.barFill}
|
||||
style={{
|
||||
width: `${(row.count / row.max) * 100}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={cx(styles.value, styles.valueNarrow)}>
|
||||
{row.count}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<AnalyticsTabContent
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
fieldName={colorByFieldName}
|
||||
rows={spanCountRows}
|
||||
valueVariant="narrow"
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import Spinner from 'components/Spinner';
|
||||
|
||||
import styles from './AnalyticsPanel.module.scss';
|
||||
|
||||
export interface AnalyticsRow {
|
||||
group: string;
|
||||
color: string;
|
||||
widthPct: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AnalyticsTabContentProps {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
fieldName: string;
|
||||
rows: AnalyticsRow[];
|
||||
valueVariant: 'wide' | 'narrow';
|
||||
}
|
||||
|
||||
// Loading / error / empty render in place of the rows so the tabs stay visible.
|
||||
function AnalyticsTabContent({
|
||||
isLoading,
|
||||
isError,
|
||||
fieldName,
|
||||
rows,
|
||||
valueVariant,
|
||||
}: AnalyticsTabContentProps): JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Spinner height="auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Typography.Text>Couldn't load analytics</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Typography.Text>No data for {fieldName}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{rows.map((row) => (
|
||||
<Fragment key={row.group}>
|
||||
<div className={styles.dot} style={{ backgroundColor: row.color }} />
|
||||
<span className={styles.serviceName}>{row.group}</span>
|
||||
<div className={styles.barCell}>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={styles.barFill}
|
||||
style={{
|
||||
width: `${row.widthPct}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cx(
|
||||
styles.value,
|
||||
valueVariant === 'wide' ? styles.valueWide : styles.valueNarrow,
|
||||
)}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsTabContent;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { DEFAULT_COLOR_BY_FIELD } from '../../../constants';
|
||||
import { useTraceStore } from '../../../stores/traceStore';
|
||||
import AnalyticsPanel from '../AnalyticsPanel';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): { id: string } => ({ id: 'trace-123' }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/trace/useGetTraceAggregations', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Isolate the panel's own logic from the floating-panel chrome.
|
||||
jest.mock('periscope/components/FloatingPanel', () => ({
|
||||
__esModule: true,
|
||||
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('components/DetailsPanel', () => ({
|
||||
__esModule: true,
|
||||
DetailsHeader: (): JSX.Element => <div data-testid="details-header" />,
|
||||
}));
|
||||
jest.mock('components/Spinner', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
const mockHook = useGetTraceAggregations as jest.Mock;
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
const renderPanel = (isOpen = true): ReturnType<typeof render> =>
|
||||
render(<AnalyticsPanel isOpen={isOpen} onClose={noop} onTabChange={noop} />);
|
||||
|
||||
const aggregationsResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
aggregations: [
|
||||
{
|
||||
field: { name: 'service.name' },
|
||||
aggregation: 'execution_time_percentage',
|
||||
value: { api: 80, db: 20 },
|
||||
},
|
||||
{
|
||||
field: { name: 'service.name' },
|
||||
aggregation: 'span_count',
|
||||
value: { api: 5, db: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('AnalyticsPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockHook.mockReset();
|
||||
useTraceStore.setState({ colorByField: DEFAULT_COLOR_BY_FIELD });
|
||||
});
|
||||
|
||||
it('renders nothing when closed and does not enable the fetch', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const { container } = renderPanel(false);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('requests both aggregations for the current color-by field when open', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
renderPanel();
|
||||
expect(mockHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
traceId: 'trace-123',
|
||||
enabled: true,
|
||||
aggregations: [
|
||||
{
|
||||
field: DEFAULT_COLOR_BY_FIELD,
|
||||
aggregation: 'execution_time_percentage',
|
||||
},
|
||||
{ field: DEFAULT_COLOR_BY_FIELD, aggregation: 'span_count' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the loading state with the tabs still visible', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
renderPanel();
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
// tabs stay visible while loading
|
||||
expect(screen.getByText('% exec time')).toBeInTheDocument();
|
||||
expect(screen.getByText('Spans')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error state when the request fails', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
renderPanel();
|
||||
expect(screen.getByText(/couldn't load analytics/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders rows for the current field on success', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: aggregationsResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
renderPanel();
|
||||
expect(screen.getByText('api')).toBeInTheDocument();
|
||||
expect(screen.getByText('80.00%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty state when the field has no data', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: { status: 'success', data: { aggregations: [] } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
renderPanel();
|
||||
expect(screen.getByText(/no data for service.name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
// Applied to both the menu content and the submenu content (each renders in
|
||||
// its own portal with a default z-index of 50) so both stack above
|
||||
// FloatingPanel (z-index 999).
|
||||
.traceOptionsDropdown {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { Settings2 } from '@signozhq/icons';
|
||||
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
@@ -14,6 +23,9 @@ interface TraceOptionsMenuProps {
|
||||
onOpenPreviewFields: () => void;
|
||||
}
|
||||
|
||||
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
|
||||
// because the simple preset offers no way to style the submenu content,
|
||||
// which renders in its own portal and needs a z-index above FloatingPanel.
|
||||
function TraceOptionsMenu({
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
@@ -25,78 +37,52 @@ function TraceOptionsMenu({
|
||||
(s) => s.availableColorByOptions,
|
||||
);
|
||||
|
||||
const menuItems: MenuItem[] = useMemo(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'toggle-trace-details',
|
||||
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
|
||||
onClick: onToggleTraceDetails,
|
||||
},
|
||||
{
|
||||
key: 'preview-fields',
|
||||
label: 'Preview fields',
|
||||
onClick: onOpenPreviewFields,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show the "Colour by" submenu if there's an actual choice to make.
|
||||
if (availableColorByOptions.length > 1) {
|
||||
items.push({
|
||||
key: 'colour-by',
|
||||
label: 'Colour by',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
label: 'COLOUR BY',
|
||||
children: [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: colorByField.name,
|
||||
onChange: (name: string): void => {
|
||||
const next = availableColorByOptions.find(
|
||||
(o) => o.field.name === name,
|
||||
);
|
||||
if (next) {
|
||||
setColorByField(next.field);
|
||||
}
|
||||
},
|
||||
children: availableColorByOptions.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.field.name,
|
||||
label: opt.label,
|
||||
value: opt.field.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const handleColorByChange = (name: string): void => {
|
||||
const next = availableColorByOptions.find((o) => o.field.name === name);
|
||||
if (next) {
|
||||
setColorByField(next.field);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
onOpenPreviewFields,
|
||||
colorByField.name,
|
||||
setColorByField,
|
||||
availableColorByOptions,
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
align="start"
|
||||
className={styles.traceOptionsDropdown}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Trace options"
|
||||
prefix={<Settings2 size={14} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Trace options"
|
||||
prefix={<Settings2 size={14} />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={styles.traceOptionsDropdown}>
|
||||
<DropdownMenuItem clickable onSelect={onToggleTraceDetails}>
|
||||
{showTraceDetails ? 'Hide trace details' : 'Show trace details'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem clickable onSelect={onOpenPreviewFields}>
|
||||
Preview fields
|
||||
</DropdownMenuItem>
|
||||
{/* Only show the "Colour by" submenu if there's an actual choice to make. */}
|
||||
{availableColorByOptions.length > 1 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Colour by</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={styles.traceOptionsDropdown}>
|
||||
<DropdownMenuLabel>COLOUR BY</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={colorByField.name}
|
||||
onValueChange={handleColorByChange}
|
||||
>
|
||||
{availableColorByOptions.map((opt) => (
|
||||
<DropdownMenuRadioItem key={opt.field.name} value={opt.field.name}>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { computeVisualLayout } from '../computeVisualLayout';
|
||||
import {
|
||||
computeVisualLayout,
|
||||
WIDE_GROUP_THRESHOLD,
|
||||
} from '../computeVisualLayout';
|
||||
|
||||
function makeSpan(
|
||||
overrides: Partial<FlamegraphSpan> & {
|
||||
@@ -472,4 +475,177 @@ describe('computeVisualLayout', () => {
|
||||
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
|
||||
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
|
||||
});
|
||||
|
||||
// --- Wide-group fast path (> WIDE_GROUP_THRESHOLD siblings) ---
|
||||
// Past the threshold the layout switches to exact overlap-only packing to
|
||||
// avoid the O(N^2) connector-avoidance spiral. These lock in correctness and
|
||||
// the no-overlap invariant at scale.
|
||||
|
||||
function noRowHasOverlap(
|
||||
layout: ReturnType<typeof computeVisualLayout>,
|
||||
): void {
|
||||
for (const row of layout.visualRows) {
|
||||
const sorted = [...row].sort((a, b) => a.timestamp - b.timestamp);
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prevEnd = sorted[i - 1].timestamp + sorted[i - 1].durationNano / 1e6;
|
||||
expect(sorted[i].timestamp).toBeGreaterThanOrEqual(prevEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should pack thousands of sequential leaf siblings into 1 row (wide path)', () => {
|
||||
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
|
||||
const kids: FlamegraphSpan[] = [];
|
||||
// 2000 strictly sequential (non-overlapping) children
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
kids.push(
|
||||
makeSpan({
|
||||
spanId: `k${i}`,
|
||||
parentSpanId: 'root',
|
||||
timestamp: i * 10,
|
||||
durationNano: 5e6, // 5ms, ends before next starts
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const layout = computeVisualLayout([[root], kids]);
|
||||
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.totalVisualRows).toBe(2); // all siblings share row 1
|
||||
for (const k of kids) {
|
||||
expect(layout.spanToVisualRow[k.spanId]).toBe(1);
|
||||
}
|
||||
noRowHasOverlap(layout);
|
||||
});
|
||||
|
||||
it('should pack thousands of fully-overlapping leaf siblings without violations (wide path)', () => {
|
||||
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
|
||||
const kids: FlamegraphSpan[] = [];
|
||||
// 1000 children all spanning the same window → each needs its own row
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
kids.push(
|
||||
makeSpan({
|
||||
spanId: `k${i}`,
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const layout = computeVisualLayout([[root], kids]);
|
||||
|
||||
expect(layout.totalVisualRows).toBe(1001); // root + 1000 stacked rows
|
||||
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1001);
|
||||
noRowHasOverlap(layout);
|
||||
});
|
||||
|
||||
it('should keep non-leaf subtrees adjacent within a wide mixed group (wide path)', () => {
|
||||
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
|
||||
const kids: FlamegraphSpan[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
kids.push(
|
||||
makeSpan({
|
||||
spanId: `k${i}`,
|
||||
parentSpanId: 'root',
|
||||
timestamp: i * 10,
|
||||
durationNano: 5e6,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// One of the wide siblings has a child of its own
|
||||
const grandchild = makeSpan({
|
||||
spanId: 'gc',
|
||||
parentSpanId: 'k500',
|
||||
timestamp: 5000,
|
||||
durationNano: 2e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], kids, [grandchild]]);
|
||||
|
||||
const parentRow = layout.spanToVisualRow['k500'];
|
||||
const gcRow = layout.spanToVisualRow['gc'];
|
||||
expect(gcRow - parentRow).toBe(1); // subtree adjacency preserved
|
||||
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1002);
|
||||
noRowHasOverlap(layout);
|
||||
});
|
||||
|
||||
// --- Regression guards for the wide-trace layout spiral ---
|
||||
// The pre-fix algorithm's connector-avoidance checks formed a positive-
|
||||
// feedback loop on wide SCATTERED groups (short spans spread across the
|
||||
// parent window with modest concurrency): each pushed-down child stamped
|
||||
// connector points on intermediate rows, pushing later children even
|
||||
// higher. Sequential and fully-overlapping groups (tests above) do NOT
|
||||
// trigger it — these two shapes do, and encode its failure signatures.
|
||||
|
||||
function makeScatteredStar(
|
||||
childCount: number,
|
||||
spacingMs: number,
|
||||
durationMs: number,
|
||||
): FlamegraphSpan[][] {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: (childCount * spacingMs + durationMs) * 1e6,
|
||||
});
|
||||
const kids: FlamegraphSpan[] = [];
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
kids.push(
|
||||
makeSpan({
|
||||
spanId: `k${i}`,
|
||||
parentSpanId: 'root',
|
||||
timestamp: i * spacingMs,
|
||||
durationNano: durationMs * 1e6,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return [[root], kids];
|
||||
}
|
||||
|
||||
it('should not spiral row count on a scattered group just above the threshold', () => {
|
||||
// Children of 50ms each, starting every 10ms → max temporal concurrency
|
||||
// is 6 (each span overlaps the next 5). The old algorithm spiraled this
|
||||
// shape to ~1 row per child; overlap-only packing needs ~7 including the
|
||||
// root. Sized just above WIDE_GROUP_THRESHOLD so a regression fails fast
|
||||
// (sub-second) rather than burning CI time.
|
||||
const count = WIDE_GROUP_THRESHOLD + 88;
|
||||
const layout = computeVisualLayout(makeScatteredStar(count, 10, 50));
|
||||
|
||||
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(count + 1);
|
||||
// Generous slack over the optimal ~7 — but orders of magnitude below the
|
||||
// one-row-per-child the spiral produced.
|
||||
expect(layout.totalVisualRows).toBeLessThan(30);
|
||||
noRowHasOverlap(layout);
|
||||
});
|
||||
|
||||
it('should be faster through the fast path despite one extra span', () => {
|
||||
// Identical scattered shape on both sides of the gate: THRESHOLD children
|
||||
// run the original connector-avoidance path, THRESHOLD + 1 run the
|
||||
// overlap-only fast path. With one MORE span to place, the fast path can
|
||||
// only win because the algorithm is cheaper — a self-calibrating
|
||||
// comparison (same machine, same process) with no absolute time budget.
|
||||
// On this shape the avoidance path spirals (~tens of ms) while the fast
|
||||
// path stays ~1ms, so the margin is well past timer noise. Also guards
|
||||
// the gate itself: if everything routed to one path, one extra span can
|
||||
// never be faster.
|
||||
const avoidancePathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD, 10, 50);
|
||||
const fastPathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD + 1, 10, 50);
|
||||
|
||||
// Warm-up runs so JIT compilation doesn't skew either side.
|
||||
computeVisualLayout(avoidancePathInput);
|
||||
computeVisualLayout(fastPathInput);
|
||||
|
||||
const timeOf = (input: FlamegraphSpan[][]): number => {
|
||||
const start = performance.now();
|
||||
computeVisualLayout(input);
|
||||
return performance.now() - start;
|
||||
};
|
||||
|
||||
// Best-of-3 per side to shave off GC pauses and scheduler noise.
|
||||
const RUNS = [0, 1, 2];
|
||||
const avoidanceMs = Math.min(...RUNS.map(() => timeOf(avoidancePathInput)));
|
||||
const fastMs = Math.min(...RUNS.map(() => timeOf(fastPathInput)));
|
||||
|
||||
expect(fastMs).toBeLessThan(avoidanceMs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,82 @@ export interface VisualLayout {
|
||||
totalVisualRows: number;
|
||||
}
|
||||
|
||||
// Above this many siblings under one parent, the connector-avoidance refinement
|
||||
// (Checks 2 & 3) is both visually meaningless — the row is already a dense wall —
|
||||
// and quadratic: every child deposits a connector point on each intermediate row,
|
||||
// which pushes later children even higher, which deposits more points. That
|
||||
// feedback loop inflates a layout needing ~50 rows to thousands and never
|
||||
// finishes on wide traces. Past the threshold we pack by overlap only.
|
||||
// Exported so the regression tests stay anchored to the real gate value.
|
||||
export const WIDE_GROUP_THRESHOLD = 512;
|
||||
|
||||
/**
|
||||
* Segment tree over rows that answers "lowest row index >= `from` whose smallest
|
||||
* span start-time is >= `end`" in O(log rows). Used to place a large group of
|
||||
* leaf siblings by overlap only: because siblings are processed in descending
|
||||
* start order, every already-placed span on a row starts at or after the current
|
||||
* one, so [start, end] overlaps a row iff some span there starts before `end` —
|
||||
* i.e. the row is free iff its minimum start >= end. Each node stores the max of
|
||||
* its subtree's per-row minimum starts so a free row can be found by descent.
|
||||
*/
|
||||
class LowestFreeRow {
|
||||
private readonly size: number;
|
||||
|
||||
private readonly tree: Float64Array;
|
||||
|
||||
constructor(rows: number) {
|
||||
let size = 1;
|
||||
while (size < rows) {
|
||||
size *= 2;
|
||||
}
|
||||
this.size = size;
|
||||
this.tree = new Float64Array(size * 2).fill(Infinity);
|
||||
}
|
||||
|
||||
place(row: number, start: number): void {
|
||||
let i = row + this.size;
|
||||
// A row's key is the minimum start among its spans. Children are processed
|
||||
// in descending start order so a leaf's start is the new minimum, but a
|
||||
// non-leaf subtree's descendant can land on a row out of order — take min.
|
||||
if (start >= this.tree[i]) {
|
||||
return;
|
||||
}
|
||||
this.tree[i] = start;
|
||||
for (i >>= 1; i >= 1; i >>= 1) {
|
||||
const next = Math.max(this.tree[2 * i], this.tree[2 * i + 1]);
|
||||
if (this.tree[i] === next) {
|
||||
break;
|
||||
}
|
||||
this.tree[i] = next;
|
||||
}
|
||||
}
|
||||
|
||||
lowestFrom(from: number, end: number): number {
|
||||
return this.descend(1, 0, this.size - 1, from, end);
|
||||
}
|
||||
|
||||
private descend(
|
||||
node: number,
|
||||
lo: number,
|
||||
hi: number,
|
||||
from: number,
|
||||
end: number,
|
||||
): number {
|
||||
if (hi < from || this.tree[node] < end) {
|
||||
return -1;
|
||||
}
|
||||
if (lo === hi) {
|
||||
return lo;
|
||||
}
|
||||
const mid = (lo + hi) >> 1;
|
||||
const left = this.descend(2 * node, lo, mid, from, end);
|
||||
if (left !== -1) {
|
||||
return left;
|
||||
}
|
||||
return this.descend(2 * node + 1, mid + 1, hi, from, end);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
|
||||
*
|
||||
@@ -214,7 +290,53 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
arr.push(point);
|
||||
}
|
||||
|
||||
// Fast path for a parent with a very large group of children: pack by overlap
|
||||
// only (descending greedy), skipping the quadratic connector-avoidance that
|
||||
// spirals at this scale. Leaf children — the bulk of a wide trace — are placed
|
||||
// in O(log rows) via the segment tree; the rare non-leaf subtree falls back to
|
||||
// findPlacement against the shared interval map. Both structures are kept in
|
||||
// sync so each placement sees all prior occupancy. Same ShapeEntry[] contract.
|
||||
function computeWideShape(
|
||||
rootSpan: FlamegraphSpan,
|
||||
children: FlamegraphSpan[],
|
||||
): ShapeEntry[] {
|
||||
const shape: ShapeEntry[] = [{ span: rootSpan, relativeRow: 0 }];
|
||||
const localIntervals = new Map<number, Array<[number, number]>>();
|
||||
// Children occupy relative rows 1..children.length in the worst case.
|
||||
const finder = new LowestFreeRow(children.length + 2);
|
||||
|
||||
const occupy = (row: number, span: FlamegraphSpan): void => {
|
||||
const s = span.timestamp;
|
||||
const e = span.timestamp + span.durationNano / 1e6;
|
||||
shape.push({ span, relativeRow: row });
|
||||
addIntervalTo(localIntervals, row, s, e);
|
||||
finder.place(row, s);
|
||||
};
|
||||
|
||||
for (const child of children) {
|
||||
if (childrenMap.has(child.spanId)) {
|
||||
// Non-leaf: place its whole subtree shape as a unit via findPlacement.
|
||||
const childShape = computeSubtreeShape(child);
|
||||
const offset = findPlacement(childShape, 1, localIntervals);
|
||||
for (const entry of childShape) {
|
||||
occupy(entry.relativeRow + offset, entry.span);
|
||||
}
|
||||
} else {
|
||||
const end = child.timestamp + child.durationNano / 1e6;
|
||||
occupy(finder.lowestFrom(1, end), child);
|
||||
}
|
||||
}
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
|
||||
const children = childrenMap.get(rootSpan.spanId);
|
||||
|
||||
if (children && children.length > WIDE_GROUP_THRESHOLD) {
|
||||
return computeWideShape(rootSpan, children);
|
||||
}
|
||||
|
||||
const localIntervals = new Map<number, Array<[number, number]>>();
|
||||
const localConnectorPoints = new Map<number, number[]>();
|
||||
const shape: ShapeEntry[] = [];
|
||||
@@ -225,7 +347,6 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
shape.push({ span: rootSpan, relativeRow: 0 });
|
||||
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
|
||||
|
||||
const children = childrenMap.get(rootSpan.spanId);
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
const childShape = computeSubtreeShape(child);
|
||||
|
||||
@@ -94,7 +94,7 @@ export function useVisualLayoutWorker(spans: FlamegraphSpan[][]): {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
// Timeout: if worker doesn't respond in 30s, terminate and error
|
||||
// Timeout: if worker doesn't respond in 15s, terminate and error
|
||||
const WORKER_TIMEOUT_MS = 15000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (requestIdRef.current === currentId && isComputingRef.current) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetTraceV3SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { GetTraceV4SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceWaterfallStates } from './constants';
|
||||
import Error from './TraceWaterfallStates/Error/Error';
|
||||
@@ -22,7 +22,7 @@ interface ITraceWaterfallProps {
|
||||
localUncollapsedNodes: Set<string>;
|
||||
setLocalUncollapsedNodes: Dispatch<SetStateAction<Set<string>>>;
|
||||
traceData:
|
||||
| SuccessResponse<GetTraceV3SuccessResponse, unknown>
|
||||
| SuccessResponse<GetTraceV4SuccessResponse, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
isFetchingTraceData: boolean;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
const span = (partial: Partial<SpanV3>): SpanV3 =>
|
||||
({ level: 1, resource: {}, attributes: {}, ...partial }) as SpanV3;
|
||||
|
||||
describe('getAvailableColorByFieldNames', () => {
|
||||
it('returns [] for an empty span set', () => {
|
||||
expect(getAvailableColorByFieldNames([])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('offers a field if any span carries it, in option order', () => {
|
||||
const spans = [
|
||||
span({ resource: { 'service.name': 'api' } }),
|
||||
// k8s.node.name lives on a non-root span — still offered
|
||||
span({ resource: { 'k8s.node.name': 'node-1' } }),
|
||||
];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual([
|
||||
'service.name',
|
||||
'k8s.node.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads from attributes when the key is not on resource', () => {
|
||||
const spans = [span({ attributes: { 'host.name': 'box-1' } })];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
|
||||
});
|
||||
|
||||
it('does not offer fields no span carries', () => {
|
||||
const spans = [span({ resource: { 'service.name': 'api' } })];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);
|
||||
});
|
||||
});
|
||||
@@ -8,23 +8,17 @@ import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV3 from 'hooks/trace/useGetTraceV3';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import NoData from 'pages/TraceDetailV2/NoData/NoData';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import {
|
||||
SpanV3,
|
||||
TraceDetailV3URLProps,
|
||||
WaterfallAggregationRequest,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { COLOR_BY_FIELDS } from './constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { AGGREGATIONS } from './utils/aggregations';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
@@ -34,6 +28,7 @@ import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from './TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from './TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from './utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
@@ -103,17 +98,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
|
||||
// upfront so a future color-by-field switch doesn't need to refetch.
|
||||
const waterfallAggregationsRequest = useMemo<WaterfallAggregationRequest[]>(
|
||||
() =>
|
||||
COLOR_BY_FIELDS.flatMap((field) => [
|
||||
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
|
||||
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
|
||||
]),
|
||||
[],
|
||||
);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
@@ -121,7 +105,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
@@ -130,19 +113,17 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV3({
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
aggregations: queryParams.aggregations,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
@@ -150,6 +131,13 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
@@ -157,7 +145,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -382,7 +369,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<TraceStoreSync aggregations={traceData?.payload?.aggregations}>
|
||||
<TraceStoreSync availableColorByFields={availableColorByFields}>
|
||||
<div className={styles.root}>
|
||||
<TraceDetailsHeader
|
||||
filterMetadata={filterMetadata}
|
||||
|
||||
@@ -2,21 +2,20 @@ import { ReactNode, useEffect } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { WaterfallAggregationResponse } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import {
|
||||
setTraceStoreAggregations,
|
||||
setTraceStoreAvailableColorByFields,
|
||||
setTraceStoreCallbacks,
|
||||
setTraceStoreUserPreferences,
|
||||
} from './traceStore';
|
||||
|
||||
interface TraceStoreSyncProps {
|
||||
aggregations: WaterfallAggregationResponse[] | undefined;
|
||||
availableColorByFields: string[] | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges React-managed inputs (the `aggregations` prop, `userPreferences`
|
||||
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
|
||||
* from AppContext, and the user-pref mutation hook) into the Zustand store.
|
||||
*
|
||||
* Renders nothing until `userPreferences` resolves so the flamegraph never
|
||||
@@ -25,15 +24,15 @@ interface TraceStoreSyncProps {
|
||||
* is logged in, so this gate is usually already settled by mount time.
|
||||
*/
|
||||
function TraceStoreSync({
|
||||
aggregations,
|
||||
availableColorByFields,
|
||||
children,
|
||||
}: TraceStoreSyncProps): JSX.Element | null {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAggregations(aggregations);
|
||||
}, [aggregations]);
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreUserPreferences(userPreferences ?? null);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
import { COLOR_BY_OPTIONS, DEFAULT_COLOR_BY_FIELD } from '../../constants';
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
setTraceStoreUserPreferences,
|
||||
useTraceStore,
|
||||
} from '../traceStore';
|
||||
|
||||
const colorByPref = (fieldName: string): UserPreference[] => [
|
||||
{
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
|
||||
value: fieldName,
|
||||
} as UserPreference,
|
||||
];
|
||||
|
||||
const optionNames = (): string[] =>
|
||||
useTraceStore.getState().availableColorByOptions.map((o) => o.field.name);
|
||||
|
||||
describe('traceStore color-by gating', () => {
|
||||
beforeEach(() => {
|
||||
useTraceStore.setState({
|
||||
availableColorByFieldNames: undefined,
|
||||
userPreferences: null,
|
||||
colorByField: DEFAULT_COLOR_BY_FIELD,
|
||||
availableColorByOptions: COLOR_BY_OPTIONS.filter(
|
||||
(o) => o.field.name === DEFAULT_COLOR_BY_FIELD.name,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('offers only the default field before spans load', () => {
|
||||
expect(optionNames()).toStrictEqual([DEFAULT_COLOR_BY_FIELD.name]);
|
||||
expect(useTraceStore.getState().colorByField).toStrictEqual(
|
||||
DEFAULT_COLOR_BY_FIELD,
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the default plus any field present on loaded spans', () => {
|
||||
setTraceStoreAvailableColorByFields(['host.name']);
|
||||
expect(optionNames()).toStrictEqual([
|
||||
DEFAULT_COLOR_BY_FIELD.name,
|
||||
'host.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors the persisted color-by field when it is available', () => {
|
||||
setTraceStoreAvailableColorByFields(['host.name']);
|
||||
setTraceStoreUserPreferences(colorByPref('host.name'));
|
||||
expect(useTraceStore.getState().colorByField.name).toBe('host.name');
|
||||
});
|
||||
|
||||
it('falls back to the default when the persisted field is not available', () => {
|
||||
setTraceStoreUserPreferences(colorByPref('host.name'));
|
||||
setTraceStoreAvailableColorByFields(['k8s.node.name']);
|
||||
expect(useTraceStore.getState().colorByField.name).toBe(
|
||||
DEFAULT_COLOR_BY_FIELD.name,
|
||||
);
|
||||
expect(optionNames()).toStrictEqual([
|
||||
DEFAULT_COLOR_BY_FIELD.name,
|
||||
'k8s.node.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('trusts the persisted field while spans are still loading', () => {
|
||||
// availableColorByFieldNames stays undefined (loading) — do not flip to default
|
||||
setTraceStoreUserPreferences(colorByPref('host.name'));
|
||||
expect(useTraceStore.getState().colorByField.name).toBe('host.name');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { WaterfallAggregationResponse } from 'types/api/trace/getTraceV3';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { create } from 'zustand';
|
||||
|
||||
@@ -11,10 +10,6 @@ import {
|
||||
ColorByOption,
|
||||
DEFAULT_COLOR_BY_FIELD,
|
||||
} from '../constants';
|
||||
import {
|
||||
AGGREGATIONS,
|
||||
getAggregationMap as findAggregationMap,
|
||||
} from '../utils/aggregations';
|
||||
import { toTelemetryFieldKey } from '../utils/previewFields';
|
||||
|
||||
interface MutateOptions {
|
||||
@@ -30,7 +25,9 @@ type MutateUserPreference = (
|
||||
|
||||
interface TraceStoreState {
|
||||
// --- Inputs synced from React layer via TraceStoreSync ---
|
||||
aggregations: WaterfallAggregationResponse[] | undefined;
|
||||
// Fields present on loaded spans; gates color-by options. `undefined` while
|
||||
// loading so we keep trusting the persisted field.
|
||||
availableColorByFieldNames: string[] | undefined;
|
||||
userPreferences: UserPreference[] | null;
|
||||
updateUserPreferenceInContext: UpdateUserPreferenceInContext | null;
|
||||
mutateUserPreference: MutateUserPreference | null;
|
||||
@@ -41,9 +38,7 @@ interface TraceStoreState {
|
||||
previewFields: TelemetryFieldKey[];
|
||||
|
||||
// --- Setters used only by TraceStoreSync ---
|
||||
setAggregations: (
|
||||
aggregations: WaterfallAggregationResponse[] | undefined,
|
||||
) => void;
|
||||
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
|
||||
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
|
||||
setCallbacks: (callbacks: {
|
||||
updateUserPreferenceInContext: UpdateUserPreferenceInContext;
|
||||
@@ -71,23 +66,18 @@ function getPersistedColorByField(
|
||||
|
||||
/**
|
||||
* Re-derives `colorByField` + `availableColorByOptions` from the two inputs.
|
||||
* Preserves the "trust persisted while aggregations load" rule so the
|
||||
* flamegraph doesn't repaint when the aggregations response arrives.
|
||||
* Preserves the "trust persisted while spans load" rule so the flamegraph
|
||||
* doesn't repaint when the waterfall response arrives.
|
||||
*/
|
||||
function deriveColorState(
|
||||
aggregations: WaterfallAggregationResponse[] | undefined,
|
||||
availableColorByFieldNames: string[] | undefined,
|
||||
userPreferences: UserPreference[] | null,
|
||||
): Pick<TraceStoreState, 'colorByField' | 'availableColorByOptions'> {
|
||||
const isFieldAvailable = (fieldName: string): boolean => {
|
||||
if (fieldName === DEFAULT_COLOR_BY_FIELD.name) {
|
||||
return true;
|
||||
}
|
||||
const map = findAggregationMap(
|
||||
aggregations,
|
||||
AGGREGATIONS.EXEC_TIME_PCT,
|
||||
fieldName,
|
||||
);
|
||||
return !!map && Object.keys(map).length > 0;
|
||||
return !!availableColorByFieldNames?.includes(fieldName);
|
||||
};
|
||||
|
||||
const availableColorByOptions = COLOR_BY_OPTIONS.filter((opt) =>
|
||||
@@ -95,10 +85,10 @@ function deriveColorState(
|
||||
);
|
||||
|
||||
const persistedColorByField = getPersistedColorByField(userPreferences);
|
||||
// While aggregations are loading, trust persisted — don't flip to default
|
||||
// just because we haven't confirmed availability yet.
|
||||
// While loading, trust persisted — don't flip to default prematurely.
|
||||
const colorByField =
|
||||
aggregations === undefined || isFieldAvailable(persistedColorByField.name)
|
||||
availableColorByFieldNames === undefined ||
|
||||
isFieldAvailable(persistedColorByField.name)
|
||||
? persistedColorByField
|
||||
: DEFAULT_COLOR_BY_FIELD;
|
||||
|
||||
@@ -134,7 +124,7 @@ function derivePreviewFields(
|
||||
}
|
||||
|
||||
export const useTraceStore = create<TraceStoreState>()((set, get) => ({
|
||||
aggregations: undefined,
|
||||
availableColorByFieldNames: undefined,
|
||||
userPreferences: null,
|
||||
updateUserPreferenceInContext: null,
|
||||
mutateUserPreference: null,
|
||||
@@ -145,19 +135,19 @@ export const useTraceStore = create<TraceStoreState>()((set, get) => ({
|
||||
),
|
||||
previewFields: [],
|
||||
|
||||
setAggregations: (aggregations): void => {
|
||||
setAvailableColorByFields: (availableColorByFieldNames): void => {
|
||||
const { userPreferences } = get();
|
||||
set({
|
||||
aggregations,
|
||||
...deriveColorState(aggregations, userPreferences),
|
||||
availableColorByFieldNames,
|
||||
...deriveColorState(availableColorByFieldNames, userPreferences),
|
||||
});
|
||||
},
|
||||
|
||||
setUserPreferences: (userPreferences): void => {
|
||||
const { aggregations } = get();
|
||||
const { availableColorByFieldNames } = get();
|
||||
set({
|
||||
userPreferences,
|
||||
...deriveColorState(aggregations, userPreferences),
|
||||
...deriveColorState(availableColorByFieldNames, userPreferences),
|
||||
previewFields: derivePreviewFields(userPreferences),
|
||||
});
|
||||
},
|
||||
@@ -235,9 +225,9 @@ export const useTraceStore = create<TraceStoreState>()((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const setTraceStoreAggregations = (
|
||||
aggregations: WaterfallAggregationResponse[] | undefined,
|
||||
): void => useTraceStore.getState().setAggregations(aggregations);
|
||||
export const setTraceStoreAvailableColorByFields = (
|
||||
fieldNames: string[] | undefined,
|
||||
): void => useTraceStore.getState().setAvailableColorByFields(fieldNames);
|
||||
|
||||
export const setTraceStoreUserPreferences = (
|
||||
userPreferences: UserPreference[] | null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { COLOR_BY_OPTIONS } from './constants';
|
||||
import {
|
||||
ColorPair,
|
||||
generateColorPair,
|
||||
@@ -109,3 +110,14 @@ export function resolveSpanColor(
|
||||
}
|
||||
return generateColorPair(getSpanGroupValue(span, colorByFieldName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Color-by fields present on any of the given spans — replaces the old
|
||||
* server-side aggregation gating. `service.name` is always offered by the store
|
||||
* regardless of this list.
|
||||
*/
|
||||
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
|
||||
return COLOR_BY_OPTIONS.filter((opt) =>
|
||||
spans.some((s) => getSpanAttribute(s, opt.field.name)),
|
||||
).map((opt) => opt.field.name);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
WaterfallAggregationResponse,
|
||||
WaterfallAggregationType,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
SpantypesSpanAggregationResultDTO,
|
||||
SpantypesSpanAggregationTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const AGGREGATIONS = {
|
||||
EXEC_TIME_PCT: 'execution_time_percentage',
|
||||
SPAN_COUNT: 'span_count',
|
||||
DURATION: 'duration',
|
||||
} as const satisfies Record<string, WaterfallAggregationType>;
|
||||
EXEC_TIME_PCT: SpantypesSpanAggregationTypeDTO.execution_time_percentage,
|
||||
SPAN_COUNT: SpantypesSpanAggregationTypeDTO.span_count,
|
||||
DURATION: SpantypesSpanAggregationTypeDTO.duration,
|
||||
} as const;
|
||||
|
||||
export function getAggregationMap(
|
||||
aggregations: WaterfallAggregationResponse[] | undefined,
|
||||
type: WaterfallAggregationType,
|
||||
aggregations: SpantypesSpanAggregationResultDTO[] | undefined,
|
||||
type: SpantypesSpanAggregationTypeDTO,
|
||||
fieldName: string,
|
||||
): Record<string, number> | undefined {
|
||||
): Record<string, number> | null | undefined {
|
||||
return aggregations?.find(
|
||||
(a) => a.aggregation === type && a.field.name === fieldName,
|
||||
)?.value;
|
||||
|
||||
@@ -53,20 +53,20 @@ export default function WorkspaceBlocked(): JSX.Element {
|
||||
const { t } = useTranslation(['workspaceLocked']);
|
||||
|
||||
useEffect((): void => {
|
||||
logEvent('Workspace Blocked: Screen Viewed', {});
|
||||
void logEvent('Workspace Blocked: Screen Viewed', {});
|
||||
}, []);
|
||||
|
||||
const handleContactUsClick = (): void => {
|
||||
logEvent('Workspace Blocked: Contact Us Clicked', {});
|
||||
void logEvent('Workspace Blocked: Contact Us Clicked', {});
|
||||
};
|
||||
|
||||
const handleTabClick = (key: string): void => {
|
||||
logEvent('Workspace Blocked: Screen Tabs Clicked', { tabKey: key });
|
||||
void logEvent('Workspace Blocked: Screen Tabs Clicked', { tabKey: key });
|
||||
};
|
||||
|
||||
const handleCollapseChange = (key: string | string[]): void => {
|
||||
const lastKey = Array.isArray(key) ? key.slice(-1)[0] : key;
|
||||
logEvent('Workspace Blocked: Screen Tab FAQ Item Clicked', {
|
||||
void logEvent('Workspace Blocked: Screen Tab FAQ Item Clicked', {
|
||||
panelKey: lastKey,
|
||||
});
|
||||
};
|
||||
@@ -109,7 +109,7 @@ export default function WorkspaceBlocked(): JSX.Element {
|
||||
);
|
||||
|
||||
const handleUpdateCreditCard = useCallback(async () => {
|
||||
logEvent('Workspace Blocked: User Clicked Update Credit Card', {});
|
||||
void logEvent('Workspace Blocked: User Clicked Update Credit Card', {});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
@@ -117,7 +117,7 @@ export default function WorkspaceBlocked(): JSX.Element {
|
||||
}, [updateCreditCard]);
|
||||
|
||||
const handleExtendTrial = (): void => {
|
||||
logEvent('Workspace Blocked: User Clicked Extend Trial', {});
|
||||
void logEvent('Workspace Blocked: User Clicked Extend Trial', {});
|
||||
|
||||
notifications.info({
|
||||
message: t('extendTrial'),
|
||||
@@ -133,7 +133,7 @@ export default function WorkspaceBlocked(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleViewBilling = (e?: React.MouseEvent): void => {
|
||||
logEvent('Workspace Blocked: User Clicked View Billing', {});
|
||||
void logEvent('Workspace Blocked: User Clicked View Billing', {});
|
||||
|
||||
safeNavigate(ROUTES.BILLING, { newTab: !!e && isModifierKeyPressed(e) });
|
||||
};
|
||||
|
||||
@@ -6,14 +6,12 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { getFormattedDateWithMinutes } from 'utils/timeUtils';
|
||||
|
||||
import featureGraphicCorrelationUrl from '@/assets/Images/feature-graphic-correlation.svg';
|
||||
|
||||
@@ -109,15 +107,6 @@ function WorkspaceSuspended(): JSX.Element {
|
||||
</Typography.Title>
|
||||
<Typography.Text className="workspace-suspended__details">
|
||||
{t('actionDescription')}
|
||||
<br />
|
||||
{t('yourDataIsSafe')}{' '}
|
||||
<span className="workspace-suspended__details__highlight">
|
||||
{getFormattedDateWithMinutes(
|
||||
dayjs(activeLicense?.event_queue?.scheduled_at).unix() ||
|
||||
Date.now(),
|
||||
)}
|
||||
</span>{' '}
|
||||
{t('actNow')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export type WaterfallAggregationType =
|
||||
| 'span_count'
|
||||
| 'execution_time_percentage'
|
||||
| 'duration';
|
||||
|
||||
export interface WaterfallAggregationRequest {
|
||||
field: TelemetryFieldKey;
|
||||
aggregation: WaterfallAggregationType;
|
||||
}
|
||||
|
||||
export interface WaterfallAggregationResponse extends WaterfallAggregationRequest {
|
||||
value: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface GetTraceV3PayloadProps {
|
||||
export interface GetTraceV4PayloadProps {
|
||||
traceId: string;
|
||||
selectedSpanId: string;
|
||||
uncollapsedSpans: string[];
|
||||
isSelectedSpanIDUnCollapsed: boolean;
|
||||
limit?: number; // Optional limit for number of spans to fetch, default can be set in API
|
||||
aggregations?: WaterfallAggregationRequest[];
|
||||
}
|
||||
|
||||
export interface TraceDetailV3URLProps {
|
||||
@@ -88,7 +71,7 @@ export interface SpanV3 {
|
||||
trace_state: string;
|
||||
}
|
||||
|
||||
export interface GetTraceV3SuccessResponse {
|
||||
export interface GetTraceV4SuccessResponse {
|
||||
spans: SpanV3[];
|
||||
hasMissingSpans: boolean;
|
||||
uncollapsedSpans: string[];
|
||||
@@ -98,5 +81,4 @@ export interface GetTraceV3SuccessResponse {
|
||||
totalErrorSpansCount: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
aggregations?: WaterfallAggregationResponse[];
|
||||
}
|
||||
|
||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@@ -24,6 +24,8 @@ interface ImportMetaEnv {
|
||||
readonly VITE_TUNNEL_URL: string;
|
||||
readonly VITE_TUNNEL_DOMAIN: string;
|
||||
readonly VITE_DOCS_BASE_URL: string;
|
||||
readonly VITE_ENVIRONMENT: string;
|
||||
readonly VITE_VERSION: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/class-name-pattern
|
||||
*
|
||||
* Enforces camelCase class names in CSS modules.
|
||||
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case is converted but
|
||||
* using camelCase directly avoids confusion.
|
||||
*
|
||||
* BAD:
|
||||
* .my-class { } // converted to myClass, but confusing
|
||||
* .my_class { } // converted to myClass, but confusing
|
||||
* .MyClass { } // PascalCase not conventional
|
||||
*
|
||||
* GOOD:
|
||||
* .myClass { }
|
||||
* .alertHistory { }
|
||||
* .statsCard { }
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/class-name-pattern';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
kebabCase: (className) =>
|
||||
`Class "${className}" uses kebab-case. Use camelCase instead: "${toCamelCase(className)}".`,
|
||||
snakeCase: (className) =>
|
||||
`Class "${className}" uses snake_case. Use camelCase instead: "${toCamelCase(className)}".`,
|
||||
pascalCase: (className) =>
|
||||
`Class "${className}" uses PascalCase. Use camelCase instead: "${className.charAt(0).toLowerCase() + className.slice(1)}".`,
|
||||
});
|
||||
|
||||
function toCamelCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((part, i) =>
|
||||
i === 0
|
||||
? part.toLowerCase()
|
||||
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isKebabCase(str) {
|
||||
return str.includes('-');
|
||||
}
|
||||
|
||||
function isSnakeCase(str) {
|
||||
return str.includes('_');
|
||||
}
|
||||
|
||||
function isPascalCase(str) {
|
||||
return /^[A-Z][a-zA-Z0-9]*$/.test(str);
|
||||
}
|
||||
|
||||
const CLASS_PATTERN = /\.([a-zA-Z_][a-zA-Z0-9_-]*)/g;
|
||||
|
||||
const DEFAULT_THIRD_PARTY_PREFIXES = [
|
||||
'ant-', // Ant Design
|
||||
'rc-', // rc-components (Ant Design internals)
|
||||
'recharts-', // Recharts
|
||||
'uplot-', // uPlot
|
||||
'u-', // uPlot legacy
|
||||
'leaflet-', // Leaflet
|
||||
'monaco-', // Monaco editor
|
||||
'react-resizable', // react-resizable
|
||||
'cm-', // CodeMirror
|
||||
];
|
||||
|
||||
// Bare `:global { ... }` block (no parens) makes all descendants global.
|
||||
// `:global(.foo)` is a per-selector escape — handled separately by globalRanges().
|
||||
function hasBareGlobalAncestor(node) {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
const selector = current.selector;
|
||||
if (selector && /:global(?!\s*\()/.test(selector)) {
|
||||
return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return list of [start, end) index ranges inside `selector` that fall within a
|
||||
// balanced `:global(...)` argument list. Class matches inside these ranges are
|
||||
// third-party and should be skipped.
|
||||
function globalRanges(selector) {
|
||||
const ranges = [];
|
||||
const re = /:global\s*\(/g;
|
||||
let match;
|
||||
while ((match = re.exec(selector)) !== null) {
|
||||
const argStart = match.index + match[0].length;
|
||||
let depth = 1;
|
||||
let i = argStart;
|
||||
while (i < selector.length && depth > 0) {
|
||||
const ch = selector[i];
|
||||
if (ch === '(') {
|
||||
depth++;
|
||||
} else if (ch === ')') {
|
||||
depth--;
|
||||
}
|
||||
if (depth > 0) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
ranges.push([argStart, i]);
|
||||
re.lastIndex = i;
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function indexInRanges(index, ranges) {
|
||||
for (const [start, end] of ranges) {
|
||||
if (index >= start && index < end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rule = (primaryOption, secondaryOptions) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userPrefixes =
|
||||
(secondaryOptions && secondaryOptions.ignoreThirdPartyPrefixes) || [];
|
||||
const allPrefixes = [...DEFAULT_THIRD_PARTY_PREFIXES, ...userPrefixes];
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
const selector = ruleNode.selector;
|
||||
|
||||
// Bare `:global { }` block makes all descendants global — skip entirely.
|
||||
if (hasBareGlobalAncestor(ruleNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ranges = globalRanges(selector);
|
||||
|
||||
let match;
|
||||
CLASS_PATTERN.lastIndex = 0;
|
||||
|
||||
while ((match = CLASS_PATTERN.exec(selector)) !== null) {
|
||||
const className = match[1];
|
||||
// Skip classes inside `:global(...)` ranges of this selector.
|
||||
if (indexInRanges(match.index, ranges)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip third-party library classes
|
||||
if (allPrefixes.some((prefix) => className.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isKebabCase(className)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.kebabCase(className),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
} else if (isSnakeCase(className)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.snakeCase(className),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
} else if (isPascalCase(className)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.pascalCase(className),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-bare-element-selectors
|
||||
*
|
||||
* Prevents bare element selectors at root level in CSS modules.
|
||||
* Bare elements affect ALL instances of that element within component scope,
|
||||
* often unintentionally.
|
||||
*
|
||||
* BAD:
|
||||
* div { }
|
||||
* span { padding: 4px; }
|
||||
* p { margin: 0; }
|
||||
*
|
||||
* GOOD:
|
||||
* .container { }
|
||||
* .title { }
|
||||
*
|
||||
* ALLOWED (nested under class):
|
||||
* .container {
|
||||
* p { margin: 0; } // Scoped to .container
|
||||
* }
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/no-bare-element-selectors';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
unexpected: (element) =>
|
||||
`Bare element selector "${element}" at root level affects all instances. Use class selector or nest under a class.`,
|
||||
unexpectedInCompound: (element, full) =>
|
||||
`Bare element "${element}" in unscoped selector "${full}" at root level matches every "<${element}>" in the module. Anchor the selector with a class (e.g., ".container ${element}") or use :global() if intentional.`,
|
||||
});
|
||||
|
||||
const ELEMENT_PATTERN = /^[a-z][a-z0-9]*$/i;
|
||||
const EXCLUDED_ELEMENTS = new Set(['html', 'body', 'root']);
|
||||
|
||||
function isPartBareElement(part) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
// Strip pseudo suffixes (`:hover`, `::before`) — element part is what's before
|
||||
const elementOnly = trimmed.split(':')[0];
|
||||
if (!elementOnly) {
|
||||
return false;
|
||||
}
|
||||
// Skip class/id/attribute parts
|
||||
if (/[.#[]/.test(elementOnly)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ELEMENT_PATTERN.test(elementOnly) &&
|
||||
!EXCLUDED_ELEMENTS.has(elementOnly.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function isBareElement(selector) {
|
||||
const trimmed = selector.trim();
|
||||
// Skip combined selectors
|
||||
if (/[,\s>+~]/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
// Skip pseudo-selectors
|
||||
if (trimmed.includes(':')) {
|
||||
return false;
|
||||
}
|
||||
// Skip class/id/attribute selectors
|
||||
if (/[.#[]/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ELEMENT_PATTERN.test(trimmed) && !EXCLUDED_ELEMENTS.has(trimmed.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Split a compound selector on combinators (`>`, `+`, `~`, descendant space)
|
||||
// while keeping each part intact. Returns an array of selector parts.
|
||||
function splitOnCombinators(selector) {
|
||||
return selector
|
||||
.split(/\s*[>+~]\s*|\s+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isInsideGlobal(selector) {
|
||||
return /:global\s*\(/.test(selector);
|
||||
}
|
||||
|
||||
const KEYFRAME_ATRULES = new Set([
|
||||
'keyframes',
|
||||
'-webkit-keyframes',
|
||||
'-moz-keyframes',
|
||||
'-o-keyframes',
|
||||
]);
|
||||
|
||||
// Rule's effective parent is root if all ancestors above it are atrules
|
||||
// (e.g. `@media`, `@supports`). A bare `div` inside `@media { }` at top level
|
||||
// still matches every `<div>` in the module. Exclude `@keyframes` — its
|
||||
// `from`/`to`/`0%` children are not element selectors.
|
||||
function isEffectivelyTopLevel(node) {
|
||||
let parent = node.parent;
|
||||
while (parent && parent.type === 'atrule') {
|
||||
if (KEYFRAME_ATRULES.has(parent.name.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return Boolean(parent) && parent.type === 'root';
|
||||
}
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
if (!isEffectivelyTopLevel(ruleNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectors = ruleNode.selector.split(',').map((s) => s.trim());
|
||||
|
||||
for (const selector of selectors) {
|
||||
if (isInsideGlobal(selector)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBareElement(selector)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.unexpected(selector),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check combined selectors part-by-part. Only flag when the compound
|
||||
// has NO class/id anchor — `.container > div` is already scoped, but
|
||||
// `div + span` at root affects every matching descendant in the module.
|
||||
if (/[\s>+~]/.test(selector) && !/[.#]/.test(selector)) {
|
||||
const parts = splitOnCombinators(selector);
|
||||
for (const part of parts) {
|
||||
if (isPartBareElement(part)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.unexpectedInCompound(part.split(':')[0], selector),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-deep-nesting
|
||||
*
|
||||
* Prevents deep nesting in CSS modules (max 3 levels).
|
||||
* Deep nesting creates specificity wars and hard-to-override styles.
|
||||
*
|
||||
* BAD:
|
||||
* .container { .wrapper { .inner { .content { } } } } // 4 levels
|
||||
*
|
||||
* GOOD:
|
||||
* .container { }
|
||||
* .containerWrapper { }
|
||||
* .containerContent { }
|
||||
*
|
||||
* Allowed nesting (pseudo-classes/elements):
|
||||
* .button { &:hover { } &::before { } }
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/no-deep-nesting';
|
||||
const DEFAULT_MAX_DEPTH = 3;
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
tooDeep: (depth, max) =>
|
||||
`Nesting depth ${depth} exceeds maximum of ${max}. Flatten selectors for CSS modules.`,
|
||||
});
|
||||
|
||||
function isPseudoSelector(selector) {
|
||||
return /^&?:/.test(selector.trim());
|
||||
}
|
||||
|
||||
function isParentReference(selector) {
|
||||
return /^&[.#[]/.test(selector.trim());
|
||||
}
|
||||
|
||||
function countNestingDepth(rule, depth = 0) {
|
||||
const selector = rule.selector || '';
|
||||
|
||||
// Don't count pseudo-selectors or parent references toward depth
|
||||
if (isPseudoSelector(selector) || isParentReference(selector)) {
|
||||
// Still check children
|
||||
let maxChildDepth = depth;
|
||||
rule.walkRules?.((child) => {
|
||||
const childDepth = countNestingDepth(child, depth);
|
||||
maxChildDepth = Math.max(maxChildDepth, childDepth);
|
||||
});
|
||||
return maxChildDepth;
|
||||
}
|
||||
|
||||
const currentDepth = depth + 1;
|
||||
let maxDepth = currentDepth;
|
||||
|
||||
rule.walkRules?.((child) => {
|
||||
const childDepth = countNestingDepth(child, currentDepth);
|
||||
maxDepth = Math.max(maxDepth, childDepth);
|
||||
});
|
||||
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
const rule = (primaryOption, secondaryOptions) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxDepth =
|
||||
secondaryOptions && Number.isInteger(secondaryOptions.maxDepth)
|
||||
? secondaryOptions.maxDepth
|
||||
: DEFAULT_MAX_DEPTH;
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
// Only check top-level rules
|
||||
if (ruleNode.parent.type !== 'root' && ruleNode.parent.type !== 'atrule') {
|
||||
return;
|
||||
}
|
||||
|
||||
const depth = countNestingDepth(ruleNode);
|
||||
|
||||
if (depth > maxDepth) {
|
||||
stylelint.utils.report({
|
||||
message: messages.tooDeep(depth, maxDepth),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-id-selectors
|
||||
*
|
||||
* Prevents ID selectors in CSS modules.
|
||||
* IDs create high specificity, can't be reused, and defeat CSS modules scoping purpose.
|
||||
*
|
||||
* BAD:
|
||||
* #myComponent { }
|
||||
* .container #header { }
|
||||
*
|
||||
* GOOD:
|
||||
* .myComponent { }
|
||||
* .containerHeader { }
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/no-id-selectors';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
unexpected: (selector) =>
|
||||
`ID selector "${selector}" not allowed in CSS modules. Use class selector instead.`,
|
||||
});
|
||||
|
||||
const ID_PATTERN = /#[a-zA-Z_][a-zA-Z0-9_-]*/g;
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
const selector = ruleNode.selector;
|
||||
const matches = selector.match(ID_PATTERN);
|
||||
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
stylelint.utils.report({
|
||||
message: messages.unexpected(match),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/prefer-css-variables
|
||||
*
|
||||
* Warns on hardcoded colors in CSS modules.
|
||||
* Use CSS variables for consistent theming.
|
||||
*
|
||||
* BAD:
|
||||
* color: #ff0000;
|
||||
* background: rgb(255, 0, 0);
|
||||
* border: 1px solid blue;
|
||||
*
|
||||
* GOOD:
|
||||
* color: var(--l1-foreground);
|
||||
* background: var(--primary-background);
|
||||
* border: 1px solid var(--l2-border);
|
||||
*
|
||||
* ALLOWED:
|
||||
* transparent, inherit, currentColor, none
|
||||
* Colors inside var() fallbacks
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/prefer-css-variables';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
hardcodedColor: (value, property) =>
|
||||
`Hardcoded color "${value}" in "${property}". Use a semantic CSS variable instead (e.g., var(--l1-foreground), var(--primary-background)). See docs/css-modules-guide.md.`,
|
||||
});
|
||||
|
||||
const COLOR_PROPERTIES = new Set([
|
||||
'color',
|
||||
'background',
|
||||
'background-color',
|
||||
'background-image',
|
||||
'border',
|
||||
'border-color',
|
||||
'border-top',
|
||||
'border-right',
|
||||
'border-bottom',
|
||||
'border-left',
|
||||
'border-top-color',
|
||||
'border-right-color',
|
||||
'border-bottom-color',
|
||||
'border-left-color',
|
||||
'border-image',
|
||||
'border-image-source',
|
||||
'outline',
|
||||
'outline-color',
|
||||
'box-shadow',
|
||||
'text-shadow',
|
||||
'fill',
|
||||
'stroke',
|
||||
'caret-color',
|
||||
'text-decoration-color',
|
||||
'column-rule-color',
|
||||
'mask',
|
||||
'mask-image',
|
||||
]);
|
||||
|
||||
const ALLOWED_VALUES = new Set([
|
||||
'transparent',
|
||||
'inherit',
|
||||
'initial',
|
||||
'unset',
|
||||
'currentcolor',
|
||||
'none',
|
||||
'auto',
|
||||
]);
|
||||
|
||||
const HEX_PATTERN = /#[0-9a-fA-F]{3,8}\b/;
|
||||
const RGB_PATTERN = /rgba?\s*\([^)]+\)/i;
|
||||
const HSL_PATTERN = /hsla?\s*\([^)]+\)/i;
|
||||
const NAMED_COLOR_PATTERN =
|
||||
/\b(red|blue|green|yellow|orange|purple|pink|black|white|gray|grey|cyan|magenta|brown|navy|teal|olive|maroon|lime|aqua|fuchsia|silver)\b/i;
|
||||
|
||||
// Strip balanced `fn(...)` sections (e.g. `var(...)`, `url(...)`) from value.
|
||||
// Handles nested parens by counting depth.
|
||||
function stripBalancedFn(value, fnName) {
|
||||
const needle = `${fnName}(`;
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < value.length) {
|
||||
if (value.startsWith(needle, i)) {
|
||||
let depth = 1;
|
||||
i += needle.length;
|
||||
while (i < value.length && depth > 0) {
|
||||
const ch = value[i];
|
||||
if (ch === '(') {
|
||||
depth++;
|
||||
} else if (ch === ')') {
|
||||
depth--;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
out += value[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function containsHardcodedColor(value) {
|
||||
// Strip url(...) first — paths/fragments may contain hex-like or color-named substrings
|
||||
let scanned = value.includes('url(') ? stripBalancedFn(value, 'url') : value;
|
||||
|
||||
// Strip var(...) — color tokens inside var fallbacks are allowed
|
||||
if (scanned.includes('var(')) {
|
||||
scanned = stripBalancedFn(scanned, 'var');
|
||||
if (!scanned.trim()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const lower = scanned.toLowerCase();
|
||||
if (ALLOWED_VALUES.has(lower)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX_PATTERN.test(scanned)) {
|
||||
return scanned.match(HEX_PATTERN)[0];
|
||||
}
|
||||
if (RGB_PATTERN.test(scanned)) {
|
||||
return scanned.match(RGB_PATTERN)[0];
|
||||
}
|
||||
if (HSL_PATTERN.test(scanned)) {
|
||||
return scanned.match(HSL_PATTERN)[0];
|
||||
}
|
||||
if (NAMED_COLOR_PATTERN.test(scanned)) {
|
||||
return scanned.match(NAMED_COLOR_PATTERN)[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkDecls((decl) => {
|
||||
const prop = decl.prop.toLowerCase();
|
||||
|
||||
if (!COLOR_PROPERTIES.has(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hardcodedColor = containsHardcodedColor(decl.value);
|
||||
|
||||
if (hardcodedColor) {
|
||||
stylelint.utils.report({
|
||||
message: messages.hardcodedColor(hardcodedColor, decl.prop),
|
||||
node: decl,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -82,11 +82,20 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
];
|
||||
|
||||
if (env.VITE_SENTRY_AUTH_TOKEN) {
|
||||
// Refuse to upload sourcemaps without an explicit version.
|
||||
if (!env.VITE_VERSION) {
|
||||
throw new Error(
|
||||
'VITE_VERSION must be set to upload sourcemaps to Sentry; refusing to upload without a matching release.',
|
||||
);
|
||||
}
|
||||
plugins.push(
|
||||
sentryVitePlugin({
|
||||
authToken: env.VITE_SENTRY_AUTH_TOKEN,
|
||||
org: env.VITE_SENTRY_ORG,
|
||||
project: env.VITE_SENTRY_PROJECT_ID,
|
||||
// Pin the sourcemap-upload release to the same value injected as
|
||||
// process.env.VERSION so uploaded sourcemaps resolve. Ref: platform-pod#2393
|
||||
release: { name: env.VITE_VERSION },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -155,6 +164,8 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
'process.env.TUNNEL_URL': JSON.stringify(env.VITE_TUNNEL_URL),
|
||||
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
|
||||
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
|
||||
'process.env.ENVIRONMENT': JSON.stringify(env.VITE_ENVIRONMENT),
|
||||
'process.env.VERSION': JSON.stringify(env.VITE_VERSION),
|
||||
},
|
||||
// In production, use relative paths so assets work with any base path injected by the backend.
|
||||
// In dev, use the configured base path for proper HMR and routing.
|
||||
|
||||
@@ -151,6 +151,26 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListAccountServicesMetadata),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListAccountServicesMetadata",
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "List account services metadata",
|
||||
Description: "This endpoint lists the services metadata for the specified account and cloud provider",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.GettableServicesMetadata),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetService),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -75,6 +75,7 @@ type Handler interface {
|
||||
UpdateAccount(http.ResponseWriter, *http.Request)
|
||||
DisconnectAccount(http.ResponseWriter, *http.Request)
|
||||
ListServicesMetadata(http.ResponseWriter, *http.Request)
|
||||
ListAccountServicesMetadata(http.ResponseWriter, *http.Request)
|
||||
GetService(http.ResponseWriter, *http.Request)
|
||||
UpdateService(http.ResponseWriter, *http.Request)
|
||||
AgentCheckIn(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -276,6 +276,44 @@ func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Req
|
||||
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableServicesMetadata(services))
|
||||
}
|
||||
|
||||
func (handler *handler) ListAccountServicesMetadata(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check if integration account exists and is not removed.
|
||||
_, err = handler.module.GetConnectedAccount(ctx, valuer.MustNewUUID(claims.OrgID), accountID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
services, err := handler.module.ListServicesMetadata(ctx, valuer.MustNewUUID(claims.OrgID), provider, accountID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableServicesMetadata(services))
|
||||
}
|
||||
|
||||
func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -440,4 +478,3 @@ func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableAgentCheckIn(provider, resp))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,16 @@ var (
|
||||
type StorableIntegrationDashboard struct {
|
||||
bun.BaseModel `bun:"table:integration_dashboard"`
|
||||
|
||||
ID string `bun:"id,pk,type:text"`
|
||||
DashboardID string `bun:"dashboard_id,type:text"`
|
||||
Provider IntegrationDashboardProviderType `bun:"provider,type:text"`
|
||||
Slug string `bun:"slug,type:text"`
|
||||
CreatedAt time.Time `bun:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at"`
|
||||
ID string `json:"id" bun:"id,pk,type:text" required:"true"`
|
||||
DashboardID string `json:"dashboardId" bun:"dashboard_id,type:text" required:"true"`
|
||||
Provider IntegrationDashboardProviderType `json:"provider" bun:"provider,type:text" required:"true"`
|
||||
Slug string `json:"slug" bun:"slug,type:text" required:"true"`
|
||||
CreatedAt time.Time `json:"createdAt" bun:"created_at" required:"true"`
|
||||
UpdatedAt time.Time `json:"updatedAt" bun:"updated_at" required:"true"`
|
||||
}
|
||||
|
||||
type IntegrationDashboard = StorableIntegrationDashboard
|
||||
|
||||
func NewStorableIntegrationDashboard(dashboardID string, provider IntegrationDashboardProviderType, slug string) *StorableIntegrationDashboard {
|
||||
now := time.Now()
|
||||
return &StorableIntegrationDashboard{
|
||||
|
||||
@@ -50,10 +50,18 @@ type ListServicesMetadataParams struct {
|
||||
// Service represents a cloud integration service with its definition,
|
||||
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
|
||||
type Service struct {
|
||||
ServiceDefinition
|
||||
ServiceDefinitionMetadata
|
||||
Overview string `json:"overview" required:"true"` // markdown
|
||||
ServiceAssets ServiceAssets `json:"assets" required:"true"`
|
||||
SupportedSignals SupportedSignals `json:"supportedSignals" required:"true"`
|
||||
DataCollected DataCollected `json:"dataCollected" required:"true"`
|
||||
CloudIntegrationService *CloudIntegrationService `json:"cloudIntegrationService" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
type ServiceAssets struct {
|
||||
Dashboards []*ServiceDashboard `json:"dashboards" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GetServiceParams struct {
|
||||
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
|
||||
}
|
||||
@@ -121,6 +129,12 @@ type Dashboard struct {
|
||||
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceDashboard struct {
|
||||
Title string `json:"title" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
IntegrationDashboard *IntegrationDashboard `json:"integrationDashboard,omitempty" required:"false"`
|
||||
}
|
||||
|
||||
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, provider CloudProviderType, config *ServiceConfig) (*CloudIntegrationService, error) {
|
||||
switch provider {
|
||||
case CloudProviderTypeAWS:
|
||||
@@ -164,11 +178,41 @@ func NewServiceMetadata(definition ServiceDefinition, enabled bool) *ServiceMeta
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(def ServiceDefinition, storableService *CloudIntegrationService) *Service {
|
||||
return &Service{
|
||||
ServiceDefinition: def,
|
||||
CloudIntegrationService: storableService,
|
||||
func NewService(provider CloudProviderType, def *ServiceDefinition, integrationService *CloudIntegrationService, integrationDashboards []*StorableIntegrationDashboard) *Service {
|
||||
service := &Service{
|
||||
ServiceDefinitionMetadata: def.ServiceDefinitionMetadata,
|
||||
Overview: def.Overview,
|
||||
SupportedSignals: def.SupportedSignals,
|
||||
DataCollected: def.DataCollected,
|
||||
CloudIntegrationService: integrationService,
|
||||
ServiceAssets: ServiceAssets{Dashboards: make([]*ServiceDashboard, 0, len(def.Assets.Dashboards))},
|
||||
}
|
||||
|
||||
integrationDashboardsMap := make(map[string]*IntegrationDashboard)
|
||||
for _, d := range integrationDashboards {
|
||||
integrationDashboardsMap[d.Slug] = d
|
||||
}
|
||||
|
||||
for _, d := range def.Assets.Dashboards {
|
||||
dashboard := &ServiceDashboard{
|
||||
Title: d.Title,
|
||||
Description: d.Description,
|
||||
}
|
||||
|
||||
if integrationService != nil {
|
||||
slug := CloudIntegrationDashboardSlug(provider, integrationService.Type, d.ID)
|
||||
|
||||
if integrationDashboard, exists := integrationDashboardsMap[slug]; exists {
|
||||
if integrationDashboard != nil {
|
||||
dashboard.IntegrationDashboard = integrationDashboard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.ServiceAssets.Dashboards = append(service.ServiceAssets.Dashboards, dashboard)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func NewGettableServicesMetadata(services []*ServiceMetadata) *GettableServicesMetadata {
|
||||
|
||||
@@ -86,6 +86,49 @@ def test_list_services_with_account(
|
||||
assert svc["enabled"] is False, f"Service {svc['id']} should be disabled before any config is set"
|
||||
|
||||
|
||||
EC2_SERVICE_ID = "ec2"
|
||||
|
||||
|
||||
def test_list_account_services(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""ListAccountServicesMetadata reflects enabled state after enabling a service."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
put_response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{EC2_SERVICE_ID}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
|
||||
timeout=10,
|
||||
)
|
||||
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable ec2 failed: {put_response.status_code}: {put_response.text}"
|
||||
|
||||
list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert list_response.status_code == HTTPStatus.OK, f"Expected 200, got {list_response.status_code}"
|
||||
|
||||
data = list_response.json()["data"]
|
||||
assert "services" in data, "Response should contain 'services' field"
|
||||
assert isinstance(data["services"], list), "services should be a list"
|
||||
assert len(data["services"]) > 0, "services list should be non-empty"
|
||||
|
||||
ec2_service = next((s for s in data["services"] if s["id"] == EC2_SERVICE_ID), None)
|
||||
assert ec2_service is not None, f"EC2 service '{EC2_SERVICE_ID}' not found in services list"
|
||||
assert ec2_service["enabled"] is True, f"EC2 service should be enabled, got: {ec2_service['enabled']}"
|
||||
|
||||
|
||||
def test_get_service_details_without_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
|
||||
Reference in New Issue
Block a user