Compare commits

..

2 Commits

Author SHA1 Message Date
therealpandey
3de6f2e72c refactor(frontend): remove the old logs explorer
The page and its redux slice were the only callers of the logs list and
aggregate endpoints removed in the previous commit, so it rendered
nothing. Dropping it takes the logs store, the logql parser and the
legacy log api clients with it.

LogViewMode moves to container/OptionsMenu/types and the restricted-field
constants to container/LogDetailedView/config, their only remaining
consumers.
2026-09-18 22:25:03 +05:30
therealpandey
be9170ced6 refactor: remove stubbed logs list and aggregate endpoints
GET /api/v1/logs and GET /api/v1/logs/aggregate have returned empty
payloads since #8299; drop the routes and the response models that only
existed to serve them.
2026-09-18 22:24:51 +05:30
377 changed files with 13433 additions and 9875 deletions

View File

@@ -47,7 +47,6 @@ jobs:
- dashboard
- ingestionkeys
- inframonitoring
- llmpricingrules
- logspipelines
- passwordauthn
- preference

View File

@@ -3321,53 +3321,6 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
fillOpacity:
$ref: '#/components/schemas/DashboardtypesFillOpacity'
lineInterpolation:
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
lineStyle:
$ref: '#/components/schemas/DashboardtypesLineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/DashboardtypesSpanGaps'
type: object
DashboardtypesAreaChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
type: object
DashboardtypesAreaChartVisualization:
properties:
fillSpans:
type: boolean
stack:
$ref: '#/components/schemas/DashboardtypesStackMode'
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesAreaFillMode:
enum:
- solid
- gradient
type: string
DashboardtypesAxes:
properties:
isLogScale:
@@ -3454,6 +3407,29 @@ components:
required:
- customValue
type: object
DashboardtypesDashboard:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
data:
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
id:
type: string
locked:
type: boolean
org_id:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
type: object
DashboardtypesDashboardPanelRef:
properties:
dashboardId:
@@ -3576,11 +3552,6 @@ components:
- gradient
- none
type: string
DashboardtypesFillOpacity:
maximum: 1
minimum: 0
nullable: true
type: number
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -3633,6 +3604,13 @@ components:
timeRangeEnabled:
type: boolean
type: object
DashboardtypesGettablePublicDashboardData:
properties:
dashboard:
$ref: '#/components/schemas/DashboardtypesDashboard'
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesGettablePublicDashboardDataV2:
properties:
dashboard:
@@ -4036,7 +4014,6 @@ components:
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -4049,7 +4026,6 @@ components:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
@@ -4061,7 +4037,6 @@ components:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/AreaChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
@@ -4069,18 +4044,6 @@ components:
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
kind:
enum:
- signoz/AreaChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
kind:
@@ -4411,12 +4374,9 @@ components:
are connected.
type: boolean
type: object
DashboardtypesStackMode:
enum:
- none
- normal
- percent
type: string
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object
DashboardtypesTableFormatting:
properties:
columnUnits:
@@ -7192,6 +7152,22 @@ components:
- attributes
- totalKeys
type: object
MetricsexplorertypesMetricDashboard:
properties:
dashboardId:
type: string
dashboardName:
type: string
widgetId:
type: string
widgetName:
type: string
required:
- dashboardName
- dashboardId
- widgetId
- widgetName
type: object
MetricsexplorertypesMetricDashboardPanelsResponse:
properties:
dashboards:
@@ -7202,6 +7178,16 @@ components:
required:
- dashboards
type: object
MetricsexplorertypesMetricDashboardsResponse:
properties:
dashboards:
items:
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboard'
nullable: true
type: array
required:
- dashboards
type: object
MetricsexplorertypesMetricHighlightsResponse:
properties:
activeTimeSeries:
@@ -9746,8 +9732,6 @@ components:
type: string
name:
type: string
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
updatedAt:
format: date-time
type: string
@@ -9760,7 +9744,6 @@ components:
- fieldContext
- config
- enabled
- origin
type: object
SpantypesSpanMapperConfig:
properties:
@@ -9789,75 +9772,48 @@ components:
type: string
orgId:
type: string
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
version:
type: integer
required:
- id
- orgId
- name
- condition
- enabled
- origin
- version
type: object
SpantypesSpanMapperGroupCondition:
nullable: true
properties:
attributes:
items:
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
type: string
nullable: true
type: array
resource:
items:
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
type: string
nullable: true
type: array
required:
- attributes
- resource
type: object
SpantypesSpanMapperGroupConditionKey:
properties:
enabled:
type: boolean
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
value:
type: string
required:
- value
- enabled
type: object
SpantypesSpanMapperOperation:
enum:
- move
- copy
type: string
SpantypesSpanMapperOrigin:
enum:
- user
- system
type: string
SpantypesSpanMapperSource:
properties:
context:
$ref: '#/components/schemas/SpantypesFieldContext'
enabled:
type: boolean
key:
type: string
operation:
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
priority:
type: integer
required:
@@ -9865,7 +9821,6 @@ components:
- context
- operation
- priority
- enabled
type: object
SpantypesSpanMapperTestSpan:
properties:
@@ -11116,9 +11071,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:read
- VIEWER
- tokenizer:
- cloud-integration:read
- VIEWER
summary: Agent check-in
tags:
- cloudintegration
@@ -11168,9 +11123,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:list
- ADMIN
- tokenizer:
- cloud-integration:list
- ADMIN
summary: List accounts
tags:
- cloudintegration
@@ -11225,9 +11180,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:create
- ADMIN
- tokenizer:
- cloud-integration:create
- ADMIN
summary: Create account
tags:
- cloudintegration
@@ -11270,9 +11225,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:delete
- ADMIN
- tokenizer:
- cloud-integration:delete
- ADMIN
summary: Disconnect account
tags:
- cloudintegration
@@ -11338,9 +11293,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:read
- ADMIN
- tokenizer:
- cloud-integration:read
- ADMIN
summary: Get account
tags:
- cloudintegration
@@ -11387,9 +11342,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:update
- ADMIN
- tokenizer:
- cloud-integration:update
- ADMIN
summary: Update account
tags:
- cloudintegration
@@ -11445,9 +11400,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:list
- ADMIN
- tokenizer:
- cloud-integration:list
- ADMIN
summary: List account services metadata
tags:
- cloudintegration
@@ -11520,9 +11475,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:read
- ADMIN
- tokenizer:
- cloud-integration:read
- ADMIN
summary: Get service for account
tags:
- cloudintegration
@@ -11574,9 +11529,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:update
- ADMIN
- tokenizer:
- cloud-integration:update
- ADMIN
summary: Update service
tags:
- cloudintegration
@@ -11631,9 +11586,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- cloud-integration:read
- VIEWER
- tokenizer:
- cloud-integration:read
- VIEWER
summary: Agent check-in
tags:
- cloudintegration
@@ -11684,17 +11639,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ingestion-key:create
- serviceaccount:create
- factor-api-key:create
- serviceaccount:attach
- role:attach
- ADMIN
- tokenizer:
- ingestion-key:create
- serviceaccount:create
- factor-api-key:create
- serviceaccount:attach
- role:attach
- ADMIN
summary: Get connection credentials
tags:
- cloudintegration
@@ -11744,8 +11691,10 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key: []
- tokenizer: []
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List services metadata
tags:
- cloudintegration
@@ -11800,8 +11749,10 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key: []
- tokenizer: []
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get service
tags:
- cloudintegration
@@ -12772,8 +12723,9 @@ paths:
put:
deprecated: false
description: Single write endpoint used by both the user and the Zeus sync job.
Rules without isOverride are matched by sourceId and override rows (is_override=true)
are skipped. Rules with isOverride are matched by id and inserted when new.
Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true)
are fully preserved when the request does not provide isOverride; only synced_at
is stamped.
operationId: CreateOrUpdateLLMPricingRules
requestBody:
content:
@@ -13243,6 +13195,112 @@ paths:
summary: Update org preference
tags:
- preferences
/api/v1/public/dashboards/{id}:
get:
deprecated: false
description: This endpoint returns the sanitized dashboard data for public access
operationId: GetPublicDashboardData
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardData'
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:
- anonymous:
- public-dashboard:read
summary: Get public dashboard data
tags:
- dashboard
/api/v1/public/dashboards/{id}/widgets/{idx}/query_range:
get:
deprecated: false
description: This endpoint return query range results for a widget of public
dashboard
operationId: GetPublicDashboardWidgetQueryRange
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: idx
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
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:
- anonymous:
- public-dashboard:read
summary: Get query range result
tags:
- dashboard
/api/v1/roles:
get:
deprecated: false
@@ -19494,6 +19552,74 @@ paths:
summary: Get metric attributes
tags:
- metrics
/api/v2/metrics/dashboards:
get:
deprecated: false
description: This endpoint returns associated dashboards for a specified metric
operationId: GetMetricDashboards
parameters:
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboardsResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get metric dashboards
tags:
- metrics
/api/v2/metrics/highlights:
get:
deprecated: false

View File

@@ -52,7 +52,7 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.GetV2(ctx, orgID, publicDashboard.DashboardID)
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
if err != nil {
return err
}
@@ -90,6 +90,15 @@ func (module *module) GetPublic(ctx context.Context, orgID valuer.UUID, dashboar
return dashboardtypes.NewPublicDashboardFromStorablePublicDashboard(storablePublicDashboard), nil
}
func (module *module) GetDashboardByPublicID(ctx context.Context, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
return nil, err
}
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
}
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
orgIDs := make([]string, len(orgs))
for idx, org := range orgs {
@@ -107,6 +116,24 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
}, storableDashboard.OrgID, nil
}
func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.UUID, widgetIdx, startTime, endTime uint64) (*querybuildertypesv5.QueryRangeResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "dashboard",
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRange",
})
dashboard, err := module.GetDashboardByPublicID(ctx, id)
if err != nil {
return nil, err
}
query, err := dashboard.GetWidgetQuery(startTime, endTime, widgetIdx, module.settings.Logger())
if err != nil {
return nil, err
}
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
@@ -162,7 +189,7 @@ func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.GetV2(ctx, orgID, publicDashboard.DashboardID)
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
if err != nil {
return err
}
@@ -173,13 +200,34 @@ func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publi
return module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
}
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
dashboard, err := module.Get(ctx, orgID, id)
if err != nil {
return err
}
if err := dashboard.ErrIfNotDeletable(); err != nil {
return err
}
if dashboard.Locked {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
}
return module.delete(ctx, orgID, id)
}
func (module *module) DeleteUnsafe(ctx context.Context, orgID, id valuer.UUID) error {
return module.delete(ctx, orgID, id)
}
func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.GetV2(ctx, orgID, dashboardID)
dashboard, err := module.Get(ctx, orgID, dashboardID)
if err != nil {
return err
}
@@ -212,6 +260,10 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, data dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, source, data)
}
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, source, postable)
}
@@ -294,10 +346,30 @@ func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.DeleteView(ctx, orgID, id)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}
func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
}
@@ -305,3 +377,12 @@ func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID value
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
}
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.store.Delete(ctx, orgID, id)
})
}

View File

@@ -462,7 +462,7 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
droppedSet[label] = struct{}{}
}
if dashboards, err := m.dashboard.GetByMetricNamesV2(ctx, orgID, []string{metricName}); err != nil {
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {

View File

@@ -1,127 +0,0 @@
---
name: scaffold-feature
description: Scaffold the co-located feature structure in frontend/src. Use when creating a new page, feature, view (tab), or component folder, when a feature needs a shell with tabs, or when moving existing code out of src/container into src/pages. Generates the full folder tree (components/hooks/store/types/utils/constants/__tests__/README) and registers the page's routes with one command.
---
# Scaffold a feature
The frontend is moving to a co-located layout (Bulletproof React / FSD): everything a
feature owns lives in the feature's folder. Read `references/layout.md` for the full
target structure and the rules about what may live where.
**Never hand-create these folders.** Run the generator so every feature comes out
identical, then fill it in.
## Command
```bash
pnpm scaffold page <Name> [options] # a page/feature under src/pages
pnpm scaffold component <Name> [options] # a component folder
```
| Option | Applies to | Effect |
| --- | --- | --- |
| `--views A,B,C` | `page` | Makes the page a shell with tab switching and generates one view folder per name. |
| `--parent <path>` | `component` | Parent, relative to `src` (default `components`). A feature path like `pages/Traces/Explorer` nests the component under that feature's `components/`. |
| `--full` | `component` | Also adds `components/`, `hooks/`, `store/`, `types.ts`, `utils.ts`, `constants.ts`, `README.md` for a component that owns children. |
| `--no-tests` | both | Skips `__tests__/`. |
| `--dry-run` | both | Prints what would be written, writes nothing. |
| `--force` | both | Overwrites files that already exist (off by default; existing entries are reported as skipped). |
Folder names keep the casing you type, with the first letter forced up, so
`LLMObservability` stays `LLMObservability` rather than being re-cased. Separated names
collapse to PascalCase: `api-monitoring` and `api monitoring` both give
`pages/ApiMonitoring`. Test ids, headings, tab paths and constants are all derived from
that folder name — `TracesFunnels` gives `traces-funnels-page`, `Traces Funnels` and
`TRACES_FUNNELS_TABS`.
## What you get
```
pages/ApiMonitoring/
index.tsx # the page component
ApiMonitoring.module.scss
components/ hooks/ store/ # empty, ready for the first file
types.ts utils.ts constants.ts
__tests__/ApiMonitoring.test.tsx
README.md
```
With `--views`, the root becomes a `RouteTab` shell and each view gets the tree above. The
shell mirrors the Logs and Traces root pages: `constants.tsx` exports one `TabRoutes` per
view (icon from `@signozhq/icons`, label, `ROUTES` key, view component), `index.tsx` composes
them into the tab bar, the SCSS module carries the tab-bar overrides, and the test asserts one
tab per view plus the active view. Tab icons come from a small name map in `scaffold.mjs`
(`Explorer`, `Funnels`, `Pipelines`, `Views`, `SavedViews`); other names get a neutral icon
to replace.
## Examples
```bash
pnpm scaffold page ApiMonitoring # leaf page, no shell
pnpm scaffold page Traces --views Explorer,Funnels,Views # shell + 3 views
pnpm scaffold page Traces/Explorer # one more view under an existing shell
pnpm scaffold component DataTable # global, src/components/DataTable
pnpm scaffold component QueryBar --parent pages/Traces/Explorer # feature-local component
```
## Route registration
`page` also registers the routes, so the page is reachable as soon as it is generated:
| File | What is added |
| --- | --- |
| `src/constants/routes.ts` | One key per path: `API_MONITORING: '/api-monitoring'` for a leaf page; `TRACES_BASE` plus `TRACES_EXPLORER`, `TRACES_FUNNELS`, … for a shell. |
| `src/utils/permission/index.ts` | A `routePermission` entry per new key, open to `ADMIN`, `EDITOR` and `VIEWER`. Tighten it if the page is admin-only. |
| `src/AppRoutes/pageComponents.ts` | A `Loadable` export named `<Page>Page` pointing at `pages/<Page>`. |
| `src/AppRoutes/routes.ts` | The import plus one private, exact route per path. For a shell the base path and every tab path render the shell; the shell redirects the base path to its first tab and `RouteTab` picks the tab otherwise. |
| `src/container/TopNav/DateTimeSelectionV2/constants.ts` | Every new path in `routesToSkip`, so the global time-range picker stays hidden until the page opts in. |
Existing keys, exports and entries are left alone, so re-running is safe. An existing key or
export that points somewhere else is a naming collision and the run stops before writing
anything. `--dry-run` lists
the edits without making them. `page Traces/Explorer` registers `TRACES_EXPLORER` pointing
at the `Traces` shell; wiring the new tab into the shell's `constants.tsx` and `index.tsx`
is still by hand. The generator never adds a SideNav item; do that in
`src/container/SideNav/menuItems.tsx` when the page needs one.
## After generating
1. **Review the route registration** (pages only) and add the SideNav entry if the page
needs one. For a view added under an existing shell, add its `TabRoutes` export to the
shell's `constants.tsx` and include it in the `routes` array in the shell's `index.tsx`.
2. **Delete the placeholders you don't need** — empty `types.ts` / `utils.ts` /
`constants.ts`, and any of `components/`, `hooks/`, `store/` the feature won't use.
Those three folders are created empty; git only picks them up once they hold a file.
3. **Fill the README** — the generated file has the prompts; a feature folder without a
filled-in README is not done.
4. **Follow the repo rules while filling it in**: `@signozhq/ui` + `@signozhq/icons` only,
CSS Modules (`docs/css-modules-guide.md`), React Query for server state (prefer
`api/generated` hooks), nuqs for URL state, Zustand for client state, `data-testid` on
every interactive element.
5. **Verify** before reporting done:
```bash
pnpm tsgo --noEmit
pnpm oxlint src/pages/<Feature>
pnpm jest src/pages/<Feature>
```
`pnpm tsgo --noEmit` is the authority. A running dev server can show errors such as
`Property 'X_BASE' does not exist` or `has no exported member 'XPage'` right after
generation. Its type-checker notices new files but, on some machines, not in-place edits
to existing ones, and the generator edits the shared files in place. If tsgo is clean,
restart `pnpm dev`.
## Editing the templates
Templates live in `templates/` — `feature/`, `shell/`, `component/` and
`component-extras/` (the `--full` additions). Every template file ends in `.tmpl`, which
keeps TypeScript, lint and your editor from reading them as source; the generator strips
that suffix on the way out, so `index.tsx.tmpl` becomes `index.tsx`. Tokens are
substituted in both file names and contents: `__Pascal__`, `__kebab__`, `__camel__`,
`__CONST__`, `__Title__`. The shell templates additionally take tokens the generator builds
from `--views`: `__ICON_IMPORTS__`, `__VIEW_IMPORTS__`, `__TAB_EXPORTS__`, `__TAB_NAMES__`,
`__BASE_ROUTE__`, `__FIRST_TAB__`, `__FIRST_VIEW_TESTID__` and `__TAB_ASSERTIONS__`. Tab icons come from
`TAB_ICONS` and the empty folders from `FEATURE_DIRS`, both in `scaffold.mjs`. Name and
route derivations live in `lib.mjs`; run `node --test .claude/skills/scaffold-feature/scaffold.test.mjs`
after changing them. Change these, not the generated
output, when the team's conventions move.

View File

@@ -1,77 +0,0 @@
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
// Folder names keep the casing the author typed — only the first letter is forced
// up — so acronyms like `LLMObservability` survive. Separated names
// (`api-monitoring`, `api monitoring`) collapse to PascalCase.
export function toDirName(value) {
const name = value.trim().replace(/[^a-zA-Z0-9\-_ ]/g, '');
if (!name) {
throw new Error(`"${value}" has no usable name characters`);
}
return /[-_\s]/.test(name)
? name
.split(/[-_\s]+/)
.filter(Boolean)
.map(capitalize)
.join('')
: capitalize(name);
}
const splitHumps = (name, separator) =>
name
.replace(/([a-z0-9])([A-Z])/g, `$1${separator}$2`)
.replace(/([A-Z]+)([A-Z][a-z])/g, `$1${separator}$2`);
export const toKebab = (value) => splitHumps(toDirName(value), '-').toLowerCase();
export const toTitle = (value) => splitHumps(toDirName(value), ' ');
export const toConst = (value) => toKebab(value).replace(/-/g, '_').toUpperCase();
export const toCamel = (value) => {
const dir = toDirName(value);
return dir.charAt(0).toLowerCase() + dir.slice(1);
};
export function tokensFor(name) {
return {
__Pascal__: toDirName(name),
__kebab__: toKebab(name),
__camel__: toCamel(name),
__CONST__: toConst(name),
__Title__: toTitle(name),
};
}
export function substitute(text, tokens) {
return Object.entries(tokens).reduce(
(acc, [token, value]) => acc.split(token).join(value),
text,
);
}
export const routeKey = (segments, view) =>
[...segments, ...(view ? [view] : [])].map(toConst).join('_');
export const routePath = (segments, view) =>
`/${[...segments, ...(view ? [view] : [])].map(toKebab).join('/')}`;
// Every path under a shell renders the shell itself (RouteTab picks the tab, the base path
// redirects to the first tab), so the page component is always the first segment.
export function routeSpec(segments, views) {
const shell = segments[0];
const component = {
name: `${shell}Page`,
importPath: `pages/${shell}`,
chunk: `${toTitle(shell)} Page`,
};
if (views.length) {
const tabs = views.map((view) => ({
key: routeKey(segments, view),
path: routePath(segments, view),
}));
const keys = [
{ key: `${routeKey(segments)}_BASE`, path: routePath(segments) },
...tabs,
];
return { component, keys, routed: keys.map(({ key }) => key) };
}
const key = routeKey(segments);
return { component, keys: [{ key, path: routePath(segments) }], routed: [key] };
}

View File

@@ -1,102 +0,0 @@
# Frontend layout
Target structure for `frontend/src`. Inspired by Bulletproof React and Feature-Sliced
Design: a feature owns its components, hooks, state, types and tests, and nothing outside
the feature folder reaches into it.
```
src/
app/ # bootstrap: routing, global styles/theme
pages/
Traces/ # has a shell
index.tsx # shell — tab switching only
constants.tsx # tab definitions
Explorer/ # a view
index.tsx # view entry — composition, no business logic
components/
QueryBar/ # same shape as a global component, nests further as needed
QueryBar.tsx
QueryBar.module.scss
components/
hooks/
__tests__/
hooks/ # feature hooks + React Query wrappers over api/generated
store/ # Zustand stores for feature-local client state
types.ts
utils.ts
constants.ts
__tests__/
README.md
Funnels/
Views/
ApiMonitoring/ # no shell — same shape, one level up
index.tsx
components/
hooks/
store/
types.ts
utils.ts
constants.ts
__tests__/
README.md
components/ # cross-feature components, same internal shape as above
DataTable/
DataTable.tsx
DataTable.module.scss
components/
hooks/
store/
types.ts
utils.ts
constants.ts
__tests__/
README.md
lib/
utils/
types/
constants/
store/ # app-wide client state only
i18n/
api/
generated/ # Orval output — never edited by hand
client/ # axios instances, interceptors, error handlers
index.tsx
```
## Rules
- **Folder names are PascalCase**, spelled the way the feature is spelled in the product
(`ApiMonitoring`, `LLMObservability`). This holds for shells, views and components alike.
- **A page folder is the unit of ownership.** Anything used by exactly one feature lives
inside it, however deeply nested. Promote to `src/components` / `src/utils` / `src/hooks`
only when a second feature needs it.
- **`index.tsx` is the entry**, and it composes. Business logic goes to `hooks/`, data
shaping to `utils.ts`, state to `store/`.
- **Nested components repeat the same shape.** A component folder may hold its own
`components/`, `hooks/`, `store/`, `types.ts`, `utils.ts`, `constants.ts`, `__tests__/`.
Nest as deep as ownership actually goes; don't flatten a component that owns children.
- **Shell vs no shell.** A page with tabs gets a shell `index.tsx` whose only job is tab
switching, plus one folder per view. A page without tabs is just the feature folder.
- **Tests.** Feature-root tests in `__tests__/`; a component's tests next to the component
(its own `__tests__/`). Never reach across features in a test.
- **No barrel files.** A page's `index.tsx` is the route entry (a component), not a
re-export hub. Import components by their own path.
- **File size.** Split past ~300 LOC: extract components, and behaviour into
`use<Component>Callbacks`-style hooks. More than ~3 type declarations in a file means a
`types.ts`, and more than ~3 in `types.ts` means a `types/` folder.
- **Styling.** CSS Modules (`<Name>.module.scss`) next to the component — see
`docs/css-modules-guide.md`. Semantic tokens only.
- **State.** Server → React Query (prefer `api/generated` hooks); URL → nuqs; client →
Zustand, one store per file, always with a selector. No Redux or Context for new code.
## Migrating existing code
Most feature code still lives in `src/container` and `src/modules`, with a thin wrapper in
`src/pages`. When touching one of those features:
1. Scaffold the target with `pnpm scaffold page <Name>` (see `../SKILL.md`).
2. Move files in, one concern per commit — components, then hooks, then state.
3. Update importers; keep `src/container/<Feature>` deleted, not re-exported. A shim
directory is how the old layout survives.
4. Do the dead-code pass first: unused props, exports, imports and debug logs go before the
move, in their own commit.

View File

@@ -1,606 +0,0 @@
#!/usr/bin/env node
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
routeKey,
routeSpec,
substitute,
toCamel,
toDirName,
toKebab,
toTitle,
tokensFor,
} from './lib.mjs';
const SKILL_DIR = dirname(fileURLToPath(import.meta.url));
const TEMPLATES = join(SKILL_DIR, 'templates');
const FRONTEND = resolve(SKILL_DIR, '..', '..', '..');
const SRC = join(FRONTEND, 'src');
const ROUTE_FILES = {
routes: join(SRC, 'constants', 'routes.ts'),
permission: join(SRC, 'utils', 'permission', 'index.ts'),
pageComponents: join(SRC, 'AppRoutes', 'pageComponents.ts'),
appRoutes: join(SRC, 'AppRoutes', 'routes.ts'),
topNav: join(SRC, 'container', 'TopNav', 'DateTimeSelectionV2', 'constants.ts'),
};
const ROUTE_ROLES = "['ADMIN', 'EDITOR', 'VIEWER']";
// Port is fixed in vite.config.ts; the base path comes from VITE_BASE_PATH like vite does.
const DEV_SERVER_ORIGIN = 'http://localhost:3301';
function devServerUrl(path) {
const base = process.env.VITE_BASE_PATH ?? envFileValue('VITE_BASE_PATH') ?? '/';
return `${DEV_SERVER_ORIGIN}${base.replace(/\/+$/, '')}${path}`;
}
function envFileValue(name) {
const envFile = join(FRONTEND, '.env');
if (!existsSync(envFile)) {
return undefined;
}
const match = readFileSync(envFile, 'utf8').match(
new RegExp(`^\\s*${name}\\s*=\\s*["']?([^"'\\n#]*)`, 'm'),
);
return match?.[1].trim() || undefined;
}
// Created empty, so the folder exists before it has a file to justify it.
const FEATURE_DIRS = ['components', 'hooks', 'store'];
const USAGE = `usage:
pnpm scaffold page <Name> [--views A,B,C] [--no-tests] [--dry-run] [--force]
pnpm scaffold component <Name> [--parent <path>] [--full] [--no-tests] [--dry-run] [--force]
examples:
pnpm scaffold page ApiMonitoring
pnpm scaffold page Traces --views Explorer,Funnels,Views
pnpm scaffold page Traces/Explorer
pnpm scaffold component DataTable
pnpm scaffold component QueryBar --parent pages/Traces/Explorer`;
function fail(message) {
process.stderr.write(`error: ${message}\n\n${USAGE}\n`);
process.exit(1);
}
function expandEquals(argv) {
return argv.flatMap((arg) =>
arg.startsWith('--') && arg.includes('=')
? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]
: [arg],
);
}
function parseArgs(argv) {
const flags = {
views: [],
parent: 'components',
full: false,
tests: true,
dryRun: false,
force: false,
};
const positional = [];
const provided = new Set();
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
provided.add(arg);
if (arg === '--views' || arg === '--parent') {
const value = argv[i + 1];
if (!value || value.startsWith('--')) {
fail(`${arg} needs a value`);
}
if (arg === '--views') {
flags.views = value
.split(',')
.map((view) => view.trim())
.filter(Boolean);
if (!flags.views.length) {
fail('--views needs at least one name');
}
} else {
flags.parent = value;
}
i += 1;
} else if (arg === '--full') {
flags.full = true;
} else if (arg === '--no-tests') {
flags.tests = false;
} else if (arg === '--dry-run') {
flags.dryRun = true;
} else if (arg === '--force') {
flags.force = true;
} else if (arg === '-h' || arg === '--help') {
process.stdout.write(`${USAGE}\n`);
process.exit(0);
} else if (arg.startsWith('-')) {
fail(`unknown option: ${arg}`);
} else {
positional.push(arg);
}
}
return { positional, flags, provided };
}
const created = [];
const skipped = [];
let targetExisted = false;
let pagePath = '';
function writeFile(target, contents, flags) {
const rel = relative(FRONTEND, target);
if (existsSync(target) && !flags.force) {
skipped.push(rel);
return;
}
if (!flags.dryRun) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents);
}
created.push(rel);
}
function createDirs(targetDir, dirs, flags) {
for (const dir of dirs) {
const target = join(targetDir, dir);
const rel = `${relative(FRONTEND, target)}/`;
if (existsSync(target)) {
skipped.push(rel);
continue;
}
if (!flags.dryRun) {
mkdirSync(target, { recursive: true });
}
created.push(rel);
}
}
// Template files carry a `.tmpl` suffix so no TypeScript, lint or editor tooling
// treats them as source; the suffix is dropped on the way out.
function renderTree(templateDir, targetDir, tokens, flags) {
for (const entry of readdirSync(templateDir).sort()) {
const from = join(templateDir, entry);
const name = substitute(entry.replace(/\.tmpl$/, ''), tokens);
if (statSync(from).isDirectory()) {
if (!flags.tests && name === '__tests__') {
continue;
}
renderTree(from, join(targetDir, name), tokens, flags);
} else {
writeFile(
join(targetDir, name),
substitute(readFileSync(from, 'utf8'), tokens),
flags,
);
}
}
}
// Icons for tab names the product already uses; anything else gets a neutral one.
const TAB_ICONS = {
Explorer: 'Compass',
Funnels: 'Cone',
Pipelines: 'Workflow',
SavedViews: 'TowerControl',
Views: 'TowerControl',
};
const DEFAULT_TAB_ICON = 'LayoutPanelTop';
const tabIcon = (view) => TAB_ICONS[toDirName(view)] ?? DEFAULT_TAB_ICON;
const tabName = (view) => `${toCamel(view)}Tab`;
function shellTokens(segments, views) {
const icons = [...new Set(views.map(tabIcon))].sort((a, b) => a.localeCompare(b));
const viewImports = views
.map((view) => `import ${toDirName(view)} from './${toDirName(view)}';`)
.join('\n');
const tabExports = views
.map((view) => {
const route = `ROUTES.${routeKey(segments, view)}`;
return [
`export const ${tabName(view)}: TabRoutes = {`,
`\tComponent: ${toDirName(view)},`,
'\tname: (',
'\t\t<div className={styles.tabItem}>',
`\t\t\t<${tabIcon(view)} size={16} /> ${toTitle(view)}`,
'\t\t</div>',
'\t),',
`\troute: ${route},`,
`\tkey: ${route},`,
'};',
].join('\n');
})
.join('\n\n');
const tabAssertions = views
.map(
(view) =>
`\t\texpect(screen.getByRole('tab', { name: '${toTitle(view)}' })).toBeInTheDocument();\n`,
)
.join('');
return {
__ICON_IMPORTS__: `import { ${icons.join(', ')} } from '@signozhq/icons';`,
__VIEW_IMPORTS__: viewImports,
__TAB_EXPORTS__: `${tabExports}\n`,
__TAB_NAMES__: views.map(tabName).join(', '),
__BASE_ROUTE__: `ROUTES.${routeKey(segments)}_BASE`,
__FIRST_TAB__: tabName(views[0]),
__FIRST_VIEW_TESTID__: `${toKebab(views[0])}-page`,
__TAB_ASSERTIONS__: tabAssertions,
};
}
const edited = [];
function insertBefore(source, anchor, text, rel, from = 0) {
const index = source.indexOf(anchor, from);
if (index === -1) {
fail(`could not find \`${anchor.trim()}\` in ${rel}`);
}
return source.slice(0, index) + text + source.slice(index);
}
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// An existing key or export is only reused when it already means what the generator
// would have written; anything else is a naming collision and stops the run before
// any shared file is touched.
function assertSame(rel, what, existing, expected) {
if (existing !== expected) {
fail(
`${what} already exists in ${rel} as ${existing}, expected ${expected}` +
'pick another name',
);
}
}
function planRoutes({ component, keys, routed }) {
return [
{
file: ROUTE_FILES.routes,
transform: (source, rel) => {
const added = keys.filter(({ key, path }) => {
const match = source.match(new RegExp(`\\n\\t${key}: '([^']*)',`));
if (match) {
assertSame(rel, `ROUTES.${key}`, `'${match[1]}'`, `'${path}'`);
}
return !match;
});
const text = added.map(({ key, path }) => `\n\t${key}: '${path}',`).join('');
return {
source: insertBefore(source, '\n} as const;', text, rel),
added: added.map(({ key }) => key),
};
},
},
{
file: ROUTE_FILES.permission,
transform: (source, rel) => {
const start = source.indexOf('export const routePermission');
if (start === -1) {
fail(`could not find \`routePermission\` in ${rel}`);
}
const added = keys
.map(({ key }) => key)
.filter((key) => !source.includes(`\n\t${key}: `));
const text = added.map((key) => `\n\t${key}: ${ROUTE_ROLES},`).join('');
return { source: insertBefore(source, '\n};', text, rel, start), added };
},
},
{
file: ROUTE_FILES.pageComponents,
transform: (source, rel) => {
const existing = source.match(
new RegExp(`export const ${component.name} = Loadable\\([\\s\\S]*?'([^']+)'`),
);
if (existing) {
assertSame(rel, component.name, `'${existing[1]}'`, `'${component.importPath}'`);
return { source, added: [] };
}
const text =
`\nexport const ${component.name} = Loadable(\n` +
`\t() => import(/* webpackChunkName: "${component.chunk}" */ '${component.importPath}'),\n);\n`;
return {
source: source.replace(/\n*$/, '\n') + text,
added: [component.name],
};
},
},
{
file: ROUTE_FILES.appRoutes,
transform: (source, rel) => {
const added = [];
let next = source;
const importEnd = next.indexOf("} from './pageComponents';");
const importStart = next.lastIndexOf('import {', importEnd);
if (importEnd === -1 || importStart === -1) {
fail(`could not find the pageComponents import in ${rel}`);
}
const names = next
.slice(importStart + 'import {'.length, importEnd)
.split(',')
.map((name) => name.trim())
.filter(Boolean);
if (!names.includes(component.name)) {
const lower = component.name.toLowerCase();
const at = names.findIndex((name) => name.toLowerCase() > lower);
names.splice(at === -1 ? names.length : at, 0, component.name);
next =
next.slice(0, importStart) +
`import {\n\t${names.join(',\n\t')},\n` +
next.slice(importEnd);
added.push(`import ${component.name}`);
}
const arrayStart = next.indexOf('const routes: AppRoutes[] = [');
if (arrayStart === -1) {
fail(`could not find \`const routes: AppRoutes[]\` in ${rel}`);
}
const missing = routed.filter((key) => {
const match = next.match(
new RegExp(`component: (\\w+),\\n\\t\\tkey: '${escapeRegExp(key)}',`),
);
if (match) {
assertSame(rel, `route ${key}`, match[1], component.name);
}
return !match;
});
const entries = missing
.map((key) =>
[
'\n\t{',
`\t\tpath: ROUTES.${key},`,
'\t\texact: true,',
`\t\tcomponent: ${component.name},`,
`\t\tkey: '${key}',`,
'\t\tisPrivate: true,',
'\t},',
].join('\n'),
)
.join('');
next = insertBefore(next, '\n];', entries, rel, arrayStart);
added.push(...missing);
return { source: next, added };
},
},
{
file: ROUTE_FILES.topNav,
transform: (source, rel) => {
const start = source.indexOf('export const routesToSkip = [');
if (start === -1) {
fail(`could not find \`routesToSkip\` in ${rel}`);
}
const end = source.indexOf('\n];', start);
const block = source.slice(start, end);
const added = routed.filter((key) => !block.includes(`ROUTES.${key},`));
const text = added.map((key) => `\n\tROUTES.${key},`).join('');
return { source: insertBefore(source, '\n];', text, rel, start), added };
},
},
];
}
// Every shared file is read and validated before any is written, so a failed anchor or
// a naming collision leaves the tree untouched.
function planRouteEdits(spec) {
return planRoutes(spec).map(({ file, transform }) => {
const rel = relative(FRONTEND, file);
if (!existsSync(file)) {
fail(`shared file not found: ${rel}`);
}
const { source, added } = transform(readFileSync(file, 'utf8'), rel);
return { file, rel, source, added };
});
}
function commitRouteEdits(pending, flags) {
for (const { file, rel, source, added } of pending) {
if (!added.length) {
continue;
}
if (!flags.dryRun) {
writeFileSync(file, source);
}
edited.push({ rel, added });
}
}
function scaffoldFeature(targetDir, name, flags) {
renderTree(join(TEMPLATES, 'feature'), targetDir, tokensFor(name), flags);
createDirs(targetDir, FEATURE_DIRS, flags);
}
function scaffoldPage(name, flags) {
const segments = name.split('/').filter(Boolean).map(toDirName);
if (!segments.length) {
fail('page needs a name');
}
const viewDirs = flags.views.map(toDirName);
const duplicate = viewDirs.find((dir, index) => viewDirs.indexOf(dir) !== index);
if (duplicate) {
fail(`duplicate view: ${duplicate}`);
}
const targetDir = join(SRC, 'pages', ...segments);
const leaf = segments[segments.length - 1];
targetExisted = existsSync(targetDir);
// Shared files land before the page folder so a watching type-checker never sees a
// page that references ROUTES keys that do not exist yet.
const spec = routeSpec(segments, flags.views);
commitRouteEdits(planRouteEdits(spec), flags);
pagePath = spec.keys[0].path;
if (flags.views.length) {
renderTree(
join(TEMPLATES, 'shell'),
targetDir,
{ ...tokensFor(leaf), ...shellTokens(segments, flags.views) },
flags,
);
for (const view of flags.views) {
scaffoldFeature(join(targetDir, toDirName(view)), view, flags);
}
} else {
scaffoldFeature(targetDir, leaf, flags);
}
return targetDir;
}
function resolveParent(parent) {
const segments = parent
.replace(/^src\//, '')
.replace(/\/components\/?$/, '')
.split('/')
.filter(Boolean);
if (segments[0] === 'pages') {
return ['pages', ...segments.slice(1).map(toDirName)];
}
return segments;
}
function scaffoldComponent(name, flags) {
const tokens = tokensFor(name);
const parent = resolveParent(flags.parent);
const isGlobal = parent.length === 1 && parent[0] === 'components';
const componentsDir = isGlobal
? join(SRC, 'components')
: join(SRC, ...parent, 'components');
if (relative(SRC, componentsDir).startsWith('..')) {
fail(`--parent must stay inside src: ${flags.parent}`);
}
if (parent[0] === 'pages' && parent.length < 2) {
fail('a component under pages/ needs a feature: --parent pages/<Feature>');
}
if (!isGlobal && !existsSync(join(SRC, ...parent))) {
fail(`parent does not exist: src/${parent.join('/')}`);
}
const targetDir = join(componentsDir, tokens.__Pascal__);
targetExisted = existsSync(targetDir);
renderTree(join(TEMPLATES, 'component'), targetDir, tokens, flags);
if (flags.full) {
renderTree(join(TEMPLATES, 'component-extras'), targetDir, tokens, flags);
createDirs(targetDir, FEATURE_DIRS, flags);
}
return targetDir;
}
function report(kind, targetDir, flags) {
const rel = relative(FRONTEND, targetDir);
const verb = flags.dryRun ? 'would create' : 'created';
const segments = rel.split('/').slice(2);
const isNestedView = kind === 'page' && segments.length > 1 && !flags.views.length;
const leafName = segments[segments.length - 1];
if (targetExisted) {
process.stdout.write(
`\nwarning: ${rel} already existed — only missing entries were added\n`,
);
}
process.stdout.write(`\n${verb} ${created.length} entr(ies) in ${rel}\n`);
for (const entry of created) {
process.stdout.write(` + ${entry}\n`);
}
if (skipped.length) {
process.stdout.write(
`\nskipped ${skipped.length} existing entr(ies) — pass --force to overwrite files\n`,
);
for (const entry of skipped) {
process.stdout.write(` = ${entry}\n`);
}
}
if (edited.length) {
const editVerb = flags.dryRun ? 'would edit' : 'edited';
process.stdout.write(`\n${editVerb} ${edited.length} shared file(s)\n`);
for (const { rel, added } of edited) {
const additions = added.map((entry) => `+${entry}`).join(', ');
process.stdout.write(` ~ ${rel}: ${additions}\n`);
}
}
const steps =
kind === 'page'
? [
'review the route registration (constants/routes.ts, utils/permission, AppRoutes/pageComponents.ts, AppRoutes/routes.ts, TopNav routesToSkip) and add a SideNav entry in container/SideNav/menuItems.tsx if the page needs one',
...(isNestedView
? [
`add a tab export for ${leafName} in the shell's constants.tsx and include it in the routes array in the shell's index.tsx`,
]
: []),
'delete the placeholders you do not need (empty types/utils/constants, unused folders)',
'fill in README.md',
`verify: pnpm tsgo --noEmit && pnpm oxlint ${rel} && pnpm jest ${rel}`,
]
: [
'delete the placeholders you do not need (empty types/utils/constants, unused folders)',
`verify: pnpm tsgo --noEmit && pnpm oxlint ${rel} && pnpm jest ${rel}`,
];
if (pagePath) {
process.stdout.write(`\nopen: ${devServerUrl(pagePath)}\n`);
}
process.stdout.write('\nnext:\n');
steps.forEach((step, index) => {
process.stdout.write(` ${index + 1}. ${step}\n`);
});
process.stdout.write(
'\nnote: git does not track empty folders — components/, hooks/ and store/ only\n' +
'show up in a commit once they hold a file.\n',
);
}
const { positional, flags, provided } = parseArgs(expandEquals(process.argv.slice(2)));
const [kind, name] = positional;
if (!kind || !name) {
fail('a command and a name are required');
}
if (positional.length > 2) {
fail(`unexpected argument: ${positional[2]}`);
}
function rejectFlags(unsupported) {
for (const flag of unsupported) {
if (provided.has(flag)) {
fail(`${flag} does not apply to \`${kind}\``);
}
}
}
let targetDir;
try {
if (kind === 'page') {
rejectFlags(['--parent', '--full']);
targetDir = scaffoldPage(name, flags);
} else if (kind === 'component') {
rejectFlags(['--views']);
targetDir = scaffoldComponent(name, flags);
} else {
fail(`unknown command: ${kind}`);
}
} catch (error) {
fail(error.message);
}
report(kind, targetDir, flags);

View File

@@ -1,95 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
routeKey,
routePath,
routeSpec,
substitute,
toCamel,
toConst,
toDirName,
toKebab,
toTitle,
tokensFor,
} from './lib.mjs';
describe('names', () => {
it('keeps typed casing and forces the first letter up', () => {
assert.equal(toDirName('LLMObservability'), 'LLMObservability');
assert.equal(toDirName('apiMonitoring'), 'ApiMonitoring');
});
it('collapses separated names to PascalCase', () => {
assert.equal(toDirName('api-monitoring'), 'ApiMonitoring');
assert.equal(toDirName('api monitoring'), 'ApiMonitoring');
assert.equal(toDirName('saved_views'), 'SavedViews');
});
it('derives kebab, title, const and camel forms, splitting acronyms', () => {
assert.deepEqual(tokensFor('LLMObservability'), {
__Pascal__: 'LLMObservability',
__kebab__: 'llm-observability',
__camel__: 'lLMObservability',
__CONST__: 'LLM_OBSERVABILITY',
__Title__: 'LLM Observability',
});
assert.equal(toKebab('SavedViews'), 'saved-views');
assert.equal(toTitle('SavedViews'), 'Saved Views');
assert.equal(toConst('SavedViews'), 'SAVED_VIEWS');
assert.equal(toCamel('SavedViews'), 'savedViews');
});
it('rejects names with no usable characters', () => {
assert.throws(() => toDirName('***'), /no usable name characters/);
});
});
describe('substitute', () => {
it('replaces every occurrence of every token, in file names and contents', () => {
const tokens = tokensFor('ApiMonitoring');
assert.equal(substitute('__Pascal__.module.scss', tokens), 'ApiMonitoring.module.scss');
assert.equal(
substitute('__kebab__-page / __kebab__-shell / __Title__', tokens),
'api-monitoring-page / api-monitoring-shell / Api Monitoring',
);
});
});
describe('routes', () => {
it('builds keys and paths from every segment plus the view', () => {
assert.equal(routeKey(['Traces'], 'SavedViews'), 'TRACES_SAVED_VIEWS');
assert.equal(routePath(['Traces'], 'SavedViews'), '/traces/saved-views');
assert.equal(routeKey(['Traces', 'Explorer']), 'TRACES_EXPLORER');
assert.equal(routePath(['Traces', 'Explorer']), '/traces/explorer');
});
it('routes a leaf page under a single key', () => {
assert.deepEqual(routeSpec(['ApiMonitoring'], []), {
component: {
name: 'ApiMonitoringPage',
importPath: 'pages/ApiMonitoring',
chunk: 'Api Monitoring Page',
},
keys: [{ key: 'API_MONITORING', path: '/api-monitoring' }],
routed: ['API_MONITORING'],
});
});
it('routes a shell under a base key plus one key per view, all to the shell', () => {
const spec = routeSpec(['Traces'], ['Explorer', 'Funnels']);
assert.equal(spec.component.name, 'TracesPage');
assert.deepEqual(spec.keys, [
{ key: 'TRACES_BASE', path: '/traces' },
{ key: 'TRACES_EXPLORER', path: '/traces/explorer' },
{ key: 'TRACES_FUNNELS', path: '/traces/funnels' },
]);
assert.deepEqual(spec.routed, ['TRACES_BASE', 'TRACES_EXPLORER', 'TRACES_FUNNELS']);
});
it('points a view added under an existing shell at the shell component', () => {
const spec = routeSpec(['Traces', 'Explorer'], []);
assert.equal(spec.component.importPath, 'pages/Traces');
assert.deepEqual(spec.keys, [{ key: 'TRACES_EXPLORER', path: '/traces/explorer' }]);
});
});

View File

@@ -1,21 +0,0 @@
# __Pascal__
<!-- What this component renders, and the features that use it. -->
## API
<!-- Props, and the behaviour each one controls. -->
## Structure
| Path | Purpose |
| --- | --- |
| `__Pascal__.tsx` | The component. |
| `__Pascal__.module.scss` | Styles. |
| `components/` | Child components this one owns. |
| `hooks/` | Behaviour extracted out of the component. |
| `store/` | Zustand stores this component owns. |
| `types.ts` | Types shared inside this folder. |
| `utils.ts` | Pure helpers. |
| `constants.ts` | Constants. |
| `__tests__/` | Tests. |

View File

@@ -1,4 +0,0 @@
.__camel__ {
display: flex;
color: var(--l1-foreground);
}

View File

@@ -1,7 +0,0 @@
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
return <div className={styles.__camel__} data-testid="__kebab__" />;
}
export default __Pascal__;

View File

@@ -1,11 +0,0 @@
import { render, screen } from 'tests/test-utils';
import __Pascal__ from '../__Pascal__';
describe('__Pascal__', () => {
it('renders', () => {
render(<__Pascal__ />);
expect(screen.getByTestId('__kebab__')).toBeInTheDocument();
});
});

View File

@@ -1,28 +0,0 @@
# __Title__
<!-- One paragraph: what this feature does, who uses it, and where it is reachable from. -->
## Structure
| Path | Purpose |
| --- | --- |
| `index.tsx` | Feature entry. Composition only — no business logic. |
| `components/` | Feature-local components, nested as `components/<Name>/`. |
| `hooks/` | Feature hooks, including React Query wrappers over `api/generated`. |
| `store/` | Zustand stores for feature-local client state. |
| `types.ts` | Shared feature types. Split into `types/` past ~3 declarations. |
| `utils.ts` | Pure helpers. |
| `constants.ts` | Feature constants. |
| `__tests__/` | Feature-root tests. Component tests live with the component. |
## Data
<!-- Endpoints this feature reads/writes, and the hooks that wrap them. -->
## State
<!-- What lives in the URL (nuqs), what lives in React Query, what lives in store/. -->
## Routing
<!-- Route key in constants/routes.ts, lazy import in AppRoutes/pageComponents.ts, entry in AppRoutes/routes.ts. -->

View File

@@ -1,12 +0,0 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-4);
background: var(--l1-background);
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
}

View File

@@ -1,11 +0,0 @@
import { render, screen } from 'tests/test-utils';
import __Pascal__ from '../index';
describe('__Pascal__', () => {
it('renders the page', () => {
render(<__Pascal__ />);
expect(screen.getByTestId('__kebab__-page')).toBeInTheDocument();
});
});

View File

@@ -1,11 +0,0 @@
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
return (
<section className={styles.container} data-testid="__kebab__-page">
<h1 className={styles.title}>__Title__</h1>
</section>
);
}
export default __Pascal__;

View File

@@ -1,20 +0,0 @@
# __Title__
<!-- One paragraph: what this section of the product is, and what each tab is for. -->
## Structure
| Path | Purpose |
| --- | --- |
| `index.tsx` | Shell. Tab switching only — no feature logic. |
| `constants.tsx` | One `TabRoutes` export per tab: icon, label, route and the view it renders. |
| `<View>/` | One folder per tab, each a self-contained feature. |
## Routing
Every path is registered in `src/constants/routes.ts`, `src/utils/permission/index.ts`,
`src/AppRoutes/routes.ts` and the `routesToSkip` list in
`src/container/TopNav/DateTimeSelectionV2/constants.ts`, all rendering this shell through the
lazy import in `src/AppRoutes/pageComponents.ts`. The base path redirects to the first tab;
`RouteTab` picks the tab from the current path. Adding a tab means a new `ROUTES` key, a
route entry, a permission entry, a `routesToSkip` entry and a `TabRoutes` export here.

View File

@@ -1,21 +0,0 @@
.shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:global(.ant-tabs-nav) {
padding: 0 var(--spacing-8);
margin-bottom: 0;
&::before {
border-bottom: 1px solid var(--l1-border) !important;
}
}
}
.tabItem {
display: flex;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -1,25 +0,0 @@
import { render, screen } from 'tests/test-utils';
import ROUTES from 'constants/routes';
import { __FIRST_TAB__ } from '../constants';
import __Pascal__ from '../index';
describe('__Pascal__', () => {
it('renders one tab per view', () => {
render(<__Pascal__ />, undefined, { initialRoute: __FIRST_TAB__.route });
expect(screen.getByTestId('__kebab__-shell')).toBeInTheDocument();
__TAB_ASSERTIONS__ });
it('renders the view for the active tab', () => {
render(<__Pascal__ />, undefined, { initialRoute: __FIRST_TAB__.route });
expect(screen.getByTestId('__FIRST_VIEW_TESTID__')).toBeInTheDocument();
});
it('redirects the base path to the first tab', () => {
render(<__Pascal__ />, undefined, { initialRoute: __BASE_ROUTE__ });
expect(screen.getByTestId('__FIRST_VIEW_TESTID__')).toBeInTheDocument();
});
});

View File

@@ -1,9 +0,0 @@
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
__ICON_IMPORTS__
__VIEW_IMPORTS__
import styles from './__Pascal__.module.scss';
__TAB_EXPORTS__

View File

@@ -1,32 +0,0 @@
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import RouteTab from 'components/RouteTab';
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { __TAB_NAMES__ } from './constants';
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
const { pathname } = useLocation();
const routes: TabRoutes[] = [__TAB_NAMES__];
if (matchPath(pathname, { path: __BASE_ROUTE__, exact: true })) {
return <Redirect to={routes[0].route} />;
}
return (
<div className={styles.shell} data-testid="__kebab__-shell">
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
/>
</div>
);
}
export default __Pascal__;

View File

@@ -10,7 +10,6 @@
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"test:storybook": "bash scripts/test-storybook.sh",
"scaffold": "node .claude/skills/scaffold-feature/scaffold.mjs",
"build": "vite build",
"preview": "vite preview",
"prettify": "oxfmt",

View File

@@ -4,6 +4,7 @@
"GET_STARTED": "SigNoz | Get Started",
"SERVICE_METRICS": "SigNoz | Service Metrics",
"SERVICE_MAP": "SigNoz | Service Map",
"TRACE": "SigNoz | Trace",
"HOME": "SigNoz | Home",
"TRACE_DETAIL": "SigNoz | Trace Detail",
"TRACES_EXPLORER": "SigNoz | Traces Explorer",
@@ -55,13 +56,13 @@
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server",
"AI_ASSISTANT": "SigNoz | AI Assistant",
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
}

View File

@@ -14,6 +14,7 @@
"GET_STARTED_AZURE_MONITORING": "SigNoz | Get Started | AZURE",
"GET_STARTED": "SigNoz | Get Started with SigNoz Cloud",
"GET_STARTED_WITH_CLOUD": "SigNoz | Get Started with SigNoz Cloud",
"TRACE": "SigNoz | Trace",
"TRACE_DETAIL": "SigNoz | Trace Detail",
"TRACES_EXPLORER": "SigNoz | Traces Explorer",
"SETTINGS": "SigNoz | Settings",
@@ -77,6 +78,7 @@
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server",
"AI_ASSISTANT": "SigNoz | AI Assistant",
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"ROLE_CREATE": "SigNoz | Create Role",
@@ -85,7 +87,6 @@
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
}

View File

@@ -1588,10 +1588,15 @@ describe('PrivateRoute', () => {
deniedRoles: DENIED_ROLES,
},
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
TRACE_DETAIL: {
path: ROUTES.TRACE_DETAIL.replace(':id', 'trace-id-1'),
deniedRoles: DENIED_ROLES,
},
TRACE_DETAIL_OLD: {
path: ROUTES.TRACE_DETAIL_OLD.replace(':id', 'trace-id-1'),
deniedRoles: DENIED_ROLES,
},
// LOGS and LOGS_EXPLORER share a path - matchPath resolves it to whichever
// route definition comes last, and both keys are authz-aware either way.
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },

View File

@@ -53,6 +53,17 @@ export const TracesFunnelDetails = Loadable(
),
);
export const TraceFilter = Loadable(
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailOldRedirect" */ 'pages/TraceDetailOldRedirect/index'
),
);
export const TraceDetailV3 = Loadable(
() =>
import(
@@ -154,6 +165,10 @@ export const Logs = Loadable(
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
);
export const LogsExplorer = Loadable(
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsModulePage'),
);
export const LiveLogs = Loadable(
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
);

View File

@@ -26,6 +26,7 @@ import {
LiveLogs,
Login,
Logs,
LogsExplorer,
LogsIndexToFields,
LogsSaveViews,
MessagingQueuesMainPage,
@@ -45,7 +46,9 @@ import {
SomethingWentWrong,
StatusPage,
SupportPage,
TraceDetailOldRedirect,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
TracesFunnels,
@@ -128,6 +131,14 @@ const routes: AppRoutes[] = [
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
// Legacy /trace-old/:id redirects to the current /trace/:id view.
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetailOldRedirect,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.TRACE_DETAIL,
exact: true,
@@ -212,6 +223,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACE,
exact: true,
component: TraceFilter,
isPrivate: true,
key: 'TRACE',
},
{
path: ROUTES.TRACES_EXPLORER,
exact: true,
@@ -282,6 +300,13 @@ const routes: AppRoutes[] = [
key: 'LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_EXPLORER,
exact: true,
component: LogsExplorer,
key: 'LOGS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.LIVE_LOGS,
exact: true,

View File

@@ -36,12 +36,16 @@ import type {
GetDashboardV2200,
GetDashboardV2PathParameters,
GetPublicDashboard200,
GetPublicDashboardData200,
GetPublicDashboardDataPathParameters,
GetPublicDashboardDataV2200,
GetPublicDashboardDataV2PathParameters,
GetPublicDashboardPanelQueryRangeV2200,
GetPublicDashboardPanelQueryRangeV2Params,
GetPublicDashboardPanelQueryRangeV2PathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
ListDashboardViews200,
@@ -470,6 +474,217 @@ export const useUpdatePublicDashboard = <
> => {
return useMutation(getUpdatePublicDashboardMutationOptions(options));
};
/**
* This endpoint returns the sanitized dashboard data for public access
* @summary Get public dashboard data
*/
export const getPublicDashboardData = (
{ id }: GetPublicDashboardDataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardData200>({
url: `/api/v1/public/dashboards/${id}`,
method: 'GET',
signal,
});
};
export const getGetPublicDashboardDataQueryKey = ({
id,
}: GetPublicDashboardDataPathParameters) => {
return [`/api/v1/public/dashboards/${id}`] as const;
};
export const getGetPublicDashboardDataQueryOptions = <
TData = Awaited<ReturnType<typeof getPublicDashboardData>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetPublicDashboardDataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardData>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetPublicDashboardDataQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardData>>
> = ({ signal }) => getPublicDashboardData({ id }, signal);
return {
queryKey,
queryFn,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardData>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPublicDashboardDataQueryResult = NonNullable<
Awaited<ReturnType<typeof getPublicDashboardData>>
>;
export type GetPublicDashboardDataQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get public dashboard data
*/
export function useGetPublicDashboardData<
TData = Awaited<ReturnType<typeof getPublicDashboardData>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetPublicDashboardDataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardData>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardDataQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get public dashboard data
*/
export const invalidateGetPublicDashboardData = async (
queryClient: QueryClient,
{ id }: GetPublicDashboardDataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardDataQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint return query range results for a widget of public dashboard
* @summary Get query range result
*/
export const getPublicDashboardWidgetQueryRange = (
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardWidgetQueryRange200>({
url: `/api/v1/public/dashboards/${id}/widgets/${idx}/query_range`,
method: 'GET',
signal,
});
};
export const getGetPublicDashboardWidgetQueryRangeQueryKey = ({
id,
idx,
}: GetPublicDashboardWidgetQueryRangePathParameters) => {
return [`/api/v1/public/dashboards/${id}/widgets/${idx}/query_range`] as const;
};
export const getGetPublicDashboardWidgetQueryRangeQueryOptions = <
TData = Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetPublicDashboardWidgetQueryRangeQueryKey({ id, idx });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>
> = ({ signal }) => getPublicDashboardWidgetQueryRange({ id, idx }, signal);
return {
queryKey,
queryFn,
enabled: id !== null && id !== undefined && idx !== null && idx !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPublicDashboardWidgetQueryRangeQueryResult = NonNullable<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>
>;
export type GetPublicDashboardWidgetQueryRangeQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get query range result
*/
export function useGetPublicDashboardWidgetQueryRange<
TData = Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardWidgetQueryRangeQueryOptions(
{ id, idx },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get query range result
*/
export const invalidateGetPublicDashboardWidgetQueryRange = async (
queryClient: QueryClient,
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardWidgetQueryRangeQueryKey({ id, idx }) },
options,
);
return queryClient;
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List dashboard saved views

View File

@@ -150,7 +150,7 @@ export const invalidateListLLMPricingRules = async (
};
/**
* Single write endpoint used by both the user and the Zeus sync job. Rules without isOverride are matched by sourceId and override rows (is_override=true) are skipped. Rules with isOverride are matched by id and inserted when new.
* Single write endpoint used by both the user and the Zeus sync job. Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true) are fully preserved when the request does not provide isOverride; only synced_at is stamped.
* @summary Create or update pricing rules
*/
export const createOrUpdateLLMPricingRules = (

View File

@@ -24,6 +24,8 @@ import type {
GetMetricAlertsParams,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricDashboards200,
GetMetricDashboardsParams,
GetMetricDashboardsV2200,
GetMetricDashboardsV2Params,
GetMetricHighlights200,
@@ -1094,6 +1096,104 @@ export const invalidateGetMetricAttributes = async (
return queryClient;
};
/**
* This endpoint returns associated dashboards for a specified metric
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
params: GetMetricDashboardsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
url: `/api/v2/metrics/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsQueryKey = (
params?: GetMetricDashboardsParams,
) => {
return [`/api/v2/metrics/dashboards`, ...(params ? [params] : [])] as const;
};
export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboards>>
> = ({ signal }) => getMetricDashboards(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricDashboardsQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricDashboards>>
>;
export type GetMetricDashboardsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric dashboards
*/
export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get metric dashboards
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
params: GetMetricDashboardsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns highlights like number of datapoints, totaltimeseries, active time series, last received time for a specified metric
* @summary Get metric highlights

View File

@@ -4144,52 +4144,6 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
}
/**
* @minimum 0
* @maximum 1
* @nullable
*/
export type DashboardtypesFillOpacityDTO = number | null;
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesAreaChartAppearanceDTO {
fillMode?: DashboardtypesAreaFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO | null;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
lineStyle?: DashboardtypesLineStyleDTO;
/**
* @type boolean
*/
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -4267,11 +4221,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
value: number;
}
export enum DashboardtypesStackModeDTO {
none = 'none',
normal = 'normal',
percent = 'percent',
}
export enum DashboardtypesTimePreferenceDTO {
global_time = 'global_time',
last_5_min = 'last_5_min',
@@ -4284,27 +4233,6 @@ export enum DashboardtypesTimePreferenceDTO {
last_1_week = 'last_1_week',
last_1_month = 'last_1_month',
}
export interface DashboardtypesAreaChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
stack?: DashboardtypesStackModeDTO;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesAreaChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -4886,6 +4814,50 @@ export interface DashboardtypesCustomVariableSpecDTO {
customValue: string;
}
export interface DashboardtypesStorableDashboardDataDTO {
[key: string]: unknown;
}
export enum DashboardtypesSourceDTO {
user = 'user',
system = 'system',
integration = 'integration',
}
export interface DashboardtypesDashboardDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
data?: DashboardtypesStorableDashboardDataDTO;
/**
* @type string
*/
id?: string;
/**
* @type boolean
*/
locked?: boolean;
/**
* @type string
*/
org_id?: string;
source?: DashboardtypesSourceDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesDashboardPanelRefDTO {
/**
* @type string
@@ -4958,6 +4930,29 @@ export enum DashboardtypesFillModeDTO {
gradient = 'gradient',
none = 'none',
}
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesTimeSeriesChartAppearanceDTO {
fillMode?: DashboardtypesFillModeDTO;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
@@ -5010,18 +5005,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesBarChartPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind {
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO {
/**
* @enum signoz/AreaChartPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind;
spec: DashboardtypesAreaChartPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTOKind {
'signoz/NumberPanel' = 'signoz/NumberPanel',
}
@@ -5227,7 +5210,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
@@ -5824,11 +5806,6 @@ export interface DashboardtypesDashboardViewDTO {
updatedAt?: string;
}
export enum DashboardtypesSourceDTO {
user = 'user',
system = 'system',
integration = 'integration',
}
export interface TagtypesGettableTagDTO {
/**
* @type string
@@ -5906,6 +5883,11 @@ export interface DashboardtypesGettablePublicDasbhboardDTO {
timeRangeEnabled?: boolean;
}
export interface DashboardtypesGettablePublicDashboardDataDTO {
dashboard?: DashboardtypesDashboardDTO;
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
dashboard?: DashboardtypesGettableDashboardV2DTO;
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
@@ -6152,7 +6134,6 @@ export interface DashboardtypesListableDashboardViewDTO {
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
'signoz/NumberPanel' = 'signoz/NumberPanel',
'signoz/PieChartPanel' = 'signoz/PieChartPanel',
'signoz/TablePanel' = 'signoz/TablePanel',
@@ -8978,6 +8959,25 @@ export interface MetricsexplorertypesMetricAttributesResponseDTO {
totalKeys: number;
}
export interface MetricsexplorertypesMetricDashboardDTO {
/**
* @type string
*/
dashboardId: string;
/**
* @type string
*/
dashboardName: string;
/**
* @type string
*/
widgetId: string;
/**
* @type string
*/
widgetName: string;
}
export interface MetricsexplorertypesMetricDashboardPanelsResponseDTO {
/**
* @type array,null
@@ -8985,6 +8985,13 @@ export interface MetricsexplorertypesMetricDashboardPanelsResponseDTO {
dashboards: DashboardtypesDashboardPanelRefDTO[] | null;
}
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
/**
* @type array,null
*/
dashboards: MetricsexplorertypesMetricDashboardDTO[] | null;
}
export interface MetricsexplorertypesMetricHighlightsResponseDTO {
/**
* @type integer
@@ -10810,22 +10817,6 @@ export interface SpantypesGettableFlamegraphTraceDTO {
startTimestampMillis: number;
}
export enum SpantypesSpanMapperOriginDTO {
user = 'user',
system = 'system',
}
export interface SpantypesSpanMapperGroupConditionKeyDTO {
/**
* @type boolean
*/
enabled: boolean;
origin?: SpantypesSpanMapperOriginDTO;
/**
* @type string
*/
value: string;
}
/**
* @nullable
*/
@@ -10833,11 +10824,11 @@ export type SpantypesSpanMapperGroupConditionDTO = {
/**
* @type array,null
*/
attributes: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
attributes: string[] | null;
/**
* @type array,null
*/
resource: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
resource: string[] | null;
} | null;
export interface SpantypesSpanMapperGroupDTO {
@@ -10867,7 +10858,6 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
*/
orgId: string;
origin: SpantypesSpanMapperOriginDTO;
/**
* @type string
* @format date-time
@@ -10877,10 +10867,6 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
*/
updatedBy?: string;
/**
* @type integer
*/
version: number;
}
export interface SpantypesGettableSpanMapperGroupsDTO {
@@ -10938,16 +10924,11 @@ export enum SpantypesSpanMapperOperationDTO {
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type boolean
*/
enabled: boolean;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
origin?: SpantypesSpanMapperOriginDTO;
/**
* @type integer
*/
@@ -10989,7 +10970,6 @@ export interface SpantypesSpanMapperDTO {
* @type string
*/
name: string;
origin: SpantypesSpanMapperOriginDTO;
/**
* @type string
* @format date-time
@@ -12418,6 +12398,29 @@ export type GetOrgPreference200 = {
export type UpdateOrgPreferencePathParameters = {
name: string;
};
export type GetPublicDashboardDataPathParameters = {
id: string;
};
export type GetPublicDashboardData200 = {
data: DashboardtypesGettablePublicDashboardDataDTO;
/**
* @type string
*/
status: string;
};
export type GetPublicDashboardWidgetQueryRangePathParameters = {
id: string;
idx: string;
};
export type GetPublicDashboardWidgetQueryRange200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**
* @type string
*/
status: string;
};
export type ListRoles200 = {
/**
* @type array
@@ -13375,6 +13378,22 @@ export type GetMetricAttributes200 = {
status: string;
};
export type GetMetricDashboardsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricDashboards200 = {
data: MetricsexplorertypesMetricDashboardsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricHighlightsParams = {
/**
* @type string

View File

@@ -0,0 +1,49 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import omitBy from 'lodash-es/omitBy';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getFilters';
const getFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const duration =
omitBy(props.other, (_, key) => !key.startsWith('duration')) || [];
const nonDuration = omitBy(props.other, (_, key) =>
key.startsWith('duration'),
);
const exclude: string[] = [];
props.isFilterExclude.forEach((value, key) => {
if (value) {
exclude.push(key);
}
});
const response = await axios.post<PayloadProps>(`/getSpanFilters`, {
start: props.start,
end: props.end,
getFilters: props.getFilters,
...nonDuration,
maxDuration: String((duration.duration || [])[0] || ''),
minDuration: String((duration.duration || [])[1] || ''),
exclude,
spanKind: props.spanKind,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getFilters;

View File

@@ -0,0 +1,62 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import omitBy from 'lodash-es/omitBy';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getSpans';
const getSpans = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const updatedSelectedTags = props.selectedTags.map((e) => ({
Key: `${e.Key}.(string)`,
Operator: e.Operator,
StringValues: e.StringValues,
NumberValues: e.NumberValues,
BoolValues: e.BoolValues,
}));
const exclude: string[] = [];
props.isFilterExclude.forEach((value, key) => {
if (value) {
exclude.push(key);
}
});
const other = Object.fromEntries(props.selectedFilter);
const duration = omitBy(other, (_, key) => !key.startsWith('duration')) || [];
const nonDuration = omitBy(other, (_, key) => key.startsWith('duration'));
const response = await axios.post<PayloadProps>(
`/getFilteredSpans/aggregates`,
{
start: String(props.start),
end: String(props.end),
function: props.function,
groupBy: props.groupBy === 'none' ? '' : props.groupBy,
step: props.step,
tags: updatedSelectedTags,
...nonDuration,
maxDuration: String((duration.duration || [])[0] || ''),
minDuration: String((duration.duration || [])[1] || ''),
exclude,
spanKind: props.spanKind,
},
);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getSpans;

View File

@@ -0,0 +1,65 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import omitBy from 'lodash-es/omitBy';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getSpanAggregate';
import { TraceFilterEnum } from 'types/reducer/trace';
const getSpanAggregate = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const preProps = {
start: String(props.start),
end: String(props.end),
limit: props.limit,
offset: props.offset,
order: props.order,
orderParam: props.orderParam,
};
const exclude: TraceFilterEnum[] = [];
props.isFilterExclude.forEach((value, key) => {
if (value) {
exclude.push(key);
}
});
const updatedSelectedTags = props.selectedTags.map((e) => ({
Key: `${e.Key}.(string)`,
Operator: e.Operator,
StringValues: e.StringValues,
NumberValues: e.NumberValues,
BoolValues: e.BoolValues,
}));
const other = Object.fromEntries(props.selectedFilter);
const duration = omitBy(other, (_, key) => !key.startsWith('duration')) || [];
const nonDuration = omitBy(other, (_, key) => key.startsWith('duration'));
const response = await axios.post<PayloadProps>(`/getFilteredSpans`, {
...preProps,
tags: updatedSelectedTags,
...nonDuration,
maxDuration: String((duration.duration || [])[0] || ''),
minDuration: String((duration.duration || [])[1] || ''),
exclude,
spanKind: props.spanKind,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getSpanAggregate;

View File

@@ -0,0 +1,49 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { omitBy } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getTagFilters';
import { TraceFilterEnum } from 'types/reducer/trace';
const getTagFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const duration =
omitBy(props.other, (_, key) => !key.startsWith('duration')) || [];
const exclude: TraceFilterEnum[] = [];
props.isFilterExclude.forEach((value, key) => {
if (value) {
exclude.push(key);
}
});
const nonDuration = omitBy(props.other, (_, key) =>
key.startsWith('duration'),
);
const response = await axios.post<PayloadProps>(`/getTagFilters`, {
start: String(props.start),
end: String(props.end),
...nonDuration,
maxDuration: String((duration.duration || [])[0] || ''),
minDuration: String((duration.duration || [])[1] || ''),
exclude,
spanKind: props.spanKind,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getTagFilters;

View File

@@ -0,0 +1,31 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getTagValue';
const getTagValue = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post<PayloadProps>(`/getTagValues`, {
start: props.start.toString(),
end: props.end.toString(),
tagKey: {
Key: props.tagKey.Key,
Type: props.tagKey.Type,
},
spanKind: props.spanKind,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getTagValue;

View File

@@ -0,0 +1,12 @@
import { ReactNode } from 'react';
import { CategoryHeadingText } from './styles';
interface ICategoryHeadingProps {
children: ReactNode;
}
function CategoryHeading({ children }: ICategoryHeadingProps): JSX.Element {
return <CategoryHeadingText color="muted">{children}</CategoryHeadingText>;
}
export default CategoryHeading;

View File

@@ -0,0 +1,6 @@
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const CategoryHeadingText = styled(Typography.Text)`
font-size: 0.8rem;
`;

View File

@@ -0,0 +1,33 @@
import { CSSProperties } from 'react';
import { Color } from '@signozhq/design-tokens';
import { TableProps } from 'antd';
export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
return {
paddingTop: 4,
paddingBottom: 6,
paddingRight: 8,
paddingLeft: 8,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400,
fontSize: '14px',
fontStyle: 'normal',
fontWeight: 400,
lineHeight: '18px',
letterSpacing: '-0.07px',
marginBottom: '0px',
minWidth: '10rem',
width: 'auto',
};
}
export const defaultTableStyle: CSSProperties = {
minWidth: '40rem',
};
export const defaultListViewPanelStyle: CSSProperties = {
maxWidth: '40rem',
};
export const tableScroll: TableProps<Record<string, unknown>>['scroll'] = {
x: true,
};

View File

@@ -0,0 +1,24 @@
import { Table } from 'antd';
// config
import { tableScroll } from './config';
import { LogsTableViewProps } from './types';
import { useTableView } from './useTableView';
function LogsTableView(props: LogsTableViewProps): JSX.Element {
const { dataSource, columns } = useTableView(props);
return (
<Table
size="small"
columns={columns}
dataSource={dataSource}
pagination={false}
rowKey="id"
bordered
scroll={tableScroll}
/>
);
}
export default LogsTableView;

View File

@@ -0,0 +1,32 @@
import { Color } from '@signozhq/design-tokens';
import { FontSize } from 'container/OptionsMenu/types';
import styled from 'styled-components';
interface TableBodyContentProps {
linesPerRow: number;
fontSize: FontSize;
isDarkMode?: boolean;
}
export const TableBodyContent = styled.div<TableBodyContentProps>`
margin-bottom: 0;
color: ${(props): string =>
props.isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400};
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: ${(props): number => props.linesPerRow};
line-clamp: ${(props): number => props.linesPerRow};
-webkit-box-orient: vertical;
${({ fontSize }): string =>
fontSize === FontSize.SMALL
? `font-size:11px; line-height:16px;`
: fontSize === FontSize.MEDIUM
? `font-size:13px; line-height:20px;`
: `font-size:14px; line-height:24px;`}
`;

View File

@@ -1,5 +1,40 @@
import { TableColumnType as ColumnType } from 'antd';
import {
TableColumnsType as ColumnsType,
TableColumnType as ColumnType,
} from 'antd';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
export type ColumnTypeRender<T = unknown> = ReturnType<
NonNullable<ColumnType<T>['render']>
>;
export type LogsTableViewProps = {
logs: ILog[];
fields: IField[];
linesPerRow: number;
fontSize: FontSize;
onClickExpand?: (log: ILog) => void;
};
export type UseTableViewResult = {
columns: ColumnsType<Record<string, unknown>>;
dataSource: Record<string, string>[];
};
export type UseTableViewProps = {
appendTo?: 'center' | 'end';
onOpenLogsContext?: (log: ILog) => void;
onClickExpand?: (log: ILog) => void;
activeLog?: ILog | null;
activeLogIndex?: number;
activeContextLog?: ILog | null;
isListViewPanel?: boolean;
} & LogsTableViewProps;
export type ActionsColumnProps = {
logId: string;
logs: ILog[];
onOpenLogsContext?: (log: ILog) => void;
};

View File

@@ -0,0 +1,59 @@
.text {
color: var(--l2-foreground);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
&.small {
font-size: 11px;
line-height: 16px;
}
&.medium {
font-size: 13px;
line-height: 20px;
}
&.large {
font-size: 14px;
line-height: 24px;
}
}
.state-indicator {
width: 15px;
.log-state-indicator {
padding: 0px;
}
}
.table-timestamp {
display: flex;
align-items: center;
.timestamp-text {
color: var(--l1-foreground);
margin: 0 !important;
}
}
.paragraph {
margin: 0;
padding: 0px !important;
&.small {
font-size: 11px !important;
line-height: 16px !important;
}
&.medium {
font-size: 13px !important;
line-height: 20px !important;
}
&.large {
font-size: 14px !important;
line-height: 24px !important;
}
}

View File

@@ -0,0 +1,180 @@
import { useMemo } from 'react';
import { TableColumnsType as ColumnsType } from 'antd';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import {
defaultListViewPanelStyle,
defaultTableStyle,
getDefaultCellStyle,
} from './config';
import { TableBodyContent } from './styles';
import {
ColumnTypeRender,
UseTableViewProps,
UseTableViewResult,
} from './types';
import './useTableView.styles.scss';
export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
const {
logs,
fields,
linesPerRow,
fontSize,
appendTo = 'center',
isListViewPanel,
} = props;
const isDarkMode = useIsDarkMode();
const flattenLogData = useMemo(
() => logs.map((log) => FlatLogData(log)),
[logs],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const bodyColumnStyle = useMemo(
() => ({
...defaultTableStyle,
...(fields.length > 2 ? { width: 'auto' } : {}),
}),
[fields.length],
);
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
.filter((e) => !['id', 'body', 'timestamp'].includes(e.name))
.map(({ name }) => ({
title: name,
dataIndex: name,
accessorKey: name,
id: name.toLowerCase().replace(/\./g, '_'),
key: name,
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: {
...(isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode)),
display: '-webkit-box',
WebkitLineClamp: linesPerRow,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-all',
},
},
children: <p className={cx('paragraph', fontSize)}>{field}</p>,
}),
}));
if (isListViewPanel) {
return [...fieldColumns];
}
return [
{
// We do not need any title and data index for the log state indicator
title: '',
dataIndex: '',
key: 'state-indicator',
accessorKey: 'state-indicator',
id: 'state-indicator',
render: (_, item): ColumnTypeRender<Record<string, unknown>> => ({
children: (
<div className={cx('state-indicator', fontSize)}>
<LogStateIndicator
fontSize={fontSize}
severityText={item.severity_text as string}
severityNumber={item.severity_number as number}
/>
</div>
),
}),
},
...(fields.some((field) => field.name === 'timestamp')
? [
{
title: 'timestamp',
dataIndex: 'timestamp',
key: 'timestamp',
accessorKey: 'timestamp',
id: 'timestamp',
// https://github.com/ant-design/ant-design/discussions/36886
render: (
field: string | number,
): ColumnTypeRender<Record<string, unknown>> => {
const date =
typeof field === 'string'
? formatTimezoneAdjustedTimestamp(
field,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
field / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return {
children: (
<div className="table-timestamp">
<p className={cx('timestamp-text text', fontSize)}>{date}</p>
</div>
),
};
},
},
]
: []),
...(appendTo === 'center' ? fieldColumns : []),
...(fields.some((field) => field.name === 'body')
? [
{
title: 'body',
dataIndex: 'body',
key: 'body',
accessorKey: 'body',
id: 'body',
render: (
field: string | number,
): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: bodyColumnStyle,
},
children: (
<TableBodyContent
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(field as string, {
shouldEscapeHtml: true,
}),
}}
fontSize={fontSize}
linesPerRow={linesPerRow}
isDarkMode={isDarkMode}
/>
),
}),
},
]
: []),
...(appendTo === 'end' ? fieldColumns : []),
];
}, [
fields,
isListViewPanel,
appendTo,
isDarkMode,
linesPerRow,
fontSize,
formatTimezoneAdjustedTimestamp,
bodyColumnStyle,
]);
return { columns, dataSource: flattenLogData };
};

View File

@@ -6,27 +6,31 @@ import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { getMs } from 'utils/timeUtils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
import { DurationSection } from 'pages/TracesExplorer/Filter/DurationSection';
import {
AllTraceFilterKeys,
AllTraceFilterKeyValue,
HandleRunProps,
traceFilterKeys,
} from 'constants/traceFilterKeys';
unionTagFilterItems,
} from 'pages/TracesExplorer/Filter/filterUtils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { clearFilterFromQuery } from '../shared/filterQuery';
import { SectionActionButton } from '../shared/SectionActionButton/SectionActionButton';
import { DurationSection } from './DurationSection';
import { FilterType, HandleRunProps, unionTagFilterItems } from './utils';
import './Duration.styles.scss';
export type { FilterType };
export type FilterType = Record<
AllTraceFilterKeys,
{ values: string[] | string; keys: BaseAutocompleteData }
>;
function Duration({
filter,

View File

@@ -1,101 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { AllTraceFilterKeys } from 'constants/traceFilterKeys';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
export type FilterType = Record<
AllTraceFilterKeys,
{ values: string[] | string; keys: BaseAutocompleteData }
>;
export interface HandleRunProps {
resetAll?: boolean;
clearByType?: AllTraceFilterKeys;
}
function convertToStringArr(value: string | string[] | undefined): string[] {
if (value) {
if (typeof value === 'string') {
return [value];
}
return value;
}
return [];
}
export const addFilter = (
filterType: AllTraceFilterKeys,
value: string,
setSelectedFilters: Dispatch<
SetStateAction<
| Record<
AllTraceFilterKeys,
{ values: string[] | string; keys: BaseAutocompleteData }
>
| undefined
>
>,
keys: BaseAutocompleteData,
): void => {
setSelectedFilters((prevFilters) => {
const isDuration = [
'durationNanoMax',
'durationNanoMin',
'durationNano',
].includes(filterType);
// Convert value to string array
const valueArray = convertToStringArr(value);
// If previous filters are undefined, initialize them
if (!prevFilters) {
return {
[filterType]: { values: isDuration ? value : valueArray, keys },
} as unknown as FilterType;
}
// If the filter type doesn't exist, initialize it
if (!prevFilters[filterType]?.values.length) {
return {
...prevFilters,
[filterType]: { values: isDuration ? value : valueArray, keys },
};
}
// If the value already exists, don't add it again
if (convertToStringArr(prevFilters[filterType].values).includes(value)) {
return prevFilters;
}
// Otherwise, add the value to the existing array
return {
...prevFilters,
[filterType]: {
values: isDuration
? value
: [...convertToStringArr(prevFilters[filterType].values), value],
keys,
},
};
});
};
/** Merges two filter lists; later items win on the same key + operator. */
export function unionTagFilterItems(
items1: TagFilterItem[],
items2: TagFilterItem[],
): TagFilterItem[] {
const unionMap = new Map<string, TagFilterItem>();
items1?.forEach((item) => {
const keyOp = `${item?.key?.key}_${item?.op}`;
unionMap.set(keyOp, item);
});
items2?.forEach((item) => {
const keyOp = `${item?.key?.key}_${item?.op}`;
unionMap.set(keyOp, item);
});
return Array.from(unionMap?.values());
}

View File

@@ -41,6 +41,7 @@ export enum LOCALSTORAGE {
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',

View File

@@ -29,7 +29,6 @@ export const getComponentForPanelType = (
[PANEL_TYPES.LIST]:
dataSource === DataSource.LOGS ? LogsPanelComponent : TracesTableComponent,
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.AREA]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.

View File

@@ -336,7 +336,6 @@ export enum PANEL_TYPES {
LIST = 'list',
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',

View File

@@ -1,4 +1,4 @@
import { OperatorValues } from 'hooks/useResourceAttribute/types';
import { OperatorValues } from 'types/reducer/trace';
export const OperatorConversions: Array<{
label: string;

View File

@@ -6,8 +6,9 @@ const ROUTES = {
SERVICE_METRICS: '/services/:servicename',
SERVICE_TOP_LEVEL_OPERATIONS: '/services/:servicename/top-level-operations',
SERVICE_MAP: '/service-map',
TRACE_BASE: '/trace',
TRACE: '/trace',
TRACE_DETAIL: '/trace/:id',
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED_WITH_CLOUD: '/get-started-with-signoz-cloud',

View File

@@ -1,124 +0,0 @@
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
/** Trace attribute key -> label shown in quick filters, the options menu and APM "view traces" links. */
export const AllTraceFilterKeyValue: Record<string, string> = {
durationNanoMin: 'Duration',
durationNano: 'Duration',
duration_nano: 'Duration',
durationNanoMax: 'Duration',
'deployment.environment': 'Environment',
hasError: 'Status',
has_error: 'Status',
serviceName: 'Service Name',
'service.name': 'service.name',
name: 'Operation / Name',
rpcMethod: 'RPC Method',
'rpc.method': 'RPC Method',
responseStatusCode: 'Status Code',
response_status_code: 'Status Code',
httpHost: 'HTTP Host',
http_host: 'HTTP Host',
httpMethod: 'HTTP Method',
http_method: 'HTTP Method',
httpRoute: 'HTTP Route',
'http.route': 'HTTP Route',
httpUrl: 'HTTP URL',
[SPAN_ATTRIBUTES.HTTP_URL]: 'HTTP URL',
traceID: 'Trace ID',
trace_id: 'Trace ID',
} as const;
export type AllTraceFilterKeys = keyof typeof AllTraceFilterKeyValue;
export const traceFilterKeys: Record<AllTraceFilterKeys, BaseAutocompleteData> =
{
durationNano: {
key: 'durationNano',
dataType: DataTypes.Float64,
type: 'tag',
id: 'durationNano--float64--tag--true',
},
hasError: {
key: 'hasError',
dataType: DataTypes.bool,
type: 'tag',
id: 'hasError--bool--tag--true',
},
serviceName: {
key: 'serviceName',
dataType: DataTypes.String,
type: 'tag',
id: 'serviceName--string--tag--true',
},
'deployment.environment': {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
id: 'deployment.environment--string--resource--false',
},
name: {
key: 'name',
dataType: DataTypes.String,
type: 'tag',
id: 'name--string--tag--true',
},
rpcMethod: {
key: 'rpcMethod',
dataType: DataTypes.String,
type: 'tag',
id: 'rpcMethod--string--tag--true',
},
responseStatusCode: {
key: 'responseStatusCode',
dataType: DataTypes.String,
type: 'tag',
id: 'responseStatusCode--string--tag--true',
},
httpHost: {
key: 'httpHost',
dataType: DataTypes.String,
type: 'tag',
id: 'httpHost--string--tag--true',
},
httpMethod: {
key: 'httpMethod',
dataType: DataTypes.String,
type: 'tag',
id: 'httpMethod--string--tag--true',
},
httpRoute: {
key: 'httpRoute',
dataType: DataTypes.String,
type: 'tag',
id: 'httpRoute--string--tag--true',
},
httpUrl: {
key: 'httpUrl',
dataType: DataTypes.String,
type: 'tag',
id: 'httpUrl--string--tag--true',
},
traceID: {
key: 'traceID',
dataType: DataTypes.String,
type: 'tag',
id: 'traceID--string--tag--true',
},
durationNanoMin: {
key: 'durationNanoMin',
dataType: DataTypes.Float64,
type: 'tag',
id: 'durationNanoMin--float64--tag--true',
},
durationNanoMax: {
key: 'durationNanoMax',
dataType: DataTypes.Float64,
type: 'tag',
id: 'durationNanoMax--float64--tag--true',
},
} as const;

View File

@@ -0,0 +1,16 @@
.span-container {
.spanDetails {
position: absolute;
height: 50px;
padding: 8px;
min-width: 150px;
background: lightcyan;
color: black;
bottom: 24px;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
}

View File

@@ -0,0 +1,96 @@
import { useEffect } from 'react';
import { Popover } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { toFixed } from 'utils/toFixed';
import { SpanBorder, SpanLine, SpanText, SpanWrapper } from './styles';
import '../GantChart.styles.scss';
interface SpanLengthProps {
globalStart: number;
startTime: number;
name: string;
width: string;
leftOffset: string;
bgColor: string;
inMsCount: number;
}
function Span(props: SpanLengthProps): JSX.Element {
const { width, leftOffset, bgColor, inMsCount, startTime, name, globalStart } =
props;
const isDarkMode = useIsDarkMode();
const { time, timeUnitName } = convertTimeToRelevantUnit(inMsCount);
const { timezone } = useTimezone();
useEffect(() => {
document.documentElement.scrollTop = document.documentElement.clientHeight;
document.documentElement.scrollLeft = document.documentElement.clientWidth;
}, []);
const getContent = (): JSX.Element => {
const timeStamp = dayjs(startTime)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.TIME_UTC_MS);
const startTimeInMs = startTime - globalStart;
return (
<div>
<Typography.Text style={{ marginBottom: '8px' }}>
{' '}
Duration : {inMsCount}
</Typography.Text>
<br />
<Typography.Text style={{ marginBottom: '8px' }}>
Start Time: {startTimeInMs}ms [{timeStamp}]{' '}
</Typography.Text>
</div>
);
};
return (
<SpanWrapper className="span-container">
<SpanLine
className="spanLine"
isDarkMode={isDarkMode}
bgColor={bgColor}
leftOffset={leftOffset}
width={width}
/>
<div>
<Popover
style={{
left: `${leftOffset}%`,
}}
title={name}
content={getContent()}
trigger="hover"
placement="left"
autoAdjustOverflow
>
<SpanBorder
className="spanTrack"
isDarkMode={isDarkMode}
bgColor={bgColor}
leftOffset={leftOffset}
width={width}
/>
</Popover>
</div>
<SpanText isDarkMode={isDarkMode} leftOffset={leftOffset}>{`${toFixed(
time,
2,
)} ${timeUnitName}`}</SpanText>
</SpanWrapper>
);
}
export default Span;

View File

@@ -0,0 +1,52 @@
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
interface Props {
width: string;
leftOffset: string;
bgColor: string;
isDarkMode: boolean;
}
export const SpanLine = styled.div<Props>`
width: ${({ leftOffset }): string => `${leftOffset}%`};
height: 0px;
border-bottom: 0.1px solid
${({ isDarkMode }): string => (isDarkMode ? '#303030' : '#c0c0c0')};
top: 50%;
position: absolute;
`;
export const SpanBorder = styled.div<Props>`
background: ${({ bgColor }): string => bgColor};
border-radius: 5px;
height: 0.625rem;
width: ${({ width }): string => `${width}%`};
left: ${({ leftOffset }): string => `${leftOffset}%`};
top: 35%;
position: absolute;
`;
export const SpanWrapper = styled.div`
display: flex;
width: 100%;
flex-direction: row;
align-items: center;
position: relative;
z-index: 2;
min-height: 2rem;
`;
interface SpanTextProps extends Pick<Props, 'leftOffset'> {
isDarkMode: boolean;
}
export const SpanText = styled(Typography.Text)<SpanTextProps>`
&&& {
left: ${({ leftOffset }): string => `${leftOffset}%`};
top: 65%;
position: absolute;
width: max-content;
color: ${({ isDarkMode }): string => (isDarkMode ? '#ACACAC' : '#666')};
font-size: 0.75rem;
}
`;

View File

@@ -0,0 +1,22 @@
import { Container, Service, Span, SpanWrapper } from './styles';
function SpanNameComponent({
name,
serviceName,
}: SpanNameComponentProps): JSX.Element {
return (
<Container title={`${name} ${serviceName}`}>
<SpanWrapper>
<Span truncate={1}>{name}</Span>
<Service truncate={1}>{serviceName}</Service>
</SpanWrapper>
</Container>
);
}
interface SpanNameComponentProps {
name: string;
serviceName: string;
}
export default SpanNameComponent;

View File

@@ -0,0 +1,41 @@
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const Span = styled(Typography.Text)`
&&& {
font-size: 0.75rem;
margin: 0;
/* border-bottom: 1px solid grey; */
}
`;
export const Service = styled(Typography.Text)`
&&& {
color: #acacac;
font-size: 0.75rem;
}
`;
export const SpanWrapper = styled.div`
display: flex;
flex-direction: column;
margin-left: 0.625rem;
width: 10rem;
`;
export const SpanConnector = styled.div`
width: 37px;
border: 1px solid #303030;
height: 0;
`;
export const Container = styled.div`
display: flex;
align-items: center;
justify-content: flex-start;
`;
export const SpanName = styled.div`
width: fit-content;
border-bottom: 1px solid black;
`;

View File

@@ -0,0 +1,233 @@
import {
Dispatch,
MouseEventHandler,
SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Col } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { StyledCol, StyledRow } from 'components/Styled';
import {
IIntervalUnit,
SPAN_DETAILS_LEFT_COL_WIDTH,
} from 'container/TraceDetail/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ITraceTree } from 'types/api/trace/getTraceItem';
import { ITraceMetaData } from '..';
import Span from '../Span';
import SpanName from '../SpanName';
import { getMetaDataFromSpanTree, getTopLeftFromBody } from '../utils';
import {
CardComponent,
CardContainer,
CaretContainer,
HoverCard,
styles,
Wrapper,
} from './styles';
import { getIconStyles } from './utils';
function Trace(props: TraceProps): JSX.Element {
const {
name,
activeHoverId,
setActiveHoverId,
globalSpread,
globalStart,
serviceName,
startTime,
value,
serviceColour,
id,
setActiveSelectedId,
activeSelectedId,
level,
activeSpanPath,
isExpandAll,
intervalUnit,
children,
isMissing,
} = props;
const isDarkMode = useIsDarkMode();
const [isOpen, setOpen] = useState<boolean>(activeSpanPath[level] === id);
const localTreeExpandInteraction = useRef<boolean | 0>(0); // Boolean is for the state of the expansion whereas the number i.e. 0 is for skipping the user interaction.
useEffect(() => {
if (localTreeExpandInteraction.current !== 0) {
setOpen(localTreeExpandInteraction.current);
localTreeExpandInteraction.current = 0;
} else if (!isOpen) {
setOpen(activeSpanPath[level] === id);
}
}, [activeSpanPath, isOpen, id, level]);
useEffect(() => {
if (isExpandAll) {
setOpen(isExpandAll);
} else {
setOpen(activeSpanPath[level] === id);
}
}, [isExpandAll, activeSpanPath, id, level]);
const isOnlyChild = children.length === 1;
const [top, setTop] = useState<number>(0);
const ref = useRef<HTMLUListElement>(null);
useEffect(() => {
if (activeSelectedId === id) {
ref.current?.scrollIntoView({
block: 'nearest',
behavior: 'auto',
inline: 'nearest',
});
}
}, [activeSelectedId, id]);
const onMouseEnterHandler = (): void => {
setActiveHoverId(id);
if (ref.current) {
const { top } = getTopLeftFromBody(ref.current);
setTop(top);
}
};
const onMouseLeaveHandler = (): void => {
setActiveHoverId('');
};
const onClick = (): void => {
setActiveSelectedId(id);
};
const onClickTreeExpansion: MouseEventHandler<HTMLDivElement> = (
event,
): void => {
event.stopPropagation();
setOpen((state) => {
localTreeExpandInteraction.current = !isOpen;
return !state;
});
};
const { totalSpans } = getMetaDataFromSpanTree(props);
const inMsCount = value;
const nodeLeftOffset = ((startTime - globalStart) * 1e2) / globalSpread;
const width = (value * 1e2) / (globalSpread * 1e6);
const panelWidth = SPAN_DETAILS_LEFT_COL_WIDTH - level * (16 + 1) - 48;
const iconStyles = useMemo(() => getIconStyles(), []);
const icon = useMemo(
() =>
isOpen ? (
<ChevronDown size="md" style={iconStyles} />
) : (
<ChevronRight size="md" style={iconStyles} />
),
[isOpen, iconStyles],
);
return (
<Wrapper
onMouseEnter={onMouseEnterHandler}
onMouseLeave={onMouseLeaveHandler}
isOnlyChild={isOnlyChild}
ref={ref}
isDarkMode={isDarkMode}
>
<HoverCard
top={top}
isHovered={activeHoverId === id}
isSelected={activeSelectedId === id}
isDarkMode={isDarkMode}
/>
<CardContainer isMissing={isMissing} onClick={onClick}>
<StyledCol flex={`${panelWidth}px`} styledclass={[styles.overFlowHidden]}>
<StyledRow styledclass={[styles.flexNoWrap]}>
<Col>
{totalSpans !== 1 && (
<CardComponent
isOnlyChild={isOnlyChild}
isDarkMode={isDarkMode}
onClick={onClickTreeExpansion}
>
<Typography style={{ wordBreak: 'normal' }}>{totalSpans}</Typography>
<CaretContainer>{icon}</CaretContainer>
</CardComponent>
)}
</Col>
<Col>
<SpanName name={name} serviceName={serviceName} />
</Col>
</StyledRow>
</StyledCol>
<Col flex="1">
<Span
globalStart={globalStart}
startTime={startTime}
name={name}
leftOffset={nodeLeftOffset.toString()}
width={width.toString()}
bgColor={serviceColour}
inMsCount={inMsCount / 1e6}
/>
</Col>
</CardContainer>
{isOpen && (
<>
{children.map((child) => (
<Trace
key={child.id}
activeHoverId={activeHoverId}
setActiveHoverId={setActiveHoverId}
{...child}
globalSpread={globalSpread}
globalStart={globalStart}
setActiveSelectedId={setActiveSelectedId}
activeSelectedId={activeSelectedId}
level={level + 1}
activeSpanPath={activeSpanPath}
isExpandAll={isExpandAll}
intervalUnit={intervalUnit}
isMissing={child.isMissing}
/>
))}
</>
)}
</Wrapper>
);
}
Trace.defaultProps = {
isMissing: false,
};
interface ITraceGlobal {
globalSpread: ITraceMetaData['spread'];
globalStart: ITraceMetaData['globalStart'];
}
interface TraceProps extends ITraceTree, ITraceGlobal {
activeHoverId: string;
setActiveHoverId: Dispatch<SetStateAction<string>>;
setActiveSelectedId: Dispatch<SetStateAction<string>>;
activeSelectedId: string;
level: number;
activeSpanPath: string[];
isExpandAll: boolean;
intervalUnit: IIntervalUnit;
isMissing?: boolean;
}
export default Trace;

View File

@@ -0,0 +1,113 @@
import { volcano } from '@ant-design/colors';
import styled, {
css,
DefaultTheme,
ThemedCssFunction,
} from 'styled-components';
interface Props {
isOnlyChild: boolean;
}
export const Wrapper = styled.ul<Props>`
display: flex;
flex-direction: column;
padding-bottom: 0.5rem;
padding-top: 0.5rem;
position: relative;
z-index: 1;
ul {
border-left: ${({ isOnlyChild }): StyledCSS =>
isOnlyChild && 'none'} !important;
${({ isOnlyChild }): StyledCSS =>
isOnlyChild &&
css`
&:before {
border-left: 1px solid #434343;
display: inline-block;
content: '';
height: 54px;
position: absolute;
left: 0;
top: -35px;
}
`}
}
`;
export const CardContainer = styled.li<{ isMissing?: boolean }>`
display: flex;
width: 100%;
cursor: pointer;
border-radius: 0.25rem;
z-index: 2;
${({ isMissing }): string =>
isMissing ? `border: 1px dashed ${volcano[6]} !important;` : ''}
`;
interface Props {
isDarkMode: boolean;
}
export type StyledCSS =
| ReturnType<ThemedCssFunction<DefaultTheme>>
| string
| false
| undefined;
export const CardComponent = styled.div<Props>`
border: 1px solid
${({ isDarkMode }): StyledCSS => (isDarkMode ? '#434343' : '#333')};
box-sizing: border-box;
border-radius: 2px;
display: flex;
justify-content: center;
align-items: center;
padding: 1px 8px;
background: ${({ isDarkMode }): StyledCSS =>
isDarkMode ? '#1d1d1d' : '#ddd'};
height: 22px;
`;
export const CaretContainer = styled.span`
margin-left: 0.304rem;
`;
interface HoverCardProps {
isHovered: boolean;
isSelected: boolean;
top: number;
isDarkMode: boolean;
}
export const HoverCard = styled.div<HoverCardProps>`
display: ${({ isSelected, isHovered }): string =>
isSelected || isHovered ? 'block' : 'none'};
width: 200%;
background-color: ${({ isHovered, isDarkMode }): string => {
if (isHovered) {
return isDarkMode ? '#262626' : '#ddd';
}
return isDarkMode ? '#4f4f4f' : '#bbb';
}};
position: absolute;
top: 0;
left: -100%;
right: 0;
height: 3rem;
opacity: 0.5;
`;
const flexNoWrap = css`
flex-wrap: nowrap;
`;
const overFlowHidden = css`
overflow: hidden;
`;
export const styles = {
flexNoWrap,
overFlowHidden,
};

View File

@@ -0,0 +1,3 @@
export const getIconStyles = (): Record<string, string> => ({
color: 'var(--l1-foreground)',
});

View File

@@ -0,0 +1,91 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { SquareMinus, SquarePlus } from '@signozhq/icons';
import { IIntervalUnit } from 'container/TraceDetail/utils';
import { ITraceTree } from 'types/api/trace/getTraceItem';
import { CardContainer, CardWrapper, CollapseButton } from './styles';
import Trace from './Trace';
import { getSpanPath } from './utils';
function GanttChart(props: GanttChartProps): JSX.Element {
const {
data,
traceMetaData,
activeHoverId,
setActiveHoverId,
activeSelectedId,
setActiveSelectedId,
spanId,
intervalUnit,
} = props;
const { globalStart, spread: globalSpread } = traceMetaData;
const [isExpandAll, setIsExpandAll] = useState<boolean>(false);
const [activeSpanPath, setActiveSpanPath] = useState<string[]>([]);
useEffect(() => {
setActiveSpanPath(getSpanPath(data, spanId));
}, [spanId, data]);
useEffect(() => {
setActiveSpanPath(getSpanPath(data, activeSelectedId));
}, [activeSelectedId, data]);
const handleCollapse = (): void => {
setIsExpandAll((prev) => !prev);
};
return (
<CardContainer>
<CollapseButton
onClick={handleCollapse}
title={isExpandAll ? 'Collapse All' : 'Expand All'}
>
{isExpandAll ? (
<SquareMinus size={16} style={{ color: 'var(--accent-primary)' }} />
) : (
<SquarePlus size={16} style={{ color: 'var(--accent-primary)' }} />
)}
</CollapseButton>
<CardWrapper>
<Trace
activeHoverId={activeHoverId}
activeSpanPath={activeSpanPath}
setActiveHoverId={setActiveHoverId}
key={data.id}
{...{
...data,
globalSpread,
globalStart,
setActiveSelectedId,
activeSelectedId,
}}
level={0}
isExpandAll={isExpandAll}
intervalUnit={intervalUnit}
/>
</CardWrapper>
</CardContainer>
);
}
export interface ITraceMetaData {
globalEnd: number;
globalStart: number;
levels: number;
spread: number;
totalSpans: number;
}
export interface GanttChartProps {
data: ITraceTree;
traceMetaData: ITraceMetaData;
activeSelectedId: string;
activeHoverId: string;
setActiveHoverId: Dispatch<SetStateAction<string>>;
setActiveSelectedId: Dispatch<SetStateAction<string>>;
spanId: string;
intervalUnit: IIntervalUnit;
}
export default GanttChart;

View File

@@ -0,0 +1,48 @@
import styled from 'styled-components';
export const Wrapper = styled.ul`
padding-left: 0;
position: absolute;
width: 100%;
height: 100%;
ul {
list-style: none;
border-left: 1px solid #434343;
padding-left: 1rem;
width: 100%;
margin: 0px;
}
ul li {
position: relative;
&:before {
position: absolute;
left: -1rem;
top: 10px;
content: '';
height: 1px;
width: 1rem;
background-color: #434343;
}
}
`;
export const CardWrapper = styled.div`
display: flex;
width: 100%;
margin-left: 1rem;
margin-top: 1.5rem;
`;
export const CardContainer = styled.li`
display: flex;
width: 100%;
position: relative;
`;
export const CollapseButton = styled.div`
position: absolute;
top: 0;
`;

View File

@@ -0,0 +1,203 @@
import { set } from 'lodash-es';
import { ITraceForest, ITraceTree } from 'types/api/trace/getTraceItem';
interface GetTraceMetaData {
globalStart: number;
globalEnd: number;
spread: number;
totalSpans: number;
levels: number;
}
export const getMetaDataFromSpanTree = (
treeData: ITraceTree,
): GetTraceMetaData => {
let globalStart = Number.POSITIVE_INFINITY;
let globalEnd = Number.NEGATIVE_INFINITY;
let totalSpans = 0;
let levels = 1;
const traverse = (treeNode: ITraceTree, level = 0): void => {
if (!treeNode) {
return;
}
totalSpans += 1;
levels = Math.max(levels, level);
const { startTime } = treeNode;
const endTime = startTime + treeNode.value;
globalStart = Math.min(globalStart, startTime);
globalEnd = Math.max(globalEnd, endTime);
treeNode.children.forEach((childNode) => {
traverse(childNode, level + 1);
});
};
traverse(treeData, 1);
globalStart *= 1e6;
globalEnd *= 1e6;
return {
globalStart,
globalEnd,
spread: globalEnd - globalStart,
totalSpans,
levels,
};
};
export function getTopLeftFromBody(elem: HTMLElement): {
top: number;
left: number;
} {
const box = elem.getBoundingClientRect();
const { body } = document;
const docEl = document.documentElement;
const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const top = box.top + scrollTop - clientTop;
const left = box.left + scrollLeft - clientLeft;
return { top: Math.round(top), left: Math.round(left) };
}
export const getNodeById = (
searchingId: string,
treesData: ITraceForest | undefined,
): ITraceForest => {
const newtreeData: ITraceForest = {} as ITraceForest;
const traverse = (
treeNode: ITraceTree,
setCallBack: (arg0: ITraceTree) => void,
level = 0,
): void => {
if (!treeNode) {
return;
}
if (searchingId === treeNode.id) {
setCallBack(treeNode);
}
treeNode.children.forEach((childNode) => {
traverse(childNode, setCallBack, level + 1);
});
};
const spanTreeSetCallback = (
path: (keyof ITraceForest)[],
value: ITraceTree,
): ITraceForest => set(newtreeData, path, [value]);
if (treesData?.spanTree) {
treesData.spanTree.forEach((tree) => {
traverse(tree, (value) => spanTreeSetCallback(['spanTree'], value), 1);
});
}
if (treesData?.missingSpanTree) {
treesData.missingSpanTree.forEach((tree) => {
traverse(
tree,
(value) => spanTreeSetCallback(['missingSpanTree'], value),
1,
);
});
}
return newtreeData;
};
const getSpanWithoutChildren = (
span: ITraceTree,
): Omit<ITraceTree, 'children'> => ({
id: span.id,
name: span.name,
parent: span.parent,
serviceColour: span.serviceColour,
serviceName: span.serviceName,
startTime: span.startTime,
tags: span.tags,
time: span.time,
value: span.value,
event: span.event,
hasError: span.hasError,
spanKind: span.spanKind,
statusCodeString: span.statusCodeString,
statusMessage: span.statusMessage,
});
export const isSpanPresentInSearchString = (
searchedString: string,
tree: ITraceTree,
): boolean => {
const parsedTree = getSpanWithoutChildren(tree);
const stringifyTree = JSON.stringify(parsedTree);
return stringifyTree.includes(searchedString);
};
export const isSpanPresent = (
tree: ITraceTree,
searchedKey: string,
): ITraceTree[] => {
const foundNode: ITraceTree[] = [];
const traverse = (
treeNode: ITraceTree,
level = 0,
foundNode: ITraceTree[],
): void => {
if (!treeNode) {
return;
}
const isPresent = isSpanPresentInSearchString(searchedKey, treeNode);
if (isPresent) {
foundNode.push(treeNode);
}
treeNode.children.forEach((childNode) => {
traverse(childNode, level + 1, foundNode);
});
};
traverse(tree, 1, foundNode);
return foundNode;
};
export const getSpanPath = (tree: ITraceTree, spanId: string): string[] => {
const spanPath: string[] = [];
const traverse = (treeNode: ITraceTree): boolean => {
if (!treeNode) {
return false;
}
spanPath.push(treeNode.id);
if (spanId === treeNode.id) {
return true;
}
let foundInChild = false;
treeNode.children.forEach((childNode) => {
if (traverse(childNode)) {
foundInChild = true;
}
});
if (!foundInChild) {
spanPath.pop();
}
return foundInChild;
};
traverse(tree);
return spanPath;
};

View File

@@ -28,7 +28,7 @@ import {
} from 'types/api/licensesV3/getActive';
import { ServicesList } from 'types/api/metrics/getService';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'hooks/useResourceAttribute/types';
import { Tags } from 'types/reducer/trace';
import { USER_ROLES } from 'types/roles';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';

View File

@@ -82,7 +82,7 @@ export function getHostMetricsQueryPayload(
start: number,
end: number,
): ReturnType<typeof getHostQueryPayload> {
return getHostQueryPayload(host.hostName, start, end);
return getHostQueryPayload(host.hostName, start, end, true);
}
export { hostWidgetInfo };

View File

@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { TextNoData } from '../../components/TextNoData';
import { getMs } from 'utils/timeUtils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
getTraceLink,

View File

@@ -333,7 +333,6 @@ describe('AttributeMappingsTab (integration)', () => {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
enabled: true,
})),
},
}),

View File

@@ -1,12 +1,10 @@
import { Typography } from '@signozhq/ui/typography';
import { ConditionKey } from 'container/LLMObservability/AttributeMapping/types';
import styles from './ConditionsTooltip.module.scss';
interface ConditionsTooltipProps {
attributes: ConditionKey[];
resource: ConditionKey[];
attributes: string[];
resource: string[];
}
function ConditionsTooltip({
@@ -35,8 +33,8 @@ function ConditionsTooltip({
</Typography.Text>
<div className={styles.keyList}>
{attributes.map((key) => (
<code key={`${key.origin}-${key.value}`} className={styles.key}>
{key.value}
<code key={key} className={styles.key}>
{key}
</code>
))}
</div>
@@ -49,8 +47,8 @@ function ConditionsTooltip({
</Typography.Text>
<div className={styles.keyList}>
{resource.map((key) => (
<code key={`${key.origin}-${key.value}`} className={styles.key}>
{key.value}
<code key={key} className={styles.key}>
{key}
</code>
))}
</div>

View File

@@ -1,6 +1,7 @@
.tableWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.toolbar {

View File

@@ -2,10 +2,11 @@
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-2);
:global(.ant-tabs-tabpane) {
padding: var(--spacing-0) var(--spacing-8);
}
--tabs-content-padding: 0;
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
}
.pageError {

View File

@@ -1,8 +1,9 @@
import { useCallback } from 'react';
import { Tabs } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Tabs } from '@signozhq/ui/tabs';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import AttributeMappingActions from './components/AttributeMappingActions/AttributeMappingActions';
import AttributeMappingHeader from './components/AttributeMappingHeader/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
@@ -58,23 +59,24 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
{editor.saveError && (
<div className={styles.pageError} role="alert">
{editor.saveError}
</div>
)}
<Divider />
<Tabs
defaultActiveKey={MAPPINGS_TAB_KEY}
testId="attribute-mapping-tabs"
defaultValue={MAPPINGS_TAB_KEY}
items={tabItems}
tabBarExtraContent={
<AttributeMappingActions
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
}
/>
{groupDrawer.isOpen && (
<GroupFormDrawer

View File

@@ -63,26 +63,6 @@ const EDITED_SPAN_JSON = `{
}
}`;
const SPAN_WITH_EXTRA_KEY_JSON = `{
"attributes": {
"input.value": "What is quantum computing?"
},
"resource": {
"service.name": "llm-gateway"
},
"demo": {
"name": "demo"
}
}`;
const EXTRA_KEY_RESULT_SPAN = {
attributes: {
'input.value': 'What is quantum computing?',
[MAPPED_ATTRIBUTE_KEY]: 'What is quantum computing?',
},
resource: { 'service.name': 'llm-gateway' },
};
const SPAN_INPUT_KEY = LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN;
describe('TestTab — sample-span flow', () => {
@@ -124,47 +104,6 @@ describe('TestTab — sample-span flow', () => {
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
});
it('trims extra top-level keys and sends only the envelope', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let body: { spans?: { attributes?: Record<string, unknown> }[] } | undefined;
server.use(
rest.post(TEST_ENDPOINT, async (req, res, ctx) => {
body = await req.json();
return res(
ctx.status(200),
ctx.json(makeTestResponse([EXTRA_KEY_RESULT_SPAN])),
);
}),
);
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
const runBtn = await screen.findByTestId('run-test-button');
await user.clear(screen.getByTestId('monaco'));
await user.paste(SPAN_WITH_EXTRA_KEY_JSON);
await waitFor(() =>
expect(screen.getByTestId('monaco')).toHaveValue(SPAN_WITH_EXTRA_KEY_JSON),
);
expect(screen.queryByTestId('test-input-error')).not.toBeInTheDocument();
await user.click(runBtn);
await expect(
screen.findByTestId('test-results'),
).resolves.toBeInTheDocument();
expect(body?.spans?.[0]?.attributes).toStrictEqual({
'input.value': 'What is quantum computing?',
});
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
MAPPED_ATTRIBUTE_KEY,
);
expect(screen.getByTestId('test-result-0-resource')).toBeInTheDocument();
expect(screen.getByText('populated')).toBeInTheDocument();
});
it('surfaces a backend error and renders no results', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(

View File

@@ -1,51 +0,0 @@
import { parseSpanInput } from '../testPayload';
describe('parseSpanInput', () => {
it('reads the envelope and trims extra top-level keys', () => {
const span = parseSpanInput(`{
"attributes": { "llm.model_name": "gpt-4o" },
"resource": { "service.name": "llm-gateway" },
"demo": { "name": "demo" }
}`);
expect(span.attributes).toStrictEqual({ 'llm.model_name': 'gpt-4o' });
expect(span.resource).toStrictEqual({ 'service.name': 'llm-gateway' });
});
it('reads a clean envelope', () => {
const span = parseSpanInput(`{
"attributes": { "llm.model_name": "gpt-4o" },
"resource": { "service.name": "llm-gateway" }
}`);
expect(span.attributes).toStrictEqual({ 'llm.model_name': 'gpt-4o' });
expect(span.resource).toStrictEqual({ 'service.name': 'llm-gateway' });
});
it('treats an envelope-less object as a bare attribute map', () => {
const span = parseSpanInput('{ "llm.model_name": "gpt-4o", "demo": "x" }');
expect(span.attributes).toStrictEqual({
'llm.model_name': 'gpt-4o',
demo: 'x',
});
expect(span.resource).toStrictEqual({});
});
it('drops an envelope key that is not an object', () => {
const span = parseSpanInput(
'{ "attributes": { "llm.provider": "openai" }, "resource": "oops" }',
);
expect(span.attributes).toStrictEqual({ 'llm.provider': 'openai' });
expect(span.resource).toStrictEqual({});
});
it.each([
[' ', 'Paste a JSON span object to run the test.'],
['{ "a": }', 'Invalid JSON — check for trailing commas or missing quotes.'],
['[1, 2]', 'Span must be a JSON object of attribute key-value pairs.'],
])('rejects %p', (input, message) => {
expect(() => parseSpanInput(input)).toThrow(message);
});
});

View File

@@ -51,9 +51,13 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
// Any other top-level key (a real span carries name, spanId, kind...) is trimmed.
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
return isPlainObject(parsed.attributes) || isPlainObject(parsed.resource);
const keys = Object.keys(parsed);
return (
keys.length > 0 &&
keys.every((key) => key === 'attributes' || key === 'resource') &&
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
);
}
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {

View File

@@ -72,15 +72,20 @@ describe('LLMObservabilityAttributeMapping', () => {
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute Mappings',
});
expect(attributeMappingsTab).toHaveAttribute('aria-selected', 'true');
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('renders no Save/Discard while pristine', () => {
it('renders the header with its description and no Save/Discard while pristine', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
// The actions only appear once there are staged changes.
expect(screen.queryByTestId('save-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
@@ -119,11 +124,7 @@ describe('LLMObservabilityAttributeMapping', () => {
await user.click(screen.getByRole('tab', { name: 'Attribute Mappings' }));
await screen.findByTestId('attribute-mappings-tab');
// antd keeps a visited pane mounted and marks it aria-hidden, rather than
// unmounting it the way the previous tabs did.
expect(
screen.getByTestId('span-json-editor').closest('[role="tabpanel"]'),
).toHaveAttribute('aria-hidden', 'true');
expect(screen.queryByTestId('span-json-editor')).not.toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: 'Test' }));

View File

@@ -3,7 +3,6 @@ import {
SpantypesSpanMapperDTO as Mapper,
SpantypesSpanMapperGroupDTO as MapperGroup,
SpantypesSpanMapperOperationDTO as MapperOperation,
SpantypesSpanMapperOriginDTO as MapperOrigin,
SpantypesSpanMapperTestSpanDTO as TestSpan,
} from 'api/generated/services/sigNoz.schemas';
@@ -22,15 +21,9 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
orgId: 'org-1',
name: 'demo',
enabled: true,
origin: MapperOrigin.user,
version: 0,
condition: {
attributes: [
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
],
resource: [
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
],
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
...overrides,
};
@@ -42,7 +35,6 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
groupId: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
origin: MapperOrigin.user,
fieldContext: FieldContext.attribute,
config: {
sources: [
@@ -51,16 +43,12 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
enabled: true,
origin: MapperOrigin.user,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
enabled: true,
origin: MapperOrigin.user,
},
],
},
@@ -97,12 +85,8 @@ export const mockGroups: MapperGroup[] = [
id: 'group-1',
name: 'demo',
condition: {
attributes: [
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
],
resource: [
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
],
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
}),
makeGroup({

View File

@@ -1,10 +0,0 @@
.actions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -1,53 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingActions.module.scss';
interface AttributeMappingActionsProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingActions({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingActionsProps): JSX.Element | null {
const canManage = useCanManageAttributeMapping();
if (!canManage || !isDirty) {
return null;
}
return (
<div className={styles.actions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
);
}
export default AttributeMappingActions;

View File

@@ -0,0 +1,18 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-left: var(--spacing-2);
margin-top: var(--spacing-4);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -0,0 +1,56 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
return (
<header className={styles.pageHeader}>
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{canManage && isDirty && (
<div className={styles.pageHeaderActions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
)}
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -1,7 +1,4 @@
.groupForm {
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-10);
@@ -21,8 +18,11 @@
}
.groupFormLabel {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.groupFormHint {

View File

@@ -5,12 +5,17 @@
}
.label {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
color: var(--l3-foreground);
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.keys {

View File

@@ -1,23 +1,19 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import {
ConditionKey,
FieldContextValue,
} from 'container/LLMObservability/AttributeMapping/types';
import { createConditionKey } from 'container/LLMObservability/AttributeMapping/utils';
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './ConditionKeyList.module.scss';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: ConditionKey[];
keys: string[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: ConditionKey[]) => void;
onChange: (keys: string[]) => void;
}
function ConditionKeyList({
@@ -31,11 +27,11 @@ function ConditionKeyList({
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? { ...key, value } : key)));
onChange(keys.map((key, i) => (i === index ? value : key)));
};
const addKey = (): void => {
onChange([...keys, createConditionKey()]);
onChange([...keys, '']);
};
const removeKey = (index: number): void => {
@@ -57,7 +53,7 @@ function ConditionKeyList({
<KeySearchInput
className={styles.keyInput}
placeholder={placeholder}
value={key.value}
value={key}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}

View File

@@ -1,6 +1,4 @@
.form {
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-10);
@@ -14,12 +12,17 @@
}
.label {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
color: var(--l3-foreground);
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.hint {

View File

@@ -42,9 +42,7 @@ function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation &&
source.enabled === b[index].enabled &&
source.origin === b[index].origin,
source.operation === b[index].operation,
)
);
}

View File

@@ -1,11 +1,8 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperOriginDTO,
SpantypesSpanMapperSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
@@ -14,23 +11,21 @@ export const FieldContext = SpantypesFieldContextDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
export const MapperOrigin = SpantypesSpanMapperOriginDTO;
export type MapperOriginValue = SpantypesSpanMapperOriginDTO;
export type ConditionKey = SpantypesSpanMapperGroupConditionKeyDTO;
export type MapperDraftMode = 'add' | 'edit';
// `priority` is left out: it is derived from list order when the draft is
// serialized.
export type SourceConfig = Omit<SpantypesSpanMapperSourceDTO, 'priority'>;
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
// Editable form state for a mapper. `sources` is ordered highest priority
// first; `fieldContext` is where the standardized target is written.
export interface MapperDraft {
id: string | null;
name: string;
fieldContext: FieldContextValue;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
@@ -38,20 +33,26 @@ export interface MapperDraft {
export interface GroupDraft {
id: string | null;
name: string;
attributes: ConditionKey[];
resource: ConditionKey[];
attributes: string[];
resource: string[];
enabled: boolean;
}
// The editor tree identifies rows by `localId` so unsaved ones are addressable;
// `serverId` is null until the row has been persisted.
export type DraftMapper = Omit<MapperDraft, 'id'> & {
export interface DraftMapper {
localId: string;
serverId: string | null;
};
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
export type DraftGroup = Omit<GroupDraft, 'id'> & {
export interface DraftGroup {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
};
}

View File

@@ -1,14 +1,12 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import {
ConditionKey,
DraftGroup,
DraftMapper,
FieldContext,
@@ -17,7 +15,6 @@ import {
MapperDraft,
MapperGroup,
MapperOperation,
MapperOrigin,
SourceConfig,
} from './types';
@@ -27,36 +24,20 @@ function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
export function createConditionKey(value = ''): ConditionKey {
return { value, enabled: true, origin: MapperOrigin.user };
}
// Trimmed, de-duplicated, non-empty keys preserving input order. A shipped and
// a user key may share a value, so the origin is part of the identity.
function cleanKeys(keys: ConditionKey[]): ConditionKey[] {
// Trimmed, de-duplicated, non-empty keys preserving input order.
function cleanKeys(keys: string[]): string[] {
const seen = new Set<string>();
const result: ConditionKey[] = [];
const result: string[] = [];
keys.forEach((raw) => {
const value = raw.value.trim();
const dedupeKey = `${raw.origin}:${value}`;
if (value && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...raw, value });
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}
function fromConditionKeys(
keys: SpantypesSpanMapperGroupConditionKeyDTO[] | null | undefined,
): ConditionKey[] {
return (keys ?? []).map((key) => ({
value: key.value,
enabled: key.enabled,
origin: key.origin ?? MapperOrigin.user,
}));
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
function getMapperSources(mapper: Mapper): SourceConfig[] {
@@ -67,8 +48,6 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
key: source.key,
context: source.context,
operation: source.operation,
enabled: source.enabled,
origin: source.origin ?? MapperOrigin.user,
}));
}
@@ -77,8 +56,6 @@ export function createEmptySource(): SourceConfig {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
enabled: true,
origin: MapperOrigin.user,
};
}
@@ -95,7 +72,7 @@ function getCleanSources(draft: MapperDraft): SourceConfig[] {
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
const dedupeKey = `${source.origin}:${source.context}:${key}`;
const dedupeKey = `${source.context}:${key}`;
if (key && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...source, key });
@@ -118,8 +95,6 @@ function buildSources(
context: source.context,
operation: source.operation,
priority: sources.length - index,
enabled: source.enabled,
origin: source.origin,
}));
}
@@ -148,7 +123,7 @@ export function buildUpdatableMapper(
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [createConditionKey()],
attributes: [''],
resource: [],
enabled: true,
};
@@ -195,8 +170,8 @@ export function buildDraftGroup(
localId: group.id,
serverId: group.id,
name: group.name,
attributes: fromConditionKeys(group.condition?.attributes),
resource: fromConditionKeys(group.condition?.resource),
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
@@ -207,8 +182,7 @@ export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes:
group.attributes.length > 0 ? group.attributes : [createConditionKey()],
attributes: group.attributes.length > 0 ? group.attributes : [''],
resource: group.resource,
enabled: group.enabled,
};

View File

@@ -1,7 +1,6 @@
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { formUrlParams } from 'utils/traceUtils';
import { formUrlParams } from 'container/TraceDetail/utils';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export const getTraceLink = (record: Record<string, unknown>): string => {
@@ -15,11 +14,7 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
if (!traceId) {
return '';
}
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,

View File

@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { getMs } from 'utils/timeUtils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useTimezone } from 'providers/Timezone';
import {

View File

@@ -11,12 +11,10 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
// An aggregate outside the default order starts hidden, so the persisted
// defaults are observable.
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'unlisted_aggregate' },
{ name: 'start_time' },
]);
function RaceHarness(): JSX.Element {
@@ -68,9 +66,7 @@ describe('TracesTable column-init race', () => {
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('unlisted_aggregate')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'unlisted_aggregate',
]);
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
});
});

View File

@@ -128,7 +128,14 @@ describe('TracesView column persistence', () => {
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
@@ -153,7 +160,7 @@ describe('TracesView column persistence', () => {
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders the display-only columns when the field keys fail', async () => {
it('renders only the default-visible columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
@@ -161,8 +168,8 @@ describe('TracesView column persistence', () => {
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {

View File

@@ -109,24 +109,17 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'trace_id',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
...AGGREGATE_KEYS,
]);
});
@@ -134,24 +127,14 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'trace_id',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
'total_tokens',
'estimated_total_cost',
]);
});
@@ -210,24 +193,14 @@ describe('useTraceViewColumns', () => {
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'trace_id',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
'total_tokens',
'estimated_total_cost',
]);
});
});

View File

@@ -5,43 +5,20 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always present: it is the row's link to the trace, but it can be reordered. */
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
const DEFAULT_COLUMN_ORDER = [
TRACE_ID_COLUMN_ID,
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
];
const orderRank = (field: TelemetryFieldKey): number => {
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const sortByDefaultOrder = (
fields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
'total_tokens',
'estimated_total_cost',
TRACE_ID_COLUMN_ID,
]);
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
@@ -50,7 +27,7 @@ export const buildTraceViewColumns = (
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: true,
enableMove: field.name !== TRACE_ID_COLUMN_ID,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
}));

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