mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 16:40:35 +01:00
Compare commits
4 Commits
feat/llm-a
...
settings-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78ca0642b2 | ||
|
|
57ef60f0e3 | ||
|
|
b96b6918e9 | ||
|
|
9b774bb8d0 |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.129.0
|
||||
image: signoz/signoz:v0.128.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.129.0
|
||||
image: signoz/signoz:v0.128.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.129.0}
|
||||
image: signoz/signoz:${VERSION:-v0.128.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.129.0}
|
||||
image: signoz/signoz:${VERSION:-v0.128.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -1357,14 +1357,6 @@ components:
|
||||
- appservice
|
||||
- containerapp
|
||||
- aks
|
||||
- sqldatabase
|
||||
- sqldatabasemi
|
||||
- mysqlflexibleserver
|
||||
- postgresqlflexibleserver
|
||||
- mongodb
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -2591,41 +2583,6 @@ components:
|
||||
- panels
|
||||
- layouts
|
||||
type: object
|
||||
DashboardtypesDashboardView:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- data
|
||||
- orgId
|
||||
type: object
|
||||
DashboardtypesDashboardViewData:
|
||||
properties:
|
||||
order:
|
||||
$ref: '#/components/schemas/DashboardtypesListOrder'
|
||||
query:
|
||||
type: string
|
||||
sort:
|
||||
$ref: '#/components/schemas/DashboardtypesListSort'
|
||||
version:
|
||||
type: string
|
||||
required:
|
||||
- version
|
||||
type: object
|
||||
DashboardtypesDatasourcePlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -2910,15 +2867,6 @@ components:
|
||||
- total
|
||||
- tags
|
||||
type: object
|
||||
DashboardtypesListableDashboardView:
|
||||
properties:
|
||||
views:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardView'
|
||||
type: array
|
||||
required:
|
||||
- views
|
||||
type: object
|
||||
DashboardtypesListedDashboardForUserV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -3223,16 +3171,6 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPostableDashboardView:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- data
|
||||
type: object
|
||||
DashboardtypesPostablePublicDashboard:
|
||||
properties:
|
||||
defaultTimeRange:
|
||||
@@ -13382,231 +13320,6 @@ paths:
|
||||
summary: Update user preference
|
||||
tags:
|
||||
- preferences
|
||||
/api/v2/dashboard_views:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns every saved view in the calling user's org. Saved views
|
||||
are shared org-wide.
|
||||
operationId: ListDashboardViews
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesListableDashboardView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List dashboard saved views
|
||||
tags:
|
||||
- dashboard
|
||||
post:
|
||||
deprecated: false
|
||||
description: Persists the calling user's dashboard listing state (query, sort,
|
||||
order) as a named, reusable view shared across the org.
|
||||
operationId: CreateDashboardView
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Create dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboard_views/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Removes a saved view. Saved views are shared org-wide. Deleting
|
||||
a non-existent view returns 404.
|
||||
operationId: DeleteDashboardView
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Delete dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
operationId: UpdateDashboardView
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardView'
|
||||
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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -14000,74 +13713,6 @@ paths:
|
||||
summary: Update dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/clone:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint clones an existing v2-shape dashboard. User and integration
|
||||
dashboards can be cloned; system dashboards are rejected. The clone keeps
|
||||
the source's display name, panels, and tags, but gets a freshly generated
|
||||
unique internal name and is always created as an unlocked user dashboard owned
|
||||
by the caller.
|
||||
operationId: CloneDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Clone dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/lock:
|
||||
delete:
|
||||
deprecated: false
|
||||
|
||||
@@ -217,10 +217,6 @@ func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy
|
||||
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, source, postable)
|
||||
}
|
||||
|
||||
func (module *module) CloneV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.CloneV2(ctx, orgID, createdBy, creator, id)
|
||||
}
|
||||
|
||||
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
@@ -266,22 +262,6 @@ func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer
|
||||
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
|
||||
return module.pkgDashboardModule.CreateView(ctx, orgID, postable)
|
||||
}
|
||||
|
||||
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
|
||||
return module.pkgDashboardModule.ListViews(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdatableDashboardView) (*dashboardtypes.DashboardView, error) {
|
||||
return module.pkgDashboardModule.UpdateView(ctx, orgID, id, updateable)
|
||||
}
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/app/api"
|
||||
"github.com/SigNoz/signoz/ee/query-service/usage"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
@@ -57,12 +59,25 @@ type Server struct {
|
||||
|
||||
// NewServer creates and initializes Server
|
||||
func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
|
||||
Provider: "memory",
|
||||
Memory: cache.Memory{
|
||||
NumCounters: 10 * 10000,
|
||||
MaxCost: 1 << 27, // 128 MB
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader := clickhouseReader.NewReader(
|
||||
signoz.Instrumentation.Logger(),
|
||||
signoz.SQLStore,
|
||||
signoz.TelemetryStore,
|
||||
signoz.Prometheus,
|
||||
signoz.TelemetryStore.Cluster(),
|
||||
config.Querier.FluxInterval,
|
||||
cacheForTraceDetail,
|
||||
signoz.Cache,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -291,8 +291,6 @@
|
||||
// Prevents the usage of specific antd components in favor of our lib
|
||||
"signoz/no-signozhq-ui-barrel": "error",
|
||||
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
|
||||
"signoz/no-css-module-bracket-access": "warn",
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -2,33 +2,9 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/no-deep-nesting.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/no-id-selectors.js'),
|
||||
path.join(
|
||||
__dirname,
|
||||
'stylelint-rules/css-modules/no-bare-element-selectors.js',
|
||||
),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/prefer-css-variables.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/class-name-pattern.js'),
|
||||
],
|
||||
plugins: [path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js')],
|
||||
customSyntax: 'postcss-scss',
|
||||
rules: {
|
||||
// Applies to all SCSS files
|
||||
'local/no-unsupported-asset-url': true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// CSS module-specific rules
|
||||
files: ['**/*.module.scss'],
|
||||
rules: {
|
||||
'local/no-deep-nesting': [true, { severity: 'warning' }],
|
||||
'local/no-id-selectors': true,
|
||||
'local/no-bare-element-selectors': true,
|
||||
'local/prefer-css-variables': [true, { severity: 'warning' }],
|
||||
'local/class-name-pattern': [true, { severity: 'warning' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -23,8 +23,6 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Always add data-testid or testId (if supported) to critical/behavioral components like inputs, buttons, etc...
|
||||
- When creating test, these IDs should be used instead of finding by role.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
@@ -1,513 +0,0 @@
|
||||
# CSS Modules Guide
|
||||
|
||||
## Checklist Before Committing
|
||||
|
||||
- [ ] All class names use camelCase in CSS
|
||||
- [ ] State classes use `is-`/`has-` prefix (e.g., `isActive`, `hasError`)
|
||||
- [ ] No bracket access (`styles['...']`) in JS unless verified
|
||||
- [ ] No dynamic class lookup - use explicit variant maps instead
|
||||
- [ ] No deep class nesting (max 3 class levels; pseudo-classes/elements and parent-reference selectors like `&.active`, `&#bar` are not counted)
|
||||
- [ ] No hardcoded colors - use `--l1/l2/l3-*` semantic tokens (not `--bg-*` primitives)
|
||||
- [ ] No magic numbers - use `--spacing-*` tokens
|
||||
- [ ] Typography uses `--periscope-font-size-*` or `--font-size-*` tokens
|
||||
- [ ] @signozhq/ui overrides use CSS variables, not direct class overrides
|
||||
- [ ] Global escapes only for third-party overrides
|
||||
- [ ] No ID selectors
|
||||
- [ ] No bare element selectors
|
||||
- [ ] Keyframes use `:local(@keyframes name)` to avoid global collisions
|
||||
|
||||
## Config (vite.config.ts)
|
||||
|
||||
```ts
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** `camelCaseOnly` exports ONLY camelCase keys. Original kebab-case NOT accessible.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| CSS Class | JS Access | Works? | Preferred? |
|
||||
|-----------|-----------|--------|----------------------------|
|
||||
| `.alertHistory` | `styles.alertHistory` | Yes | Yes |
|
||||
| `.alert-history` | `styles.alertHistory` | Yes | No, use `.alertHistory` |
|
||||
| `.alert-history` | `styles['alert-history']` | NO - undefined | Never, use `.alertHistory` |
|
||||
|
||||
## Bad Patterns
|
||||
|
||||
### Class Naming
|
||||
|
||||
```scss
|
||||
// BAD: Bracket access won't work
|
||||
.my-class { }
|
||||
// Then in JS: styles['my-class'] -> undefined
|
||||
|
||||
// BAD: Collision - both become same key
|
||||
.alertHistory { }
|
||||
.alert-history { } // -> styles.alertHistory (conflicts)
|
||||
|
||||
// BAD: Underscore inconsistency
|
||||
.my_class { } // -> styles.myClass (confusing)
|
||||
|
||||
// GOOD: Direct camelCase
|
||||
.alertHistory { }
|
||||
.statsCard { }
|
||||
|
||||
// GOOD: State classes with is-/has- prefix
|
||||
.isDisabled { }
|
||||
.isActive { }
|
||||
.hasError { }
|
||||
.isLoading { }
|
||||
```
|
||||
|
||||
### Nesting
|
||||
|
||||
```scss
|
||||
// BAD: Deep nesting - specificity wars, hard to override
|
||||
.container {
|
||||
.wrapper {
|
||||
.inner {
|
||||
.content { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BAD: Nesting creates separate classes you might not expect
|
||||
.button {
|
||||
.icon { } // -> styles.icon (separate class, not scoped under .button)
|
||||
}
|
||||
|
||||
// GOOD: Flat structure
|
||||
.container { }
|
||||
.containerWrapper { }
|
||||
.containerContent { }
|
||||
|
||||
// GOOD: Nesting only for pseudo/states
|
||||
.button {
|
||||
&:hover { }
|
||||
&:disabled { }
|
||||
&::before { }
|
||||
}
|
||||
```
|
||||
|
||||
### Global Escapes
|
||||
|
||||
```scss
|
||||
// BAD: Overusing global
|
||||
:global {
|
||||
.everything { }
|
||||
.in-here { }
|
||||
.is-global { }
|
||||
}
|
||||
|
||||
// BAD: Global without necessity
|
||||
:global(.myComponent) { } // defeats purpose of modules
|
||||
|
||||
// GOOD: Targeted global for third-party overrides
|
||||
.container {
|
||||
:global(.ant-modal-content) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Selectors
|
||||
|
||||
```scss
|
||||
// BAD: ID selectors - not reusable
|
||||
#myComponent { }
|
||||
|
||||
// BAD: Element selectors without scope
|
||||
div { } // affects ALL divs in component
|
||||
|
||||
// BAD: Complex selectors
|
||||
.container > div + span ~ p { }
|
||||
|
||||
// GOOD: Class-only selectors
|
||||
.container { }
|
||||
.title { }
|
||||
```
|
||||
|
||||
### Variables & Values
|
||||
|
||||
```scss
|
||||
// BAD: Hardcoded colors
|
||||
.button {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
// BAD: Magic numbers
|
||||
.container {
|
||||
padding: 17px;
|
||||
margin-left: 43px;
|
||||
}
|
||||
|
||||
// GOOD: Semantic tokens (theme-aware)
|
||||
.button {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// GOOD: Spacing system
|
||||
.container {
|
||||
padding: var(--spacing-4);
|
||||
margin-left: var(--spacing-5);
|
||||
}
|
||||
```
|
||||
|
||||
## Design Tokens (@signozhq/design-tokens)
|
||||
|
||||
Prefer semantic tokens over hardcoded values.
|
||||
|
||||
You can read the ./node_modules/@signozhq/design-tokens/dist/style.css to find complete list of available tokens.
|
||||
|
||||
### Spacing
|
||||
|
||||
```scss
|
||||
// Spacing scale (index -> px):
|
||||
// --spacing-0=0 --spacing-1=2 --spacing-2=4 --spacing-3=6 --spacing-4=8
|
||||
// --spacing-5=10 --spacing-6=12 --spacing-7=14 --spacing-8=16 --spacing-10=20
|
||||
// --spacing-12=24 --spacing-16=32 --spacing-20=40 --spacing-24=48 --spacing-32=64
|
||||
// --spacing-40=80 --spacing-48=96 --spacing-56=112 --spacing-64=128
|
||||
// (index != px; --spacing-2 is 4px, not 2px)
|
||||
.container {
|
||||
padding: var(--spacing-4); // 8px
|
||||
gap: var(--spacing-6); // 12px
|
||||
margin-bottom: var(--spacing-8); // 16px
|
||||
}
|
||||
|
||||
// Also available: --padding-* and --margin-* (rem-based)
|
||||
// --padding-1 = 0.25rem, --padding-4 = 1rem, etc.
|
||||
```
|
||||
|
||||
### Typography
|
||||
|
||||
```scss
|
||||
// Font sizes (preferred)
|
||||
.title {
|
||||
font-size: var(--periscope-font-size-large); // 18px
|
||||
font-size: var(--periscope-font-size-medium); // 16px
|
||||
font-size: var(--periscope-font-size-base); // 13px
|
||||
font-size: var(--periscope-font-size-small); // 11px
|
||||
}
|
||||
|
||||
// Alternative scale (rem-based)
|
||||
.heading {
|
||||
font-size: var(--font-size-xl); // 1.25rem
|
||||
font-size: var(--font-size-lg); // 1.125rem
|
||||
font-size: var(--font-size-base); // 1rem
|
||||
font-size: var(--font-size-sm); // 0.875rem
|
||||
}
|
||||
|
||||
// Font weights
|
||||
.bold {
|
||||
font-weight: var(--font-weight-semibold); // 600
|
||||
font-weight: var(--font-weight-medium); // 500
|
||||
font-weight: var(--font-weight-normal); // 400
|
||||
}
|
||||
|
||||
// Line heights
|
||||
.text {
|
||||
line-height: var(--line-height-20); // 20px
|
||||
line-height: var(--line-height-24); // 24px
|
||||
}
|
||||
```
|
||||
|
||||
### Colors (Prefer Semantic Tokens)
|
||||
|
||||
Use L1/L2/L3 semantic tokens - they handle light/dark theme automatically.
|
||||
|
||||
```scss
|
||||
// BAD: Primitive tokens (fixed value across themes, won't swap on theme change)
|
||||
.card {
|
||||
background: var(--bg-ink-400);
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
// GOOD: L1/L2/L3 tokens (theme-aware - swap automatically light/dark)
|
||||
.card {
|
||||
background: var(--l1-background); // base layer
|
||||
color: var(--l1-foreground); // primary text
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--l2-background); // elevated surface
|
||||
color: var(--l2-foreground); // secondary text
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.nested {
|
||||
background: var(--l3-background); // nested/inset
|
||||
color: var(--l3-foreground); // tertiary text
|
||||
}
|
||||
|
||||
// Hover states
|
||||
.card:hover {
|
||||
background: var(--l1-background-hover);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
// Semantic action colors (also theme-aware)
|
||||
.primary {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: var(--danger-background);
|
||||
color: var(--danger-foreground);
|
||||
}
|
||||
|
||||
.success {
|
||||
background: var(--success-background);
|
||||
color: var(--success-foreground);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--warning-background);
|
||||
color: var(--warning-foreground);
|
||||
}
|
||||
|
||||
// Accent colors (for highlights, badges, etc.)
|
||||
.accent {
|
||||
background: var(--accent-primary); // robin blue
|
||||
background: var(--accent-forest); // green
|
||||
background: var(--accent-cherry); // red
|
||||
background: var(--accent-amber); // yellow
|
||||
}
|
||||
```
|
||||
|
||||
**Token hierarchy:**
|
||||
- Primitive tokens (`--bg-*`, `--text-*`, etc.) have fixed values across themes.
|
||||
- Semantic tokens (L1/L2/L3, `--primary-*`, `--danger-*`, etc.) automatically swap based on theme.
|
||||
- L1 = base/root layer
|
||||
- L2 = elevated surfaces (cards, panels)
|
||||
- L3 = nested/inset elements
|
||||
|
||||
## Overriding @signozhq/ui Components
|
||||
|
||||
Components expose CSS variables for customization.
|
||||
|
||||
You can ensure they exist by looking at ./node_modules/@signozhq/ui/dist.
|
||||
Never write a override without confirm it exists.
|
||||
|
||||
Override via:
|
||||
|
||||
### Method 1: CSS Variables (Preferred)
|
||||
|
||||
Each component exposes `--<component>-<property>` variables:
|
||||
|
||||
```scss
|
||||
// Override Button
|
||||
.customButton {
|
||||
--button-background: var(--success-background);
|
||||
--button-border-radius: var(--radius-2);
|
||||
--button-padding: var(--spacing-4) var(--spacing-8);
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Override Input
|
||||
.customInput {
|
||||
--input-height: 2.5rem;
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-padding: var(--spacing-2) var(--spacing-6);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// Override nested parts
|
||||
.customInput {
|
||||
--input-prefix-padding: 0 var(--spacing-4) 0 var(--spacing-6);
|
||||
--input-suffix-color: var(--accent-primary);
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Data Attributes
|
||||
|
||||
Components use data attributes for variants/states. Target them for state-specific overrides:
|
||||
|
||||
```scss
|
||||
// Target variant
|
||||
.wrapper :global([data-variant="outlined"]) {
|
||||
--button-border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
// Target size
|
||||
.wrapper :global([data-size="sm"]) {
|
||||
--button-font-size: var(--periscope-font-size-small);
|
||||
}
|
||||
|
||||
// Target color
|
||||
.wrapper :global([data-color="destructive"]) {
|
||||
--button-background: var(--danger-background);
|
||||
}
|
||||
|
||||
// Target state (Radix patterns)
|
||||
.popover :global([data-state="open"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip :global([data-side="top"]) {
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
```
|
||||
|
||||
### Common Component CSS Variables
|
||||
|
||||
**Button:**
|
||||
- `--button-background`, `--button-border-radius`, `--button-padding`
|
||||
- `--button-font-size`, `--button-height`, `--button-gap`
|
||||
- `--button-hover-background`, `--button-disabled-opacity`
|
||||
|
||||
**Input:**
|
||||
- `--input-height`, `--input-border-color`, `--input-background`
|
||||
- `--input-padding`, `--input-font-size`, `--input-placeholder-color`
|
||||
- `--input-focus-outline-color`, `--input-hover-border-color`
|
||||
- `--input-prefix-*`, `--input-suffix-*` for adornments
|
||||
|
||||
**General pattern:** `--<component>-<property>` or `--<component>-<state>-<property>`
|
||||
|
||||
## Good Patterns
|
||||
|
||||
### Structure
|
||||
|
||||
```scss
|
||||
// Flat, descriptive, component-scoped
|
||||
.alertHistory { }
|
||||
.alertHistoryHeader { }
|
||||
.alertHistoryContent { }
|
||||
.alertHistoryFooter { }
|
||||
|
||||
// State modifiers as separate classes
|
||||
.alertHistory { }
|
||||
.alertHistoryLoading { }
|
||||
.alertHistoryEmpty { }
|
||||
.alertHistoryError { }
|
||||
```
|
||||
|
||||
### Composition
|
||||
|
||||
```scss
|
||||
// GOOD: Composing styles
|
||||
.baseButton {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
composes: baseButton;
|
||||
background: var(--primary-background);
|
||||
}
|
||||
```
|
||||
|
||||
### Pseudo Elements
|
||||
|
||||
```scss
|
||||
.button {
|
||||
// States
|
||||
&:hover { opacity: 0.9; }
|
||||
&:focus { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
// Pseudo elements
|
||||
&::before { content: ''; }
|
||||
&::after { content: ''; }
|
||||
}
|
||||
```
|
||||
|
||||
### Media Queries
|
||||
|
||||
```scss
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Keyframes (Local Scoping)
|
||||
|
||||
Without `:local()`, keyframe names are global and can clash across modules:
|
||||
|
||||
```scss
|
||||
// BAD: Global keyframe - can conflict with other modules
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
// GOOD: Locally scoped keyframe
|
||||
:local(@keyframes fadeIn) {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
animation: fadeIn 200ms ease;
|
||||
}
|
||||
```
|
||||
|
||||
## JS Import Patterns
|
||||
|
||||
```tsx
|
||||
// GOOD
|
||||
import styles from './Component.module.scss';
|
||||
|
||||
<div className={styles.container}>
|
||||
<span className={styles.title}>Title</span>
|
||||
</div>
|
||||
|
||||
// GOOD: Conditional classes
|
||||
<div className={`${styles.button} ${isActive ? styles.buttonActive : ''}`}>
|
||||
|
||||
// GOOD: With clsx/classnames
|
||||
<div className={clsx(styles.button, { [styles.buttonActive]: isActive })}>
|
||||
|
||||
// BAD: Bracket access (may be undefined)
|
||||
<div className={styles['button-active']}> // undefined if CSS has .button-active
|
||||
|
||||
// BAD: String interpolation for class names
|
||||
<div className={`${styles.button}-active`}> // won't work
|
||||
|
||||
// BAD: Dynamic class lookup - can't be statically analyzed
|
||||
const cls = styles[`variant${props.type}`]; // Vite can't tree-shake or type-check
|
||||
|
||||
// GOOD: Explicit map for dynamic variants
|
||||
const variantMap = {
|
||||
primary: styles.variantPrimary,
|
||||
secondary: styles.variantSecondary,
|
||||
ghost: styles.variantGhost,
|
||||
};
|
||||
const cls = variantMap[props.type];
|
||||
```
|
||||
|
||||
## Lint Rules
|
||||
|
||||
### JS/TS (oxlint)
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|------|----------|---------|
|
||||
| `signoz/no-css-module-bracket-access` | warn | `styles['kebab-case']`, dynamic access |
|
||||
|
||||
### CSS/SCSS (stylelint)
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|------|----------|---------|
|
||||
| `local/no-deep-nesting` | warning | class nesting >3 levels (pseudo-classes/elements and parent-reference selectors `&.foo`, `&#bar` not counted; configurable via `maxDepth` secondary option) |
|
||||
| `local/no-id-selectors` | error | `#id` selectors |
|
||||
| `local/no-bare-element-selectors` | error | root-level `div`, `span` etc |
|
||||
| `local/prefer-css-variables` | warning | hardcoded colors |
|
||||
| `local/class-name-pattern` | warning | kebab-case, snake_case, PascalCase |
|
||||
|
||||
Run: `pnpm lint:styles` to check CSS modules.
|
||||
@@ -43,10 +43,10 @@
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.15",
|
||||
"@grafana/data": "^11.6.14",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@sentry/react": "8.41.0",
|
||||
"@sentry/vite-plugin": "2.22.6",
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
@@ -79,7 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"http-proxy-middleware": "4.0.0",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
@@ -192,9 +192,9 @@
|
||||
"lint-staged": "^17.0.4",
|
||||
"msw": "1.3.2",
|
||||
"orval": "8.9.1",
|
||||
"oxfmt": "0.54.0",
|
||||
"oxlint": "1.69.0",
|
||||
"oxlint-tsgolint": "0.23.0",
|
||||
"oxfmt": "0.47.0",
|
||||
"oxlint": "1.62.0",
|
||||
"oxlint-tsgolint": "0.22.1",
|
||||
"postcss": "8.5.14",
|
||||
"postcss-scss": "4.0.9",
|
||||
"react-resizable": "3.0.4",
|
||||
@@ -231,17 +231,16 @@
|
||||
"xml2js": "0.5.0",
|
||||
"phin": "^3.7.1",
|
||||
"body-parser": "1.20.3",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"http-proxy-middleware": "4.0.0",
|
||||
"cross-spawn": "7.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"serialize-javascript": "6.0.2",
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.6",
|
||||
"form-data": "4.0.4",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"tmp": "0.2.7",
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Rule: no-css-module-bracket-access
|
||||
*
|
||||
* Prevents bracket access on CSS module imports that may fail with camelCaseOnly config.
|
||||
*
|
||||
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case class names are
|
||||
* converted to camelCase and the original key is NOT exported.
|
||||
*
|
||||
* This rule catches patterns like:
|
||||
* styles['my-class'] // BAD - undefined if CSS has .my-class
|
||||
* styles['myClass'] // OK but prefer dot notation
|
||||
* styles.myClass // GOOD
|
||||
*
|
||||
* Catches:
|
||||
* - Bracket access with kebab-case strings (always fails)
|
||||
* - Bracket access with any string literal (warn - prefer dot notation)
|
||||
* - Dynamic bracket access (warn - risky)
|
||||
*/
|
||||
|
||||
const CSS_MODULE_IMPORT_NAMES = new Set([
|
||||
'styles',
|
||||
'classes',
|
||||
'css',
|
||||
'classNames',
|
||||
]);
|
||||
|
||||
function looksLikeCssModuleImport(name) {
|
||||
// Common patterns: styles, componentStyles, alertHistoryStyles
|
||||
return (
|
||||
CSS_MODULE_IMPORT_NAMES.has(name) ||
|
||||
name.endsWith('Styles') ||
|
||||
name.endsWith('Classes') ||
|
||||
name.endsWith('Css')
|
||||
);
|
||||
}
|
||||
|
||||
function isKebabCase(str) {
|
||||
return str.includes('-');
|
||||
}
|
||||
|
||||
function isSnakeCase(str) {
|
||||
return str.includes('_');
|
||||
}
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
// Only check bracket notation: styles['...']
|
||||
if (!node.computed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const object = node.object;
|
||||
if (object.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this looks like a CSS module import
|
||||
if (!looksLikeCssModuleImport(object.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const property = node.property;
|
||||
|
||||
// Dynamic access: styles[variable]
|
||||
if (property.type === 'Identifier') {
|
||||
context.report({
|
||||
node,
|
||||
message: `Dynamic CSS module access '${object.name}[${property.name}]' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify the key exists.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Template literal: styles[\`...\`]
|
||||
if (property.type === 'TemplateLiteral') {
|
||||
context.report({
|
||||
node,
|
||||
message: `Template literal CSS module access is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Prefer dot notation.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric / boolean / null literal: styles[0]. Not a class lookup; ignore.
|
||||
if (property.type === 'Literal' && typeof property.value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// String literal: styles['...']
|
||||
if (property.type === 'Literal' && typeof property.value === 'string') {
|
||||
const className = property.value;
|
||||
|
||||
// Kebab-case will definitely fail
|
||||
if (isKebabCase(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `CSS module class '${className}' uses kebab-case which won't work with 'camelCaseOnly' config. Use '${object.name}.${toCamelCase(className)}' instead.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Snake_case is suspicious
|
||||
if (isSnakeCase(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `CSS module class '${className}' uses snake_case which may not work as expected. Prefer camelCase: '${object.name}.${toCamelCase(className)}'.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid camelCase but using bracket notation - prefer dot
|
||||
if (/^[a-z][a-zA-Z0-9]*$/.test(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Prefer dot notation: '${object.name}.${className}' instead of '${object.name}['${className}']'.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Catch-all for other dynamic expressions:
|
||||
// styles['prefix' + suffix] (BinaryExpression)
|
||||
// styles[isActive && 'foo'] (LogicalExpression)
|
||||
// styles[isActive ? 'a' : 'b'] (ConditionalExpression)
|
||||
// styles[fn()] (CallExpression)
|
||||
context.report({
|
||||
node,
|
||||
message: `Dynamic CSS module access on '${object.name}' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify each key resolves to an exported camelCase class.`,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function toCamelCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((part, i) =>
|
||||
i === 0
|
||||
? part.toLowerCase()
|
||||
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs'
|
||||
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -24,6 +23,5 @@ export default {
|
||||
'no-raw-absolute-path': noRawAbsolutePath,
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
},
|
||||
};
|
||||
|
||||
781
frontend/pnpm-lock.yaml
generated
781
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -64,17 +64,10 @@ export const TraceDetail = Loadable(
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailOldRedirect = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetailOldRedirect" */ 'pages/TraceDetailOldRedirect/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailV3 = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailsV3/index'
|
||||
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -330,10 +323,3 @@ export const AIAssistantPage = Loadable(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityAttributeMappingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Attribute Mapping Page" */ 'pages/LLMObservabilityAttributeMapping'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityAttributeMappingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -48,7 +47,7 @@ import {
|
||||
SomethingWentWrong,
|
||||
StatusPage,
|
||||
SupportPage,
|
||||
TraceDetailOldRedirect,
|
||||
TraceDetail,
|
||||
TraceDetailV3,
|
||||
TraceFilter,
|
||||
TracesExplorer,
|
||||
@@ -140,11 +139,13 @@ const routes: AppRoutes[] = [
|
||||
exact: true,
|
||||
key: 'LOGS_SAVE_VIEWS',
|
||||
},
|
||||
// Legacy /trace-old/:id redirects to the current /trace/:id view.
|
||||
// V3 trace details is gated until release: /trace serves V2 (public),
|
||||
// /trace-old serves V3 (URL-only access). Flip the two `component`
|
||||
// values back to release V3.
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
component: TraceDetailOldRedirect,
|
||||
component: TraceDetail,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL_OLD',
|
||||
},
|
||||
@@ -506,13 +507,6 @@ const routes: AppRoutes[] = [
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
exact: true,
|
||||
component: LLMObservabilityAttributeMappingPage,
|
||||
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -55,9 +55,6 @@ import type {
|
||||
ThreadDetailResponseDTO,
|
||||
ThreadListResponseDTO,
|
||||
ThreadSummaryDTO,
|
||||
ChipDTO,
|
||||
ChipsResponseDTO,
|
||||
PageTypeDTO,
|
||||
ToolCallEventDTO,
|
||||
ToolResultEventDTO,
|
||||
} from './sigNozAIAssistantAPI.schemas';
|
||||
@@ -544,19 +541,3 @@ export async function submitFeedback(
|
||||
comment: comment ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contextual empty-state chips
|
||||
// GET /api/v1/assistant/empty-state/chips?page_type=… → { chips }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getEmptyStateChips(
|
||||
pageType: PageTypeDTO,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChipDTO[]> {
|
||||
const response = await AIAssistantInstance.get<ChipsResponseDTO>(
|
||||
'/empty-state/chips',
|
||||
{ params: { page_type: pageType }, signal },
|
||||
);
|
||||
return response.data.chips;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
CancelApiV1AssistantCancelPostHeaders,
|
||||
CancelRequestDTO,
|
||||
CancelResponseDTO,
|
||||
ChipsResponseDTO,
|
||||
ClarifyApiV1AssistantClarifyPostHeaders,
|
||||
ClarifyRequestDTO,
|
||||
ClarifyResponseDTO,
|
||||
@@ -40,11 +39,8 @@ import type {
|
||||
ErrorResponseDTO,
|
||||
FeedbackRequestDTO,
|
||||
FeedbackResponseDTO,
|
||||
GetChipsApiV1AssistantEmptyStateChipsGetHeaders,
|
||||
GetChipsApiV1AssistantEmptyStateChipsGetParams,
|
||||
GetThreadApiV1AssistantThreadsThreadIdGetHeaders,
|
||||
GetThreadApiV1AssistantThreadsThreadIdGetPathParameters,
|
||||
GetUsageApiV1AssistantUsageGetHeaders,
|
||||
HTTPValidationErrorDTO,
|
||||
HealthResponseDTO,
|
||||
ListThreadsApiV1AssistantThreadsGetHeaders,
|
||||
@@ -69,89 +65,93 @@ import type {
|
||||
UpdateThreadApiV1AssistantThreadsThreadIdPatchHeaders,
|
||||
UpdateThreadApiV1AssistantThreadsThreadIdPatchPathParameters,
|
||||
UpdateThreadRequestDTO,
|
||||
UsageResponseDTO,
|
||||
} from './sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../generatedAPIInstance';
|
||||
import {
|
||||
GeneratedAPIInstance,
|
||||
getGeneratedAPIQueryKeyHeaders,
|
||||
} from '../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* @summary Healthz
|
||||
* @summary Health
|
||||
*/
|
||||
export const healthzHealthzGet = (signal?: AbortSignal) => {
|
||||
export const healthHealthGet = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<HealthResponseDTO>({
|
||||
url: `/healthz`,
|
||||
url: `/health`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getHealthzHealthzGetQueryKey = () => {
|
||||
return [`/healthz`] as const;
|
||||
export const getHealthHealthGetQueryKey = () => {
|
||||
return [`/health`] as const;
|
||||
};
|
||||
|
||||
export const getHealthzHealthzGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof healthzHealthzGet>>,
|
||||
export const getHealthHealthGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof healthHealthGet>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof healthzHealthzGet>>,
|
||||
Awaited<ReturnType<typeof healthHealthGet>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getHealthzHealthzGetQueryKey();
|
||||
const queryKey = queryOptions?.queryKey ?? getHealthHealthGetQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof healthzHealthzGet>>
|
||||
> = ({ signal }) => healthzHealthzGet(signal);
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof healthHealthGet>>> = ({
|
||||
signal,
|
||||
}) => healthHealthGet(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof healthzHealthzGet>>,
|
||||
Awaited<ReturnType<typeof healthHealthGet>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type HealthzHealthzGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof healthzHealthzGet>>
|
||||
export type HealthHealthGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof healthHealthGet>>
|
||||
>;
|
||||
export type HealthzHealthzGetQueryError = ErrorType<unknown>;
|
||||
export type HealthHealthGetQueryError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Healthz
|
||||
* @summary Health
|
||||
*/
|
||||
|
||||
export function useHealthzHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof healthzHealthzGet>>,
|
||||
export function useHealthHealthGet<
|
||||
TData = Awaited<ReturnType<typeof healthHealthGet>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof healthzHealthzGet>>,
|
||||
Awaited<ReturnType<typeof healthHealthGet>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getHealthzHealthzGetQueryOptions(options);
|
||||
const queryOptions = getHealthHealthGetQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Healthz
|
||||
* @summary Health
|
||||
*/
|
||||
export const invalidateHealthzHealthzGet = async (
|
||||
export const invalidateHealthHealthGet = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getHealthzHealthzGetQueryKey() },
|
||||
{ queryKey: getHealthHealthGetQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -159,82 +159,84 @@ export const invalidateHealthzHealthzGet = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Readyz
|
||||
* @summary Ready
|
||||
*/
|
||||
export const readyzReadyzGet = (signal?: AbortSignal) => {
|
||||
export const readyReadyGet = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ReadinessResponseDTO>({
|
||||
url: `/readyz`,
|
||||
url: `/ready`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getReadyzReadyzGetQueryKey = () => {
|
||||
return [`/readyz`] as const;
|
||||
export const getReadyReadyGetQueryKey = () => {
|
||||
return [`/ready`] as const;
|
||||
};
|
||||
|
||||
export const getReadyzReadyzGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
|
||||
export const getReadyReadyGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof readyReadyGet>>,
|
||||
TError = ErrorType<ReadinessResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof readyzReadyzGet>>,
|
||||
Awaited<ReturnType<typeof readyReadyGet>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getReadyzReadyzGetQueryKey();
|
||||
const queryKey = queryOptions?.queryKey ?? getReadyReadyGetQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof readyzReadyzGet>>> = ({
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof readyReadyGet>>> = ({
|
||||
signal,
|
||||
}) => readyzReadyzGet(signal);
|
||||
}) => readyReadyGet(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof readyzReadyzGet>>,
|
||||
Awaited<ReturnType<typeof readyReadyGet>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ReadyzReadyzGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof readyzReadyzGet>>
|
||||
export type ReadyReadyGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof readyReadyGet>>
|
||||
>;
|
||||
export type ReadyzReadyzGetQueryError = ErrorType<ReadinessResponseDTO>;
|
||||
export type ReadyReadyGetQueryError = ErrorType<ReadinessResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Readyz
|
||||
* @summary Ready
|
||||
*/
|
||||
|
||||
export function useReadyzReadyzGet<
|
||||
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
|
||||
export function useReadyReadyGet<
|
||||
TData = Awaited<ReturnType<typeof readyReadyGet>>,
|
||||
TError = ErrorType<ReadinessResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof readyzReadyzGet>>,
|
||||
Awaited<ReturnType<typeof readyReadyGet>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getReadyzReadyzGetQueryOptions(options);
|
||||
const queryOptions = getReadyReadyGetQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Readyz
|
||||
* @summary Ready
|
||||
*/
|
||||
export const invalidateReadyzReadyzGet = async (
|
||||
export const invalidateReadyReadyGet = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getReadyzReadyzGetQueryKey() },
|
||||
{ queryKey: getReadyReadyGetQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -245,7 +247,7 @@ export const invalidateReadyzReadyzGet = async (
|
||||
* @summary Create a new thread
|
||||
*/
|
||||
export const createThreadApiV1AssistantThreadsPost = (
|
||||
createThreadApiV1AssistantThreadsPostBody?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>,
|
||||
createThreadApiV1AssistantThreadsPostBody: BodyType<CreateThreadApiV1AssistantThreadsPostBody>,
|
||||
headers?: CreateThreadApiV1AssistantThreadsPostHeaders,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
@@ -266,7 +268,7 @@ export const getCreateThreadApiV1AssistantThreadsPostMutationOptions = <
|
||||
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
|
||||
},
|
||||
TContext
|
||||
@@ -275,7 +277,7 @@ export const getCreateThreadApiV1AssistantThreadsPostMutationOptions = <
|
||||
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
|
||||
},
|
||||
TContext
|
||||
@@ -292,7 +294,7 @@ export const getCreateThreadApiV1AssistantThreadsPostMutationOptions = <
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
|
||||
{
|
||||
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
|
||||
}
|
||||
> = (props) => {
|
||||
@@ -308,8 +310,7 @@ export type CreateThreadApiV1AssistantThreadsPostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>
|
||||
>;
|
||||
export type CreateThreadApiV1AssistantThreadsPostMutationBody =
|
||||
| BodyType<CreateThreadApiV1AssistantThreadsPostBody>
|
||||
| undefined;
|
||||
BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
export type CreateThreadApiV1AssistantThreadsPostMutationError = ErrorType<
|
||||
ErrorResponseDTO | HTTPValidationErrorDTO
|
||||
>;
|
||||
@@ -325,7 +326,7 @@ export const useCreateThreadApiV1AssistantThreadsPost = <
|
||||
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
|
||||
},
|
||||
TContext
|
||||
@@ -334,14 +335,15 @@ export const useCreateThreadApiV1AssistantThreadsPost = <
|
||||
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
|
||||
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getCreateThreadApiV1AssistantThreadsPostMutationOptions(options),
|
||||
);
|
||||
const mutationOptions =
|
||||
getCreateThreadApiV1AssistantThreadsPostMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Cursor-based pagination, sorted by updatedAt desc. Use `archived=true|false|all` to filter.
|
||||
@@ -363,8 +365,13 @@ export const listThreadsApiV1AssistantThreadsGet = (
|
||||
|
||||
export const getListThreadsApiV1AssistantThreadsGetQueryKey = (
|
||||
params?: ListThreadsApiV1AssistantThreadsGetParams,
|
||||
headers?: ListThreadsApiV1AssistantThreadsGetHeaders,
|
||||
) => {
|
||||
return [`/api/v1/assistant/threads`, ...(params ? [params] : [])] as const;
|
||||
return [
|
||||
`/api/v1/assistant/threads`,
|
||||
...(params ? [params] : []),
|
||||
...getGeneratedAPIQueryKeyHeaders(headers),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListThreadsApiV1AssistantThreadsGetQueryOptions = <
|
||||
@@ -385,7 +392,7 @@ export const getListThreadsApiV1AssistantThreadsGetQueryOptions = <
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getListThreadsApiV1AssistantThreadsGetQueryKey(params);
|
||||
getListThreadsApiV1AssistantThreadsGetQueryKey(params, headers);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listThreadsApiV1AssistantThreadsGet>>
|
||||
@@ -434,7 +441,9 @@ export function useListThreadsApiV1AssistantThreadsGet<
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,7 +456,7 @@ export const invalidateListThreadsApiV1AssistantThreadsGet = async (
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListThreadsApiV1AssistantThreadsGetQueryKey(params) },
|
||||
{ queryKey: getListThreadsApiV1AssistantThreadsGetQueryKey(params, headers) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -471,10 +480,14 @@ export const getThreadApiV1AssistantThreadsThreadIdGet = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey = ({
|
||||
threadId,
|
||||
}: GetThreadApiV1AssistantThreadsThreadIdGetPathParameters) => {
|
||||
return [`/api/v1/assistant/threads/${threadId}`] as const;
|
||||
export const getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey = (
|
||||
{ threadId }: GetThreadApiV1AssistantThreadsThreadIdGetPathParameters,
|
||||
headers?: GetThreadApiV1AssistantThreadsThreadIdGetHeaders,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/assistant/threads/${threadId}`,
|
||||
...getGeneratedAPIQueryKeyHeaders(headers),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetThreadApiV1AssistantThreadsThreadIdGetQueryOptions = <
|
||||
@@ -495,7 +508,7 @@ export const getGetThreadApiV1AssistantThreadsThreadIdGetQueryOptions = <
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey({ threadId });
|
||||
getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey({ threadId }, headers);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getThreadApiV1AssistantThreadsThreadIdGet>>
|
||||
@@ -549,7 +562,9 @@ export function useGetThreadApiV1AssistantThreadsThreadIdGet<
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,7 +578,10 @@ export const invalidateGetThreadApiV1AssistantThreadsThreadIdGet = async (
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{
|
||||
queryKey: getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey({ threadId }),
|
||||
queryKey: getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey(
|
||||
{ threadId },
|
||||
headers,
|
||||
),
|
||||
},
|
||||
options,
|
||||
);
|
||||
@@ -578,14 +596,12 @@ export const updateThreadApiV1AssistantThreadsThreadIdPatch = (
|
||||
{ threadId }: UpdateThreadApiV1AssistantThreadsThreadIdPatchPathParameters,
|
||||
updateThreadRequestDTO: BodyType<UpdateThreadRequestDTO>,
|
||||
headers?: UpdateThreadApiV1AssistantThreadsThreadIdPatchHeaders,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ThreadSummaryDTO>({
|
||||
url: `/api/v1/assistant/threads/${threadId}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
data: updateThreadRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -679,9 +695,10 @@ export const useUpdateThreadApiV1AssistantThreadsThreadIdPatch = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getUpdateThreadApiV1AssistantThreadsThreadIdPatchMutationOptions(options),
|
||||
);
|
||||
const mutationOptions =
|
||||
getUpdateThreadApiV1AssistantThreadsThreadIdPatchMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Persists the user message, creates an execution (state: queued), kicks off the agent loop asynchronously, and returns immediately. Open `GET /executions/{executionId}/events` for the SSE stream.
|
||||
@@ -808,11 +825,12 @@ export const useCreateMessageApiV1AssistantThreadsThreadIdMessagesPost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
const mutationOptions =
|
||||
getCreateMessageApiV1AssistantThreadsThreadIdMessagesPostMutationOptions(
|
||||
options,
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Clean-slate regeneration. Starts a fresh execution with conversation history up to (excluding) the original assistant response.
|
||||
@@ -943,11 +961,12 @@ export const useRegenerateMessageApiV1AssistantMessagesMessageIdRegeneratePost =
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
const mutationOptions =
|
||||
getRegenerateMessageApiV1AssistantMessagesMessageIdRegeneratePostMutationOptions(
|
||||
options,
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Triggers a replay execution that runs the stored tool call with exact params. Returns a new executionId — open SSE for that execution.
|
||||
@@ -1047,9 +1066,10 @@ export const useApproveApiV1AssistantApprovePost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getApproveApiV1AssistantApprovePostMutationOptions(options),
|
||||
);
|
||||
const mutationOptions =
|
||||
getApproveApiV1AssistantApprovePostMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Marks the approval as rejected. The execution completes with no tool execution.
|
||||
@@ -1149,7 +1169,10 @@ export const useRejectApiV1AssistantRejectPost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getRejectApiV1AssistantRejectPostMutationOptions(options));
|
||||
const mutationOptions =
|
||||
getRejectApiV1AssistantRejectPostMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Provides structured answers to a clarification request. Persists the answers as a user transcript message, emits `user_message` as the first replayable event on the new execution stream, and resumes the agent with the answers as tool results.
|
||||
@@ -1249,9 +1272,10 @@ export const useClarifyApiV1AssistantClarifyPost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getClarifyApiV1AssistantClarifyPostMutationOptions(options),
|
||||
);
|
||||
const mutationOptions =
|
||||
getClarifyApiV1AssistantClarifyPostMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Cooperative cancel. The agent loop finishes its current step, emits a truncated message if streaming, and transitions to canceled.
|
||||
@@ -1351,7 +1375,10 @@ export const useCancelApiV1AssistantCancelPost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCancelApiV1AssistantCancelPostMutationOptions(options));
|
||||
const mutationOptions =
|
||||
getCancelApiV1AssistantCancelPostMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Deletes the resource that was created by the assistant.
|
||||
@@ -1450,7 +1477,9 @@ export const useUndoApiV1AssistantUndoPost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUndoApiV1AssistantUndoPostMutationOptions(options));
|
||||
const mutationOptions = getUndoApiV1AssistantUndoPostMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Rolls back the resource to its pre-change snapshot.
|
||||
@@ -1550,7 +1579,10 @@ export const useRevertApiV1AssistantRevertPost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getRevertApiV1AssistantRevertPostMutationOptions(options));
|
||||
const mutationOptions =
|
||||
getRevertApiV1AssistantRevertPostMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Recreates the resource from its pre-delete snapshot.
|
||||
@@ -1650,9 +1682,10 @@ export const useRestoreApiV1AssistantRestorePost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getRestoreApiV1AssistantRestorePostMutationOptions(options),
|
||||
);
|
||||
const mutationOptions =
|
||||
getRestoreApiV1AssistantRestorePostMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* @summary Submit feedback on an assistant message
|
||||
@@ -1778,221 +1811,10 @@ export const useSubmitFeedbackApiV1AssistantMessagesMessageIdFeedbackPost = <
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
const mutationOptions =
|
||||
getSubmitFeedbackApiV1AssistantMessagesMessageIdFeedbackPostMutationOptions(
|
||||
options,
|
||||
),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @summary Current rate-limit usage for the authenticated user + org
|
||||
*/
|
||||
export const getUsageApiV1AssistantUsageGet = (
|
||||
headers?: GetUsageApiV1AssistantUsageGetHeaders,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UsageResponseDTO>({
|
||||
url: `/api/v1/assistant/usage`,
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetUsageApiV1AssistantUsageGetQueryKey = () => {
|
||||
return [`/api/v1/assistant/usage`] as const;
|
||||
};
|
||||
|
||||
export const getGetUsageApiV1AssistantUsageGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getUsageApiV1AssistantUsageGet>>,
|
||||
TError = ErrorType<ErrorResponseDTO | HTTPValidationErrorDTO>,
|
||||
>(
|
||||
headers?: GetUsageApiV1AssistantUsageGetHeaders,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUsageApiV1AssistantUsageGet>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetUsageApiV1AssistantUsageGetQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getUsageApiV1AssistantUsageGet>>
|
||||
> = ({ signal }) => getUsageApiV1AssistantUsageGet(headers, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUsageApiV1AssistantUsageGet>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetUsageApiV1AssistantUsageGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getUsageApiV1AssistantUsageGet>>
|
||||
>;
|
||||
export type GetUsageApiV1AssistantUsageGetQueryError = ErrorType<
|
||||
ErrorResponseDTO | HTTPValidationErrorDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Current rate-limit usage for the authenticated user + org
|
||||
*/
|
||||
|
||||
export function useGetUsageApiV1AssistantUsageGet<
|
||||
TData = Awaited<ReturnType<typeof getUsageApiV1AssistantUsageGet>>,
|
||||
TError = ErrorType<ErrorResponseDTO | HTTPValidationErrorDTO>,
|
||||
>(
|
||||
headers?: GetUsageApiV1AssistantUsageGetHeaders,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUsageApiV1AssistantUsageGet>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetUsageApiV1AssistantUsageGetQueryOptions(
|
||||
headers,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Current rate-limit usage for the authenticated user + org
|
||||
*/
|
||||
export const invalidateGetUsageApiV1AssistantUsageGet = async (
|
||||
queryClient: QueryClient,
|
||||
headers?: GetUsageApiV1AssistantUsageGetHeaders,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetUsageApiV1AssistantUsageGetQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Contextual empty-state chips
|
||||
*/
|
||||
export const getChipsApiV1AssistantEmptyStateChipsGet = (
|
||||
params: GetChipsApiV1AssistantEmptyStateChipsGetParams,
|
||||
headers?: GetChipsApiV1AssistantEmptyStateChipsGetHeaders,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ChipsResponseDTO>({
|
||||
url: `/api/v1/assistant/empty-state/chips`,
|
||||
method: 'GET',
|
||||
headers,
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetChipsApiV1AssistantEmptyStateChipsGetQueryKey = (
|
||||
params?: GetChipsApiV1AssistantEmptyStateChipsGetParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/assistant/empty-state/chips`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetChipsApiV1AssistantEmptyStateChipsGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getChipsApiV1AssistantEmptyStateChipsGet>>,
|
||||
TError = ErrorType<ErrorResponseDTO | HTTPValidationErrorDTO>,
|
||||
>(
|
||||
params: GetChipsApiV1AssistantEmptyStateChipsGetParams,
|
||||
headers?: GetChipsApiV1AssistantEmptyStateChipsGetHeaders,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getChipsApiV1AssistantEmptyStateChipsGet>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetChipsApiV1AssistantEmptyStateChipsGetQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getChipsApiV1AssistantEmptyStateChipsGet>>
|
||||
> = ({ signal }) =>
|
||||
getChipsApiV1AssistantEmptyStateChipsGet(params, headers, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getChipsApiV1AssistantEmptyStateChipsGet>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetChipsApiV1AssistantEmptyStateChipsGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getChipsApiV1AssistantEmptyStateChipsGet>>
|
||||
>;
|
||||
export type GetChipsApiV1AssistantEmptyStateChipsGetQueryError = ErrorType<
|
||||
ErrorResponseDTO | HTTPValidationErrorDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Contextual empty-state chips
|
||||
*/
|
||||
|
||||
export function useGetChipsApiV1AssistantEmptyStateChipsGet<
|
||||
TData = Awaited<ReturnType<typeof getChipsApiV1AssistantEmptyStateChipsGet>>,
|
||||
TError = ErrorType<ErrorResponseDTO | HTTPValidationErrorDTO>,
|
||||
>(
|
||||
params: GetChipsApiV1AssistantEmptyStateChipsGetParams,
|
||||
headers?: GetChipsApiV1AssistantEmptyStateChipsGetHeaders,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getChipsApiV1AssistantEmptyStateChipsGet>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetChipsApiV1AssistantEmptyStateChipsGetQueryOptions(
|
||||
params,
|
||||
headers,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Contextual empty-state chips
|
||||
*/
|
||||
export const invalidateGetChipsApiV1AssistantEmptyStateChipsGet = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetChipsApiV1AssistantEmptyStateChipsGetParams,
|
||||
headers?: GetChipsApiV1AssistantEmptyStateChipsGetHeaders,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetChipsApiV1AssistantEmptyStateChipsGetQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
|
||||
@@ -159,25 +159,6 @@ export interface CancelResponseDTO {
|
||||
state: ExecutionStateDTO;
|
||||
}
|
||||
|
||||
export interface ChipDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Stable chip id. Rule-engine chips use intent ids.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ChipsResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
chips: ChipDTO[];
|
||||
}
|
||||
|
||||
export type ClarificationFieldDTOOptions = string[] | null;
|
||||
|
||||
export type ClarificationFieldDTODefault = string | string[] | null;
|
||||
@@ -405,74 +386,15 @@ export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
|
||||
|
||||
export type ErrorBodyDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Machine-readable error codes carried on ``ErrorBody.code``.
|
||||
|
||||
**Extensible set.** This enum is the single source of truth for every code
|
||||
the backend can emit, on both the REST envelope and the SSE ``ErrorEvent``.
|
||||
It is published in the OpenAPI schema (and therefore the generated TS
|
||||
client) so clients get autocomplete and a typed discriminant. The set is
|
||||
expected to *grow*: adding a member is a backward-compatible change (the
|
||||
wire is still a plain JSON string), so clients MUST treat unknown codes
|
||||
gracefully — branch on the codes they handle and keep a default fallback,
|
||||
never hard-reject an unrecognized value. Re-exported from ``app.errors``
|
||||
for convenience; ``AssistantError(code=...)`` requires a member of this
|
||||
enum so a typo can never reach a client.
|
||||
*/
|
||||
export enum ErrorCodeDTO {
|
||||
missing_signoz_url = 'missing_signoz_url',
|
||||
invalid_signoz_url = 'invalid_signoz_url',
|
||||
invalid_content_length = 'invalid_content_length',
|
||||
invalid_fork_target = 'invalid_fork_target',
|
||||
rate_limit_override_exceeds_ceiling = 'rate_limit_override_exceeds_ceiling',
|
||||
thread_message_limit = 'thread_message_limit',
|
||||
validation_error = 'validation_error',
|
||||
missing_token = 'missing_token',
|
||||
invalid_token = 'invalid_token',
|
||||
permission_denied = 'permission_denied',
|
||||
user_disabled = 'user_disabled',
|
||||
org_disabled = 'org_disabled',
|
||||
thread_not_found = 'thread_not_found',
|
||||
message_not_found = 'message_not_found',
|
||||
execution_not_found = 'execution_not_found',
|
||||
approval_not_found = 'approval_not_found',
|
||||
clarification_not_found = 'clarification_not_found',
|
||||
action_metadata_not_found = 'action_metadata_not_found',
|
||||
user_not_found = 'user_not_found',
|
||||
region_not_configured = 'region_not_configured',
|
||||
thread_busy = 'thread_busy',
|
||||
thread_has_active_execution = 'thread_has_active_execution',
|
||||
no_active_execution = 'no_active_execution',
|
||||
approval_superseded = 'approval_superseded',
|
||||
clarification_superseded = 'clarification_superseded',
|
||||
undo_conflict = 'undo_conflict',
|
||||
revert_conflict = 'revert_conflict',
|
||||
revert_expired = 'revert_expired',
|
||||
restore_expired = 'restore_expired',
|
||||
connection_limit_exceeded = 'connection_limit_exceeded',
|
||||
hourly_message_limit = 'hourly_message_limit',
|
||||
daily_message_limit = 'daily_message_limit',
|
||||
daily_token_limit = 'daily_token_limit',
|
||||
daily_cost_limit = 'daily_cost_limit',
|
||||
upstream_auth_error = 'upstream_auth_error',
|
||||
max_turns_exceeded = 'max_turns_exceeded',
|
||||
budget_exceeded = 'budget_exceeded',
|
||||
agent_execution_error = 'agent_execution_error',
|
||||
cli_not_found = 'cli_not_found',
|
||||
cli_connection_error = 'cli_connection_error',
|
||||
cli_process_error = 'cli_process_error',
|
||||
sandbox_unavailable = 'sandbox_unavailable',
|
||||
mcp_unavailable = 'mcp_unavailable',
|
||||
internal_error = 'internal_error',
|
||||
region_unreachable = 'region_unreachable',
|
||||
heartbeat_expired = 'heartbeat_expired',
|
||||
replay_unavailable = 'replay_unavailable',
|
||||
}
|
||||
/**
|
||||
* Inner error object — matches Go ErrorsJSON.
|
||||
*/
|
||||
export interface ErrorBodyDTO {
|
||||
code: ErrorCodeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @pattern ^[a-z_]+$
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -568,23 +490,6 @@ export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
|
||||
|
||||
export type MessageActionDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Explorer namespace a saved view belongs to — its ``sourcePage``.
|
||||
|
||||
Mirrors the SigNoz product's saved-view ``sourcePage`` values so the
|
||||
frontend can route an ``open_resource`` action for a view to the right
|
||||
Explorer via its existing ``SOURCEPAGE_VS_ROUTES`` map. ``meter`` is the
|
||||
Cost Meter Explorer and is intentionally distinct from ``metrics`` (the
|
||||
product persists and lists meter views under ``sourcePage="meter"``).
|
||||
*/
|
||||
export enum SavedViewEntityDTO {
|
||||
logs = 'logs',
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
}
|
||||
export type MessageActionDTOEntity = SavedViewEntityDTO | null;
|
||||
|
||||
export enum MessageActionKindDTO {
|
||||
undo = 'undo',
|
||||
revert = 'revert',
|
||||
@@ -595,7 +500,7 @@ export enum MessageActionKindDTO {
|
||||
apply_filter = 'apply_filter',
|
||||
}
|
||||
/**
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url. open_resource for a saved view also carries entity (logs/traces/metrics/meter) so the frontend routes to the correct Explorer.
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
|
||||
*/
|
||||
export interface MessageActionDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
@@ -612,7 +517,6 @@ export interface MessageActionDTO {
|
||||
signal?: MessageActionDTOSignal;
|
||||
query?: MessageActionDTOQuery;
|
||||
url?: MessageActionDTOUrl;
|
||||
entity?: MessageActionDTOEntity;
|
||||
}
|
||||
|
||||
export enum MessageContentTypeDTO {
|
||||
@@ -686,26 +590,6 @@ export interface MessageSummaryDTO {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export enum PageTypeDTO {
|
||||
homepage = 'homepage',
|
||||
dashboard_detail = 'dashboard_detail',
|
||||
dashboard_list = 'dashboard_list',
|
||||
panel_edit = 'panel_edit',
|
||||
panel_fullscreen = 'panel_fullscreen',
|
||||
logs_explorer = 'logs_explorer',
|
||||
log_detail = 'log_detail',
|
||||
traces_explorer = 'traces_explorer',
|
||||
trace_detail = 'trace_detail',
|
||||
metrics_explorer = 'metrics_explorer',
|
||||
service_detail = 'service_detail',
|
||||
services_list = 'services_list',
|
||||
alert_edit = 'alert_edit',
|
||||
alert_list = 'alert_list',
|
||||
alert_new = 'alert_new',
|
||||
alerts_triggered = 'alerts_triggered',
|
||||
infra_entity_detail = 'infra_entity_detail',
|
||||
other = 'other',
|
||||
}
|
||||
export enum ReadinessChecksDTODatabase {
|
||||
ok = 'ok',
|
||||
failed = 'failed',
|
||||
@@ -1106,10 +990,8 @@ export type MessageActionEventDTOQuery = MessageActionEventDTOQueryAnyOf | null;
|
||||
|
||||
export type MessageActionEventDTOUrl = string | null;
|
||||
|
||||
export type MessageActionEventDTOEntity = SavedViewEntityDTO | null;
|
||||
|
||||
/**
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url. open_resource for a saved view also carries entity (logs/traces/metrics/meter) so the frontend routes to the correct Explorer.
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
|
||||
*/
|
||||
export interface MessageActionEventDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
@@ -1126,7 +1008,6 @@ export interface MessageActionEventDTO {
|
||||
signal?: MessageActionEventDTOSignal;
|
||||
query?: MessageActionEventDTOQuery;
|
||||
url?: MessageActionEventDTOUrl;
|
||||
entity?: MessageActionEventDTOEntity;
|
||||
}
|
||||
|
||||
export type MessageEventDTOActions = MessageActionEventDTO[] | null;
|
||||
@@ -1504,21 +1385,3 @@ export type GetUsageApiV1AssistantUsageGetHeaders = {
|
||||
*/
|
||||
'X-SigNoz-URL'?: string | null;
|
||||
};
|
||||
|
||||
export type GetChipsApiV1AssistantEmptyStateChipsGetParams = {
|
||||
/**
|
||||
* @description Frontend-declared page type. Typed as an enum, but unrecognized values are coerced to 'other' (not rejected) so a new frontend page type works before the backend knows it. The page type alone identifies the focused entity (e.g. trace_detail) for the 'Explain this …' chip; the agent reads the concrete entity from page context once a chip is clicked, so no separate entity id is needed.
|
||||
*/
|
||||
page_type: PageTypeDTO;
|
||||
};
|
||||
|
||||
export type GetChipsApiV1AssistantEmptyStateChipsGetHeaders = {
|
||||
/**
|
||||
* @description SigNoz auth token (Bearer or raw JWT)
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* @description SigNoz instance base URL for multi-tenant deployments. Falls back to SIGNOZ_API_URL env var when omitted.
|
||||
*/
|
||||
'X-SigNoz-URL'?: string | null;
|
||||
};
|
||||
|
||||
@@ -18,20 +18,15 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CloneDashboardV2201,
|
||||
CloneDashboardV2PathParameters,
|
||||
CreateDashboardV2201,
|
||||
CreateDashboardView201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
DashboardtypesPatchableDashboardV2DTO,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostableDashboardViewDTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatableDashboardV2DTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
DeleteDashboardV2PathParameters,
|
||||
DeleteDashboardViewPathParameters,
|
||||
DeletePublicDashboardPathParameters,
|
||||
GetDashboardV2200,
|
||||
GetDashboardV2PathParameters,
|
||||
@@ -41,7 +36,6 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
ListDashboardViews200,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListDashboardsV2200,
|
||||
@@ -55,8 +49,6 @@ import type {
|
||||
UnpinDashboardV2PathParameters,
|
||||
UpdateDashboardV2200,
|
||||
UpdateDashboardV2PathParameters,
|
||||
UpdateDashboardView200,
|
||||
UpdateDashboardViewPathParameters,
|
||||
UpdatePublicDashboardPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
@@ -656,354 +648,6 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
|
||||
* @summary List dashboard saved views
|
||||
*/
|
||||
export const listDashboardViews = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListDashboardViews200>({
|
||||
url: `/api/v2/dashboard_views`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListDashboardViewsQueryKey = () => {
|
||||
return [`/api/v2/dashboard_views`] as const;
|
||||
};
|
||||
|
||||
export const getListDashboardViewsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListDashboardViewsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>
|
||||
> = ({ signal }) => listDashboardViews(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListDashboardViewsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>
|
||||
>;
|
||||
export type ListDashboardViewsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List dashboard saved views
|
||||
*/
|
||||
|
||||
export function useListDashboardViews<
|
||||
TData = Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListDashboardViewsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List dashboard saved views
|
||||
*/
|
||||
export const invalidateListDashboardViews = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListDashboardViewsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.
|
||||
* @summary Create dashboard saved view
|
||||
*/
|
||||
export const createDashboardView = (
|
||||
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateDashboardView201>({
|
||||
url: `/api/v2/dashboard_views`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateDashboardViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createDashboardView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createDashboardView(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateDashboardViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createDashboardView>>
|
||||
>;
|
||||
export type CreateDashboardViewMutationBody =
|
||||
| BodyType<DashboardtypesPostableDashboardViewDTO>
|
||||
| undefined;
|
||||
export type CreateDashboardViewMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create dashboard saved view
|
||||
*/
|
||||
export const useCreateDashboardView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateDashboardViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
|
||||
* @summary Delete dashboard saved view
|
||||
*/
|
||||
export const deleteDashboardView = (
|
||||
{ id }: DeleteDashboardViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/dashboard_views/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteDashboardViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDashboardViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDashboardViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteDashboardView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
{ pathParams: DeleteDashboardViewPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteDashboardView(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteDashboardViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>
|
||||
>;
|
||||
|
||||
export type DeleteDashboardViewMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete dashboard saved view
|
||||
*/
|
||||
export const useDeleteDashboardView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDashboardViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDashboardViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteDashboardViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
* @summary Update dashboard saved view
|
||||
*/
|
||||
export const updateDashboardView = (
|
||||
{ id }: UpdateDashboardViewPathParameters,
|
||||
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateDashboardView200>({
|
||||
url: `/api/v2/dashboard_views/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateDashboardViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateDashboardView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateDashboardView(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateDashboardViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>
|
||||
>;
|
||||
export type UpdateDashboardViewMutationBody =
|
||||
| BodyType<DashboardtypesPostableDashboardViewDTO>
|
||||
| undefined;
|
||||
export type UpdateDashboardViewMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update dashboard saved view
|
||||
*/
|
||||
export const useUpdateDashboardView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
|
||||
* @summary List dashboards (v2)
|
||||
@@ -1562,85 +1206,6 @@ export const useUpdateDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.
|
||||
* @summary Clone dashboard (v2)
|
||||
*/
|
||||
export const cloneDashboardV2 = (
|
||||
{ id }: CloneDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CloneDashboardV2201>({
|
||||
url: `/api/v2/dashboards/${id}/clone`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCloneDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: CloneDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: CloneDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['cloneDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
{ pathParams: CloneDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return cloneDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CloneDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>
|
||||
>;
|
||||
|
||||
export type CloneDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Clone dashboard (v2)
|
||||
*/
|
||||
export const useCloneDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: CloneDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: CloneDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCloneDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Unlock dashboard (v2)
|
||||
|
||||
@@ -2645,14 +2645,6 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
appservice = 'appservice',
|
||||
containerapp = 'containerapp',
|
||||
aks = 'aks',
|
||||
sqldatabase = 'sqldatabase',
|
||||
sqldatabasemi = 'sqldatabasemi',
|
||||
mysqlflexibleserver = 'mysqlflexibleserver',
|
||||
postgresqlflexibleserver = 'postgresqlflexibleserver',
|
||||
mongodb = 'mongodb',
|
||||
cosmosdb = 'cosmosdb',
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -4633,54 +4625,6 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
}
|
||||
|
||||
export enum DashboardtypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum DashboardtypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export interface DashboardtypesDashboardViewDataDTO {
|
||||
order?: DashboardtypesListOrderDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
query?: string;
|
||||
sort?: DashboardtypesListSortDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDashboardViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
data: DashboardtypesDashboardViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginKindDTO {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
@@ -4792,6 +4736,15 @@ export interface DashboardtypesJSONPatchOperationDTO {
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum DashboardtypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export interface DashboardtypesListedDashboardV2SpecDTO {
|
||||
display?: DashboardtypesDisplayDTO;
|
||||
}
|
||||
@@ -4934,13 +4887,6 @@ export interface DashboardtypesListableDashboardV2DTO {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesListableDashboardViewDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
views: DashboardtypesDashboardViewDTO[];
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
@@ -4992,14 +4938,6 @@ export interface DashboardtypesPostableDashboardV2DTO {
|
||||
tags: TagtypesPostableTagDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPostableDashboardViewDTO {
|
||||
data: DashboardtypesDashboardViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPostablePublicDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8135,44 +8073,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributes =
|
||||
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOResource =
|
||||
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanMapperTestSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesSpanMapperTestSpanDTOResource;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -8468,33 +8368,6 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestGroupDTO {
|
||||
condition: SpantypesSpanMapperGroupConditionDTO | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
mappers?: SpantypesPostableSpanMapperDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
@@ -9863,14 +9736,6 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type TestSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperTestDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
@@ -9964,36 +9829,6 @@ export type GetUserPreference200 = {
|
||||
export type UpdateUserPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListDashboardViews200 = {
|
||||
data: DashboardtypesListableDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateDashboardView201 = {
|
||||
data: DashboardtypesDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteDashboardViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardView200 = {
|
||||
data: DashboardtypesDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListDashboardsV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
@@ -10072,17 +9907,6 @@ export type UpdateDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CloneDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneDashboardV2201 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -30,10 +30,8 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -782,86 +780,3 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
|
||||
/**
|
||||
* Fetches a single saved view by ID (`GET /api/v1/explorer/views/{viewId}`).
|
||||
*
|
||||
* Hand-maintained alongside the other `api/saveView/*` clients — explorer views
|
||||
* are not in `docs/api/openapi.yml`, so Orval does not generate a hook here
|
||||
* (unlike e.g. `useGetChannelByID` under `api/generated/services/channels`).
|
||||
*
|
||||
* Used by the AI assistant "Open view" action to load `compositeQuery` and
|
||||
* navigate to the correct explorer without listing every view per source page.
|
||||
* See `container/AIAssistant/components/ActionsSection/utils/openSavedView.ts`.
|
||||
*/
|
||||
export interface GetViewByIdProps {
|
||||
status: string;
|
||||
data: ViewProps;
|
||||
}
|
||||
|
||||
export const getViewById = (
|
||||
viewKey: string,
|
||||
): Promise<AxiosResponse<GetViewByIdProps>> =>
|
||||
axios.get(`/explorer/views/${viewKey}`);
|
||||
@@ -1,29 +1,67 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import {
|
||||
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { cloneDeep, isArray, isFunction } from 'lodash-es';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import CheckboxFilterHeader from './CheckboxFilterHeader';
|
||||
import CheckboxValueRow from './CheckboxValueRow';
|
||||
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
|
||||
import useActiveQueryIndex from './useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from './useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from './useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from './useCheckboxFilterState';
|
||||
import useCheckboxFilterValues from './useCheckboxFilterValues';
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
import './Checkbox.styles.scss';
|
||||
|
||||
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
|
||||
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
|
||||
/**
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
}
|
||||
|
||||
function setDefaultValues(
|
||||
values: string[],
|
||||
trueOrFalse: boolean,
|
||||
): Record<string, boolean> {
|
||||
const defaultState: Record<string, boolean> = {};
|
||||
values.forEach((val) => {
|
||||
defaultState[val] = trueOrFalse;
|
||||
});
|
||||
return defaultState;
|
||||
}
|
||||
interface ICheckboxProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
@@ -34,39 +72,194 @@ interface ICheckboxProps {
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
lastUsedQuery,
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
panelType,
|
||||
} = useQueryBuilder();
|
||||
|
||||
// Determine if we're in ListView mode
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
|
||||
// Otherwise use lastUsedQuery for non-ListView modes
|
||||
const activeQueryIndex = useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
|
||||
// Check if this filter has active filters in the query
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
|
||||
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
),
|
||||
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
|
||||
);
|
||||
|
||||
// Derive isOpen from filter state + user action
|
||||
const isOpen = useMemo(() => {
|
||||
// If user explicitly toggled, respect that
|
||||
if (userToggleState !== null) {
|
||||
return userToggleState;
|
||||
}
|
||||
|
||||
// Auto-open if this filter has active filters in the query
|
||||
if (isSomeFilterPresentForCurrentAttribute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise use default behavior (first 2 filters open)
|
||||
return filter.defaultOpen;
|
||||
}, [
|
||||
userToggleState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
|
||||
const { attributeValues, isLoading } = useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
});
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
dataSource: filter.dataSource || DataSource.LOGS,
|
||||
aggregateAttribute: filter.aggregateAttribute || '',
|
||||
attributeKey: filter.attributeKey.key,
|
||||
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
|
||||
tagType: filter.attributeKey.type || '',
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
|
||||
useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
});
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined,
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
// derive the state of each filter key here in the renderer itself and keep it in sync with current query
|
||||
// also we need to keep a note of last focussed query.
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const currentFilterState = useMemo(() => {
|
||||
let filterState: Record<string, boolean> = setDefaultValues(
|
||||
attributeValues,
|
||||
false,
|
||||
);
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items.find((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (filterSync) {
|
||||
if (SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = true;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = true;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
}
|
||||
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = false;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = false;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
}
|
||||
return filterState;
|
||||
}, [
|
||||
attributeValues,
|
||||
currentQuery?.builder.queryData,
|
||||
filter.attributeKey,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
|
||||
const isFilterDisabled = useMemo(
|
||||
() =>
|
||||
(currentQuery?.builder?.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items?.filter((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)?.length || 0) > 1,
|
||||
|
||||
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
|
||||
);
|
||||
|
||||
// variable to check if the current filter has multiple values to its name in the key op value section
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
// Sort checked items to the top, then unchecked items
|
||||
const currentAttributeKeys = useMemo(() => {
|
||||
const checkedValues = attributeValues.filter(
|
||||
@@ -84,6 +277,293 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
[currentAttributeKeys, currentFilterState],
|
||||
);
|
||||
|
||||
const handleClearFilterAttribute = (): void => {
|
||||
const preparedQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(preparedQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(preparedQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): void => {
|
||||
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
|
||||
|
||||
// if only or all are clicked we do not need to worry about anything just override whatever we have
|
||||
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
|
||||
if (isOnlyOrAllClicked && query?.filters?.items) {
|
||||
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only';
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnlyOrAll === 'Only') {
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
} else if (query?.filters?.items) {
|
||||
if (
|
||||
query.filters?.items?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)
|
||||
) {
|
||||
// if there is already a running filter for the current attribute key then
|
||||
// we split the cases by which particular operator is present right now!
|
||||
const currentFilter = query.filters?.items?.find((q) =>
|
||||
isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
// if it's an IN operator then if we are checking another value it get's added to the
|
||||
// filter clause. example - key IN [value1, currentSelectedValue]
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// if the current state wasn't an array we make it one and add our value
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// if not an array remove the whole thing altogether!
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (!checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '!=':
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
}
|
||||
const finalQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
...currentQuery.builder.queryData.map((q, idx) => {
|
||||
if (idx === activeQueryIndex) {
|
||||
return query;
|
||||
}
|
||||
return q;
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(finalQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(finalQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyStateWithDocsEnabled =
|
||||
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
|
||||
!searchText &&
|
||||
@@ -91,19 +571,48 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="checkbox-filter">
|
||||
<CheckboxFilterHeader
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
<section
|
||||
className="filter-header-checkbox"
|
||||
onClick={(): void => {
|
||||
if (isOpen) {
|
||||
setUserToggleState(false);
|
||||
setVisibleItemsCount(10);
|
||||
} else {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section className="left-action">
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className="title">{filter.title}</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && (
|
||||
<section className="right-action">
|
||||
{isOpen && !!attributeValues.length && (
|
||||
<Typography.Text
|
||||
className="clear-all"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleClearFilterAttribute();
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
{isOpen &&
|
||||
(isLoading || isLoadingKeyValueSuggestions) &&
|
||||
!attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
|
||||
<>
|
||||
{!isEmptyStateWithDocsEnabled && (
|
||||
<section className="search">
|
||||
@@ -125,24 +634,48 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
data-testid="filter-separator"
|
||||
/>
|
||||
)}
|
||||
<CheckboxValueRow
|
||||
value={value}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
title={filter.title}
|
||||
onlyButtonLabel={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'
|
||||
}
|
||||
customRendererForValue={filter.customRendererForValue}
|
||||
onCheckboxChange={(checked): void => onChange(value, checked, false)}
|
||||
onOnlyOrAllClick={(): void =>
|
||||
onChange(value, currentFilterState[value], true)
|
||||
}
|
||||
/>
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(checked): void =>
|
||||
onChange(value, checked === true, false)
|
||||
}
|
||||
value={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
className="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'checkbox-value-section',
|
||||
isFilterDisabled ? 'filter-disabled' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
if (isFilterDisabled) {
|
||||
return;
|
||||
}
|
||||
onChange(value, currentFilterState[value], true);
|
||||
}}
|
||||
>
|
||||
<div className={`${filter.title} label-${value}`} />
|
||||
{filter.customRendererForValue ? (
|
||||
filter.customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</section>
|
||||
@@ -155,7 +688,10 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
{visibleItemsCount < attributeValues?.length && (
|
||||
<section className="show-more">
|
||||
<Typography.Text className="show-more-text" onClick={onShowMore}>
|
||||
<Typography.Text
|
||||
className="show-more-text"
|
||||
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function CheckboxFilterHeader({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onClear,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section className="filter-header-checkbox" onClick={onToggleOpen}>
|
||||
<section className="left-action">
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className="title">{title}</Typography.Text>
|
||||
</section>
|
||||
<section className="right-action">
|
||||
{isOpen && showClearAll && (
|
||||
<Typography.Text
|
||||
className="clear-all"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxFilterHeader;
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Button } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
interface CheckboxValueRowProps {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
title: string;
|
||||
onlyButtonLabel: string;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
onCheckboxChange: (checked: boolean) => void;
|
||||
onOnlyOrAllClick: () => void;
|
||||
}
|
||||
|
||||
function CheckboxValueRow({
|
||||
value,
|
||||
checked,
|
||||
disabled,
|
||||
title,
|
||||
onlyButtonLabel,
|
||||
customRendererForValue,
|
||||
onCheckboxChange,
|
||||
onOnlyOrAllClick,
|
||||
}: CheckboxValueRowProps): JSX.Element {
|
||||
return (
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
|
||||
value={checked}
|
||||
disabled={disabled}
|
||||
className="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
|
||||
onClick={(): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onOnlyOrAllClick();
|
||||
}}
|
||||
>
|
||||
<div className={`${title} label-${value}`} />
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CheckboxValueRow.defaultProps = {
|
||||
customRendererForValue: undefined,
|
||||
};
|
||||
|
||||
export default CheckboxValueRow;
|
||||
@@ -1,417 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { cloneDeep, isArray } from 'lodash-es';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
|
||||
/**
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
export function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
}
|
||||
|
||||
function setDefaultValues(
|
||||
values: string[],
|
||||
trueOrFalse: boolean,
|
||||
): Record<string, boolean> {
|
||||
const defaultState: Record<string, boolean> = {};
|
||||
values.forEach((val) => {
|
||||
defaultState[val] = trueOrFalse;
|
||||
});
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the checked/unchecked state for each attribute value by reading the
|
||||
* active filter clause for this attribute key out of the query.
|
||||
*
|
||||
* - No matching clause -> every value is checked (all selected).
|
||||
* - IN / `=` clause -> only the listed values are checked.
|
||||
* - NOT IN / `!=` clause -> every value is checked except the excluded ones.
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems,
|
||||
filterKey,
|
||||
}: {
|
||||
attributeValues: string[];
|
||||
filterItems: TagFilterItem[] | undefined;
|
||||
filterKey: string;
|
||||
}): Record<string, boolean> {
|
||||
let filterState: Record<string, boolean> = setDefaultValues(
|
||||
attributeValues,
|
||||
false,
|
||||
);
|
||||
const filterSync = filterItems?.find((item) =>
|
||||
isKeyMatch(item.key?.key, filterKey),
|
||||
);
|
||||
|
||||
if (filterSync) {
|
||||
if (SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = true;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = true;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
}
|
||||
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = false;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = false;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
}
|
||||
return filterState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new query with every clause for this attribute key removed, both
|
||||
* from the structured filter items and the raw filter expression.
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isOnlyOrAllClicked: boolean;
|
||||
}): Query {
|
||||
const activeItems =
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
|
||||
|
||||
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
const currentFilterState = deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems: activeItems,
|
||||
filterKey: filter.attributeKey.key,
|
||||
});
|
||||
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
|
||||
|
||||
// if only or all are clicked we do not need to worry about anything just override whatever we have
|
||||
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
|
||||
if (isOnlyOrAllClicked && query?.filters?.items) {
|
||||
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only';
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
|
||||
filter.attributeKey.key,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isOnlyOrAll === 'Only') {
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
} else if (query?.filters?.items) {
|
||||
if (
|
||||
query.filters?.items?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)
|
||||
) {
|
||||
// if there is already a running filter for the current attribute key then
|
||||
// we split the cases by which particular operator is present right now!
|
||||
const currentFilter = query.filters?.items?.find((q) =>
|
||||
isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
// if it's an IN operator then if we are checking another value it get's added to the
|
||||
// filter clause. example - key IN [value1, currentSelectedValue]
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// if the current state wasn't an array we make it one and add our value
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// if not an array remove the whole thing altogether!
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (!checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '!=':
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
...currentQuery.builder.queryData.map((q, idx) => {
|
||||
if (idx === activeQueryIndex) {
|
||||
return query;
|
||||
}
|
||||
return q;
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
/**
|
||||
* Resolves which query-builder query index the checkbox filter reads from and
|
||||
* writes to.
|
||||
*
|
||||
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
|
||||
* mode track the last focused query.
|
||||
*/
|
||||
function useActiveQueryIndex(source: QuickFiltersSource): number {
|
||||
const { lastUsedQuery, panelType } = useQueryBuilder();
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
|
||||
return useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
}
|
||||
|
||||
export default useActiveQueryIndex;
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
|
||||
interface UseCheckboxDisclosureProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseCheckboxDisclosureReturn {
|
||||
isOpen: boolean;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
visibleItemsCount: number;
|
||||
onToggleOpen: () => void;
|
||||
onShowMore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the open/collapsed state of a checkbox filter section and how many
|
||||
* values are visible.
|
||||
*
|
||||
* Auto-opens when the query already has a clause for this attribute, otherwise
|
||||
* falls back to `filter.defaultOpen`. An explicit user toggle always wins.
|
||||
* Collapsing resets the visible count.
|
||||
*/
|
||||
function useCheckboxDisclosure({
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: UseCheckboxDisclosureProps): UseCheckboxDisclosureReturn {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(
|
||||
DEFAULT_VISIBLE_ITEMS_COUNT,
|
||||
);
|
||||
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
|
||||
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
),
|
||||
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
|
||||
);
|
||||
|
||||
const isOpen = useMemo(() => {
|
||||
// If user explicitly toggled, respect that
|
||||
if (userToggleState !== null) {
|
||||
return userToggleState;
|
||||
}
|
||||
|
||||
// Auto-open if this filter has active filters in the query
|
||||
if (isSomeFilterPresentForCurrentAttribute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise use default behavior (first 2 filters open)
|
||||
return filter.defaultOpen;
|
||||
}, [
|
||||
userToggleState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
|
||||
const onToggleOpen = (): void => {
|
||||
if (isOpen) {
|
||||
setUserToggleState(false);
|
||||
setVisibleItemsCount(DEFAULT_VISIBLE_ITEMS_COUNT);
|
||||
} else {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onShowMore = (): void => {
|
||||
setVisibleItemsCount((prev) => prev + DEFAULT_VISIBLE_ITEMS_COUNT);
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxDisclosure;
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
onFilterChange?: ((query: Query) => void) | null;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterActionsReturn {
|
||||
onChange: (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the pure checkbox query algebra to query-builder dispatch: the
|
||||
* caller-provided `onFilterChange` when present, otherwise a URL redirect.
|
||||
*/
|
||||
function useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const dispatch = (query: Query): void => {
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(query);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(query);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
): void => {
|
||||
dispatch(
|
||||
applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onClear = (): void => {
|
||||
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
|
||||
};
|
||||
|
||||
return { onChange, onClear };
|
||||
}
|
||||
|
||||
export default useCheckboxFilterActions;
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { deriveCheckboxState } from './checkboxFilterQuery';
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
interface UseCheckboxFilterStateProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterStateReturn {
|
||||
currentFilterState: Record<string, boolean>;
|
||||
isFilterDisabled: boolean;
|
||||
isMultipleValuesTrueForTheKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the active query and derives the per-value checked state for this
|
||||
* attribute, whether the filter is disabled (same key used more than once in
|
||||
* the filter bar), and whether more than one value is currently selected.
|
||||
*/
|
||||
function useCheckboxFilterState({
|
||||
filter,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
}: UseCheckboxFilterStateProps): UseCheckboxFilterStateReturn {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
// derive the state of each filter key here and keep it in sync with current query
|
||||
const currentFilterState = useMemo(
|
||||
() =>
|
||||
deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems:
|
||||
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
|
||||
filterKey: filter.attributeKey.key,
|
||||
}),
|
||||
[
|
||||
attributeValues,
|
||||
currentQuery?.builder.queryData,
|
||||
filter.attributeKey,
|
||||
activeQueryIndex,
|
||||
],
|
||||
);
|
||||
|
||||
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
|
||||
const isFilterDisabled = useMemo(
|
||||
() =>
|
||||
(currentQuery?.builder?.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items?.filter((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)?.length || 0) > 1,
|
||||
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
|
||||
);
|
||||
|
||||
// whether the current filter has multiple values to its name in the key op value section
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
return {
|
||||
currentFilterState,
|
||||
isFilterDisabled,
|
||||
isMultipleValuesTrueForTheKey,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxFilterState;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseCheckboxFilterValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
searchText: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterValuesReturn {
|
||||
attributeValues: string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
dataSource: filter.dataSource || DataSource.LOGS,
|
||||
aggregateAttribute: filter.aggregateAttribute || '',
|
||||
attributeKey: filter.attributeKey.key,
|
||||
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
|
||||
tagType: filter.attributeKey.type || '',
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
|
||||
useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined,
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
return {
|
||||
attributeValues,
|
||||
isLoading: isLoading || isLoadingKeyValueSuggestions,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxFilterValues;
|
||||
@@ -38,8 +38,8 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
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',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
|
||||
@@ -113,7 +113,4 @@ export const REACT_QUERY_KEY = {
|
||||
|
||||
// Fields Selector Query Keys
|
||||
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
|
||||
|
||||
// AI Assistant Query Keys
|
||||
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
|
||||
} as const;
|
||||
|
||||
@@ -91,8 +91,6 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING:
|
||||
'/llm-observability/settings/attribute-mapping',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { getAutoContexts } from '../getAutoContexts';
|
||||
|
||||
describe('getAutoContexts', () => {
|
||||
it('returns alert detail context on alert overview with ruleId', () => {
|
||||
const ruleId = 'rule-abc';
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.relativeTime}=1h`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_detail',
|
||||
ruleId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns alert detail context on alert history with ruleId', () => {
|
||||
const ruleId = 'rule-xyz';
|
||||
const startTime = '1700000000000';
|
||||
const endTime = '1700003600000';
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.startTime}=${startTime}&${QueryParams.endTime}=${endTime}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_detail',
|
||||
ruleId,
|
||||
timeRange: {
|
||||
start: Number(startTime),
|
||||
end: Number(endTime),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns triggered alerts context on alert history without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'alerts_triggered',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves alert list tabs on /alerts', () => {
|
||||
expect(getAutoContexts(ROUTES.LIST_ALL_ALERT, '')).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_list' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAutoContexts(ROUTES.LIST_ALL_ALERT, '?tab=AlertRules'),
|
||||
).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_list' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAutoContexts(ROUTES.LIST_ALL_ALERT, '?tab=TriggeredAlerts'),
|
||||
).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alerts_triggered' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAutoContexts(ROUTES.LIST_ALL_ALERT, '?tab=Configuration'),
|
||||
).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_list' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns dashboard detail context on dashboard page', () => {
|
||||
const dashboardId = 'dash-123';
|
||||
const pathname = ROUTES.DASHBOARD.replace(':dashboardId', dashboardId);
|
||||
|
||||
const contexts = getAutoContexts(pathname, '');
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardId,
|
||||
metadata: {
|
||||
page: 'dashboard_detail',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array on alert overview without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, '');
|
||||
|
||||
expect(contexts).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('emits no auto-context on /home (no attachable resource)', () => {
|
||||
expect(getAutoContexts(ROUTES.HOME, '')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('emits no auto-context on infrastructure monitoring routes', () => {
|
||||
expect(
|
||||
getAutoContexts(ROUTES.INFRASTRUCTURE_MONITORING_BASE, ''),
|
||||
).toStrictEqual([]);
|
||||
|
||||
expect(
|
||||
getAutoContexts(
|
||||
ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
|
||||
'?selectedItem=host-1',
|
||||
),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { PageTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { resolvePageType } from '../resolvePageType';
|
||||
|
||||
describe('resolvePageType', () => {
|
||||
it('returns other for the standalone assistant surface', () => {
|
||||
expect(
|
||||
resolvePageType('/services', '', { isStandaloneAssistant: true }),
|
||||
).toBe(PageTypeDTO.other);
|
||||
});
|
||||
|
||||
it('returns dashboard_detail on a dashboard page', () => {
|
||||
const pathname = ROUTES.DASHBOARD.replace(':dashboardId', 'dash-123');
|
||||
|
||||
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.dashboard_detail);
|
||||
});
|
||||
|
||||
it('returns alerts_triggered on alert history without ruleId', () => {
|
||||
expect(resolvePageType(ROUTES.ALERT_HISTORY, '')).toBe(
|
||||
PageTypeDTO.alerts_triggered,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves alert list tabs on /alerts', () => {
|
||||
expect(resolvePageType(ROUTES.LIST_ALL_ALERT, '')).toBe(
|
||||
PageTypeDTO.alert_list,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.LIST_ALL_ALERT, '?tab=AlertRules')).toBe(
|
||||
PageTypeDTO.alert_list,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.LIST_ALL_ALERT, '?tab=TriggeredAlerts')).toBe(
|
||||
PageTypeDTO.alerts_triggered,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.LIST_ALL_ALERT, '?tab=Configuration')).toBe(
|
||||
PageTypeDTO.alert_list,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns log_detail when logs explorer has activeLogId', () => {
|
||||
const search = `?${QueryParams.activeLogId}=log-1`;
|
||||
|
||||
expect(resolvePageType(ROUTES.LOGS_EXPLORER, search)).toBe(
|
||||
PageTypeDTO.log_detail,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns other for unmapped routes', () => {
|
||||
expect(resolvePageType(ROUTES.ALERT_OVERVIEW, '')).toBe(PageTypeDTO.other);
|
||||
});
|
||||
|
||||
it('returns other for the app root route (no contextual mapping)', () => {
|
||||
expect(resolvePageType(ROUTES.HOME_PAGE, '')).toBe(PageTypeDTO.other);
|
||||
});
|
||||
|
||||
it('returns homepage on /home', () => {
|
||||
expect(resolvePageType(ROUTES.HOME, '')).toBe(PageTypeDTO.homepage);
|
||||
});
|
||||
|
||||
it('returns infra_entity_detail on infrastructure monitoring routes', () => {
|
||||
expect(resolvePageType(ROUTES.INFRASTRUCTURE_MONITORING_BASE, '')).toBe(
|
||||
PageTypeDTO.infra_entity_detail,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.INFRASTRUCTURE_MONITORING_HOSTS, '')).toBe(
|
||||
PageTypeDTO.infra_entity_detail,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES, '')).toBe(
|
||||
PageTypeDTO.infra_entity_detail,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns metrics_explorer on all metrics explorer routes', () => {
|
||||
expect(resolvePageType(ROUTES.METRICS_EXPLORER_BASE, '')).toBe(
|
||||
PageTypeDTO.metrics_explorer,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.METRICS_EXPLORER, '')).toBe(
|
||||
PageTypeDTO.metrics_explorer,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.METRICS_EXPLORER_EXPLORER, '')).toBe(
|
||||
PageTypeDTO.metrics_explorer,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.METRICS_EXPLORER_VIEWS, '')).toBe(
|
||||
PageTypeDTO.metrics_explorer,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -47,15 +47,6 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
resolveSavedViewSourceHint,
|
||||
} from './utils/resolveOpenResource';
|
||||
import { ResourceType, resourceRoute } from './utils/resourceRoute';
|
||||
|
||||
import styles from './ActionsSection.module.scss';
|
||||
|
||||
interface ActionsSectionProps {
|
||||
@@ -64,6 +55,20 @@ interface ActionsSectionProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-type strings the backend uses for `open_resource` and rollback
|
||||
* actions. Centralized here so the route/module lookups below stay in sync.
|
||||
*/
|
||||
const ResourceType = {
|
||||
dashboard: 'dashboard',
|
||||
alert: 'alert',
|
||||
service: 'service',
|
||||
saved_view: 'saved_view',
|
||||
logs_explorer: 'logs_explorer',
|
||||
traces_explorer: 'traces_explorer',
|
||||
metrics_explorer: 'metrics_explorer',
|
||||
} as const;
|
||||
|
||||
/** Maps an open_resource action's resourceType to its product module name. */
|
||||
function targetModuleForResource(resourceType: string): string | null {
|
||||
switch (resourceType) {
|
||||
@@ -73,8 +78,6 @@ function targetModuleForResource(resourceType: string): string | null {
|
||||
return 'alerts';
|
||||
case ResourceType.service:
|
||||
return 'apm';
|
||||
case ResourceType.channel:
|
||||
return 'channels';
|
||||
case ResourceType.saved_view:
|
||||
return 'savedViews';
|
||||
case ResourceType.logs_explorer:
|
||||
@@ -137,6 +140,39 @@ function ActionIcon({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an `open_resource` action to an in-app route.
|
||||
* Resource taxonomy mirrors `MessageContextDTOType`: dashboard, alert,
|
||||
* saved_view, service, and the *_explorer signals.
|
||||
*/
|
||||
function resourceRoute(
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
): string | null {
|
||||
switch (resourceType) {
|
||||
case ResourceType.dashboard:
|
||||
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
|
||||
case ResourceType.alert: {
|
||||
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
|
||||
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
|
||||
}
|
||||
case ResourceType.service:
|
||||
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
|
||||
case ResourceType.saved_view:
|
||||
// No detail route — saved views land on the list page.
|
||||
// Caller may provide signal-aware metadata in future; default to logs.
|
||||
return ROUTES.LOGS_SAVE_VIEWS;
|
||||
case ResourceType.logs_explorer:
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case ResourceType.traces_explorer:
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
case ResourceType.metrics_explorer:
|
||||
return ROUTES.METRICS_EXPLORER_EXPLORER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent emits `action.query` as the SigNoz REST query-range request body:
|
||||
*
|
||||
@@ -448,35 +484,6 @@ export default function ActionsSection({
|
||||
setResults((prev) => ({ ...prev, [key]: result }));
|
||||
};
|
||||
|
||||
const runOpenSavedView = async (
|
||||
key: string,
|
||||
action: MessageActionDTO,
|
||||
): Promise<void> => {
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceId) {
|
||||
return;
|
||||
}
|
||||
setResult(key, { state: 'loading' });
|
||||
try {
|
||||
await openSavedViewByKey(
|
||||
resourceId,
|
||||
resolveSavedViewSourceHint(action),
|
||||
history,
|
||||
);
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(ResourceType.saved_view),
|
||||
resourceId,
|
||||
});
|
||||
setResult(key, { state: 'success' });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to open saved view';
|
||||
setResult(key, { state: 'error', error: message });
|
||||
}
|
||||
};
|
||||
|
||||
const runRollback = async (
|
||||
key: string,
|
||||
action: MessageActionDTO,
|
||||
@@ -495,31 +502,6 @@ export default function ActionsSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenResource = (key: string, action: MessageActionDTO): void => {
|
||||
if (isSavedViewOpenAction(action)) {
|
||||
void runOpenSavedView(key, action);
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceType = resolveOpenResourceType(action);
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceType || !resourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resourceRoute(resourceType, resourceId);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(resourceType),
|
||||
resourceId,
|
||||
});
|
||||
history.push(path);
|
||||
};
|
||||
|
||||
const handleClick = (key: string, action: MessageActionDTO): void => {
|
||||
switch (action.kind) {
|
||||
case MessageActionKindDTO.open_docs: {
|
||||
@@ -560,9 +542,21 @@ export default function ActionsSection({
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.open_resource:
|
||||
handleOpenResource(key, action);
|
||||
case MessageActionKindDTO.open_resource: {
|
||||
if (action.resourceType && action.resourceId) {
|
||||
const path = resourceRoute(action.resourceType, action.resourceId);
|
||||
if (path) {
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(action.resourceType),
|
||||
resourceId: action.resourceId,
|
||||
});
|
||||
history.push(path);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.undo:
|
||||
case MessageActionKindDTO.revert:
|
||||
case MessageActionKindDTO.restore: {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
MessageActionKindDTO,
|
||||
SavedViewEntityDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import type { History } from 'history';
|
||||
|
||||
import {
|
||||
buildExplorerNavigationUrl,
|
||||
findSavedViewInLists,
|
||||
openSavedView,
|
||||
openSavedViewByKey,
|
||||
} from '../openSavedView';
|
||||
import {
|
||||
entityToDataSource,
|
||||
isSavedViewOpenAction,
|
||||
resolveActionEntity,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
resolveResourceType,
|
||||
resolveSavedViewSourceHint,
|
||||
} from '../resolveOpenResource';
|
||||
import { resourceRoute, ResourceType } from '../resourceRoute';
|
||||
|
||||
jest.mock('api/saveView/getAllViews');
|
||||
jest.mock('api/saveView/getViewById');
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
() => ({
|
||||
mapQueryDataFromApi: jest.fn(() => ({
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [{ id: 'A' }],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockedGetAllViews = getAllViews as jest.MockedFunction<
|
||||
typeof getAllViews
|
||||
>;
|
||||
const mockedGetViewById = getViewById as jest.MockedFunction<
|
||||
typeof getViewById
|
||||
>;
|
||||
|
||||
function makeView(id: string, sourcePage: DataSource): ViewProps {
|
||||
return {
|
||||
id,
|
||||
name: `View ${id}`,
|
||||
category: 'test',
|
||||
createdAt: '2021-07-07T06:31:00.000Z',
|
||||
createdBy: 'user',
|
||||
updatedAt: '2021-07-07T06:33:00.000Z',
|
||||
updatedBy: 'user',
|
||||
sourcePage,
|
||||
tags: [],
|
||||
extraData: '',
|
||||
compositeQuery: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
} as ICompositeMetricQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function mockViewsResponse(views: ViewProps[]): AxiosResponse<AllViewsProps> {
|
||||
return {
|
||||
data: { status: 'success', data: views },
|
||||
} as AxiosResponse<AllViewsProps>;
|
||||
}
|
||||
|
||||
function mockViewByIdResponse(
|
||||
view: ViewProps,
|
||||
): AxiosResponse<{ status: string; data: ViewProps }> {
|
||||
return {
|
||||
data: { status: 'success', data: view },
|
||||
} as AxiosResponse<{ status: string; data: ViewProps }>;
|
||||
}
|
||||
|
||||
describe('resourceRoute', () => {
|
||||
it('returns null for saved_view so async navigation is used', () => {
|
||||
expect(resourceRoute(ResourceType.saved_view, 'view-123')).toBeNull();
|
||||
});
|
||||
|
||||
it('routes channels to the edit page', () => {
|
||||
expect(resourceRoute(ResourceType.channel, 'channel-uuid-1')).toBe(
|
||||
'/settings/channels/edit/channel-uuid-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOpenResource', () => {
|
||||
it('reads entity from the action envelope', () => {
|
||||
expect(
|
||||
resolveActionEntity({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
entity: SavedViewEntityDTO.traces,
|
||||
}),
|
||||
).toBe(SavedViewEntityDTO.traces);
|
||||
});
|
||||
|
||||
it('reads resource id from input.viewKey', () => {
|
||||
expect(
|
||||
resolveResourceId({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
input: { viewKey: 'abc-123' },
|
||||
}),
|
||||
).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('maps entity values to explorer data sources', () => {
|
||||
expect(entityToDataSource('logs')).toBe(DataSource.LOGS);
|
||||
expect(entityToDataSource('logs_explorer')).toBe(DataSource.LOGS);
|
||||
expect(entityToDataSource('traces')).toBe(DataSource.TRACES);
|
||||
});
|
||||
|
||||
it('prefers entity over signal for saved-view source hints', () => {
|
||||
expect(
|
||||
resolveSavedViewSourceHint({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
entity: SavedViewEntityDTO.traces,
|
||||
signal: ApplyFilterSignalDTO.logs,
|
||||
}),
|
||||
).toBe(DataSource.TRACES);
|
||||
});
|
||||
|
||||
it('falls back to signal when entity is absent', () => {
|
||||
expect(
|
||||
resolveSavedViewSourceHint({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
signal: ApplyFilterSignalDTO.metrics,
|
||||
}),
|
||||
).toBe(DataSource.METRICS);
|
||||
});
|
||||
|
||||
it('normalises saved-view resource types', () => {
|
||||
expect(
|
||||
resolveResourceType({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
resourceType: 'saved-view',
|
||||
}),
|
||||
).toBe(ResourceType.saved_view);
|
||||
});
|
||||
|
||||
it('detects open-view actions from label when id is present in input', () => {
|
||||
expect(
|
||||
isSavedViewOpenAction({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
input: { viewId: 'view-1' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves channel type from notification_channel alias', () => {
|
||||
expect(
|
||||
resolveResourceType({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open channel',
|
||||
resourceType: 'notification_channel',
|
||||
}),
|
||||
).toBe(ResourceType.channel);
|
||||
});
|
||||
|
||||
it('infers channel type from Open channel label when resourceId is present', () => {
|
||||
expect(
|
||||
resolveOpenResourceType({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open channel',
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
).toBe(ResourceType.channel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedViewInLists', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetAllViews.mockReset();
|
||||
});
|
||||
|
||||
it('loads only the hinted source when entity is provided', async () => {
|
||||
const tracesView = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
|
||||
|
||||
expect(result).toStrictEqual(tracesView);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExplorerNavigationUrl', () => {
|
||||
it('encodes composite query and view selectors', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
{ queryType: 'builder' } as never,
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
|
||||
[QueryParams.viewName]: 'My view',
|
||||
[QueryParams.viewKey]: 'view-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedView', () => {
|
||||
it('navigates with history.push and view query params', () => {
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
|
||||
openSavedView(view, history);
|
||||
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
const pushedUrl = push.mock.calls[0][0] as string;
|
||||
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(pushedUrl).toContain(QueryParams.viewKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedViewByKey', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetAllViews.mockReset();
|
||||
mockedGetViewById.mockReset();
|
||||
});
|
||||
|
||||
it('prefers the direct view lookup endpoint', async () => {
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
mockedGetViewById.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-logs', DataSource.LOGS, history);
|
||||
|
||||
expect(mockedGetViewById).toHaveBeenCalledWith('view-logs');
|
||||
expect(mockedGetAllViews).not.toHaveBeenCalled();
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to list probing when direct lookup fails', async () => {
|
||||
const view = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-traces', DataSource.TRACES, history);
|
||||
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the saved view does not exist', async () => {
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValue(mockViewsResponse([]));
|
||||
|
||||
await expect(
|
||||
openSavedViewByKey('missing', DataSource.LOGS, {
|
||||
push: jest.fn(),
|
||||
} as unknown as History),
|
||||
).rejects.toThrow('Saved view not found');
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { History } from 'history';
|
||||
|
||||
type SavedViewSourceHint = DataSource | 'meter';
|
||||
|
||||
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
DataSource.LOGS,
|
||||
DataSource.TRACES,
|
||||
DataSource.METRICS,
|
||||
];
|
||||
|
||||
export async function findSavedViewInLists(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<ViewProps | null> {
|
||||
const sources = sourceHint ? [sourceHint] : DEFAULT_PROBE_SOURCES;
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await getAllViews(source);
|
||||
const match = response.data.data.find((view) => view.id === viewKey);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
} catch {
|
||||
// Probe the next source page when no entity hint is provided.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSavedView(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<ViewProps> {
|
||||
try {
|
||||
const response = await getViewById(viewKey);
|
||||
if (response.data?.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to list probing when the direct lookup fails.
|
||||
}
|
||||
|
||||
const fromList = await findSavedViewInLists(viewKey, sourceHint);
|
||||
if (fromList) {
|
||||
return fromList;
|
||||
}
|
||||
|
||||
throw new Error('Saved view not found');
|
||||
}
|
||||
|
||||
export function explorerRouteForSourcePage(
|
||||
sourcePage: DataSource | string,
|
||||
): (typeof SOURCEPAGE_VS_ROUTES)[keyof typeof SOURCEPAGE_VS_ROUTES] | null {
|
||||
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
|
||||
* without inheriting stale query params from the current page's `urlQuery`.
|
||||
*/
|
||||
export function buildExplorerNavigationUrl(
|
||||
route: string,
|
||||
query: Query,
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(view: ViewProps, history: History): void {
|
||||
const route = explorerRouteForSourcePage(view.sourcePage);
|
||||
if (!route) {
|
||||
throw new Error('Unsupported saved view source');
|
||||
}
|
||||
|
||||
if (!view.compositeQuery) {
|
||||
throw new Error('Saved view is missing query data');
|
||||
}
|
||||
|
||||
const query = mapQueryDataFromApi(view.compositeQuery);
|
||||
const url = buildExplorerNavigationUrl(route, query, {
|
||||
[QueryParams.panelTypes]: view.compositeQuery.panelType as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.name,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
history.push(url);
|
||||
}
|
||||
|
||||
export async function openSavedViewByKey(
|
||||
viewKey: string,
|
||||
sourceHint: SavedViewSourceHint | null | undefined,
|
||||
history: History,
|
||||
): Promise<void> {
|
||||
const view = await loadSavedView(viewKey, sourceHint);
|
||||
openSavedView(view, history);
|
||||
}
|
||||
|
||||
/** @deprecated Use findSavedViewInLists — kept for tests. */
|
||||
export const findSavedView = findSavedViewInLists;
|
||||
@@ -1,203 +0,0 @@
|
||||
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
SavedViewEntityDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { ResourceType } from './resourceRoute';
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/** Normalises backend resource-type strings to the taxonomy used in the UI. */
|
||||
export function normalizeResourceType(
|
||||
resourceType: string | null | undefined,
|
||||
): string | null {
|
||||
if (!resourceType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = resourceType.trim().toLowerCase().replace(/-/g, '_');
|
||||
if (normalized === 'savedview') {
|
||||
return ResourceType.saved_view;
|
||||
}
|
||||
if (
|
||||
normalized === 'notification_channel' ||
|
||||
normalized === 'notificationchannel'
|
||||
) {
|
||||
return ResourceType.channel;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Reads a resource type from the action envelope or its `input` payload. */
|
||||
export function resolveResourceType(action: MessageActionDTO): string | null {
|
||||
const direct = normalizeResourceType(action.resourceType);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const input = action.input;
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizeResourceType(readString(input.resourceType)) ??
|
||||
normalizeResourceType(readString(input.type))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the resource type for an `open_resource` action, including label-based
|
||||
* fallbacks when the backend only sends a display label + id.
|
||||
*/
|
||||
export function resolveOpenResourceType(
|
||||
action: MessageActionDTO,
|
||||
): string | null {
|
||||
const fromFields = resolveResourceType(action);
|
||||
if (fromFields) {
|
||||
return fromFields;
|
||||
}
|
||||
|
||||
if (/open\s+channel/i.test(action.label) && resolveResourceId(action)) {
|
||||
return ResourceType.channel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reads a resource id from `resourceId` or common `input` keys. */
|
||||
export function resolveResourceId(action: MessageActionDTO): string | null {
|
||||
const direct = readString(action.resourceId);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const input = action.input;
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'resourceId',
|
||||
'viewId',
|
||||
'viewKey',
|
||||
'channelId',
|
||||
'id',
|
||||
] as const) {
|
||||
const value = readString(input[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reads `entity` from the action envelope or its `input` payload. */
|
||||
export function resolveActionEntity(
|
||||
action: MessageActionDTO,
|
||||
): SavedViewEntityDTO | null {
|
||||
if (action.entity) {
|
||||
return action.entity;
|
||||
}
|
||||
|
||||
const fromInput = readString(action.input?.entity);
|
||||
if (!fromInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeToSavedViewEntity(fromInput);
|
||||
}
|
||||
|
||||
function normalizeToSavedViewEntity(value: string): SavedViewEntityDTO | null {
|
||||
const source = entityToDataSource(value);
|
||||
switch (source) {
|
||||
case DataSource.LOGS:
|
||||
return SavedViewEntityDTO.logs;
|
||||
case DataSource.TRACES:
|
||||
return SavedViewEntityDTO.traces;
|
||||
case DataSource.METRICS:
|
||||
return SavedViewEntityDTO.metrics;
|
||||
case 'meter':
|
||||
return SavedViewEntityDTO.meter;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an action `entity` to an explorer `DataSource` for saved-view lookups.
|
||||
* Accepts both short (`logs`) and taxonomy (`logs_explorer`) values.
|
||||
*/
|
||||
export function entityToDataSource(
|
||||
entity: SavedViewEntityDTO | string,
|
||||
): DataSource | 'meter' | null {
|
||||
const normalized = entity.trim().toLowerCase().replace(/-/g, '_');
|
||||
|
||||
switch (normalized) {
|
||||
case SavedViewEntityDTO.logs:
|
||||
case ResourceType.logs_explorer:
|
||||
return DataSource.LOGS;
|
||||
case SavedViewEntityDTO.traces:
|
||||
case ResourceType.traces_explorer:
|
||||
return DataSource.TRACES;
|
||||
case SavedViewEntityDTO.metrics:
|
||||
case ResourceType.metrics_explorer:
|
||||
return DataSource.METRICS;
|
||||
case SavedViewEntityDTO.meter:
|
||||
return 'meter';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks which explorer source page to search when resolving a saved view.
|
||||
* Prefers `entity` (open_resource); falls back to `signal` only for legacy payloads.
|
||||
*/
|
||||
export function resolveSavedViewSourceHint(
|
||||
action: MessageActionDTO,
|
||||
): DataSource | 'meter' | null {
|
||||
const entity = resolveActionEntity(action);
|
||||
if (entity) {
|
||||
const fromEntity = entityToDataSource(entity);
|
||||
if (fromEntity) {
|
||||
return fromEntity;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.signal) {
|
||||
switch (action.signal) {
|
||||
case ApplyFilterSignalDTO.logs:
|
||||
return DataSource.LOGS;
|
||||
case ApplyFilterSignalDTO.traces:
|
||||
return DataSource.TRACES;
|
||||
case ApplyFilterSignalDTO.metrics:
|
||||
return DataSource.METRICS;
|
||||
default: {
|
||||
const _exhaustive: never = action.signal;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSavedViewOpenAction(action: MessageActionDTO): boolean {
|
||||
if (resolveResourceType(action) === ResourceType.saved_view) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Defensive: some agent payloads only set a human label + id in `input`.
|
||||
return /open\s+view/i.test(action.label) && resolveResourceId(action) !== null;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
/**
|
||||
* Resource-type strings the backend uses for `open_resource` and rollback
|
||||
* actions. Centralized here so route/module lookups stay in sync.
|
||||
*/
|
||||
export const ResourceType = {
|
||||
dashboard: 'dashboard',
|
||||
alert: 'alert',
|
||||
service: 'service',
|
||||
channel: 'channel',
|
||||
saved_view: 'saved_view',
|
||||
logs_explorer: 'logs_explorer',
|
||||
traces_explorer: 'traces_explorer',
|
||||
metrics_explorer: 'metrics_explorer',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolves an `open_resource` action to an in-app route for synchronous
|
||||
* navigation. Returns `null` for `saved_view` — callers must load the view
|
||||
* by id and navigate with query-builder state instead.
|
||||
*/
|
||||
export function resourceRoute(
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
): string | null {
|
||||
switch (resourceType) {
|
||||
case ResourceType.dashboard:
|
||||
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
|
||||
case ResourceType.alert: {
|
||||
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
|
||||
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
|
||||
}
|
||||
case ResourceType.service:
|
||||
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
|
||||
case ResourceType.channel:
|
||||
return ROUTES.CHANNELS_EDIT.replace(':channelId', resourceId);
|
||||
case ResourceType.saved_view:
|
||||
return null;
|
||||
case ResourceType.logs_explorer:
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case ResourceType.traces_explorer:
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
case ResourceType.metrics_explorer:
|
||||
return ROUTES.METRICS_EXPLORER_EXPLORER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,6 @@ function autoContextLabel(ctx: MessageContext): string {
|
||||
return 'Panel (fullscreen)';
|
||||
case 'dashboard_list':
|
||||
return 'Dashboards';
|
||||
case 'alert_detail':
|
||||
return 'Current alert';
|
||||
case 'alert_edit':
|
||||
return 'Editing alert';
|
||||
case 'alert_new':
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
type MarkdownExternalLinkProps = ComponentProps<'a'> & {
|
||||
// react-markdown passes `node` — accept and ignore it
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
node?: any;
|
||||
};
|
||||
|
||||
export default function MarkdownExternalLink({
|
||||
href,
|
||||
children,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownExternalLinkProps): JSX.Element {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="ai-markdown-link"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { Message, MessageBlock } from '../../types';
|
||||
import ActionsSection from '../ActionsSection';
|
||||
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import MarkdownExternalLink from '../MarkdownExternalLink/MarkdownExternalLink';
|
||||
import { MessageContext } from '../MessageContext';
|
||||
import MessageFeedback from '../MessageFeedback';
|
||||
import UserMessageActions from '../UserMessageActions';
|
||||
@@ -38,11 +37,7 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = {
|
||||
code: RichCodeBlock,
|
||||
pre: SmartPre,
|
||||
a: MarkdownExternalLink,
|
||||
};
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
font-size: 10px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
padding-left: 2px;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { StreamingEventItem } from '../../types';
|
||||
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
|
||||
import ApprovalCard from '../ApprovalCard';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import MarkdownExternalLink from '../MarkdownExternalLink/MarkdownExternalLink';
|
||||
import ClarificationForm from '../ClarificationForm';
|
||||
|
||||
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
|
||||
@@ -31,11 +30,7 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = {
|
||||
code: RichCodeBlock,
|
||||
pre: SmartPre,
|
||||
a: MarkdownExternalLink,
|
||||
};
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
.emptySuggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
@@ -53,8 +53,11 @@
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
width: 100%;
|
||||
.emptyChip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start !important;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
@@ -63,16 +66,21 @@
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--l3-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import {
|
||||
Activity,
|
||||
TriangleAlert,
|
||||
ChartBar,
|
||||
Search,
|
||||
Zap,
|
||||
} from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -12,7 +20,29 @@ import MessageBubble from '../MessageBubble';
|
||||
import StreamingMessage from '../StreamingMessage';
|
||||
|
||||
import styles from './VirtualizedMessages.module.scss';
|
||||
import { useEmptyStateChips } from './useEmptyStateChips';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
icon: TriangleAlert,
|
||||
text: 'Show me the top errors in the last hour',
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
text: 'What services have the highest latency?',
|
||||
},
|
||||
{
|
||||
icon: ChartBar,
|
||||
text: 'Give me an overview of system health',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
text: 'Find slow database queries',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
text: 'Which endpoints have the most 5xx errors?',
|
||||
},
|
||||
];
|
||||
|
||||
const EMPTY_EVENTS: StreamingEventItem[] = [];
|
||||
|
||||
@@ -143,10 +173,8 @@ export default function VirtualizedMessages({
|
||||
|
||||
const showStreamingSlot =
|
||||
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
|
||||
const isEmptyState = messages.length === 0 && !showStreamingSlot;
|
||||
const { chips: emptyStateChips } = useEmptyStateChips(isEmptyState);
|
||||
|
||||
if (isEmptyState) {
|
||||
if (messages.length === 0 && !showStreamingSlot) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<div className={`${styles.emptyIcon} noz-wave`}>
|
||||
@@ -156,22 +184,24 @@ export default function VirtualizedMessages({
|
||||
<p className={styles.emptySubtitle}>
|
||||
Ask questions about your traces, logs, metrics, and infrastructure.
|
||||
</p>
|
||||
<div className={styles.suggestions}>
|
||||
{emptyStateChips.map((chip) => (
|
||||
<div
|
||||
key={chip.id}
|
||||
className={styles.suggestion}
|
||||
<div className={styles.emptySuggestions}>
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<Button
|
||||
key={s.text}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.emptyChip}
|
||||
onClick={(): void => {
|
||||
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
|
||||
promptId: chip.id,
|
||||
promptId: s.text,
|
||||
category: SuggestedPromptCategory.EmptyState,
|
||||
});
|
||||
onSendSuggestedPrompt(chip.text);
|
||||
onSendSuggestedPrompt(s.text);
|
||||
}}
|
||||
data-testid={`empty-state-chip-${chip.id}`}
|
||||
prefix={<s.icon size={14} />}
|
||||
>
|
||||
{chip.text}
|
||||
</div>
|
||||
{s.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { ChipDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
/** Static empty-state chips used when the contextual chips API is unavailable. */
|
||||
export const EMPTY_STATE_CHIPS_FALLBACK: ChipDTO[] = [
|
||||
{
|
||||
id: 'top_errors_last_hour',
|
||||
text: 'Show me the top errors in the last hour',
|
||||
},
|
||||
{
|
||||
id: 'highest_latency_services',
|
||||
text: 'What services have the highest latency?',
|
||||
},
|
||||
{
|
||||
id: 'system_health_overview',
|
||||
text: 'Give me an overview of system health',
|
||||
},
|
||||
{
|
||||
id: 'slow_database_queries',
|
||||
text: 'Find slow database queries',
|
||||
},
|
||||
{
|
||||
id: 'endpoints_5xx_errors',
|
||||
text: 'Which endpoints have the most 5xx errors?',
|
||||
},
|
||||
];
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { getEmptyStateChips } from 'api/ai-assistant/chat';
|
||||
import type { ChipDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { useResolvePageType } from 'hooks/aiAssistant/useResolvePageType';
|
||||
|
||||
import { EMPTY_STATE_CHIPS_FALLBACK } from './emptyStateChipsFallback';
|
||||
|
||||
interface UseEmptyStateChipsResult {
|
||||
chips: ChipDTO[];
|
||||
}
|
||||
|
||||
export function useEmptyStateChips(enabled: boolean): UseEmptyStateChipsResult {
|
||||
const pageType = useResolvePageType();
|
||||
|
||||
const { data, isError } = useQuery(
|
||||
[REACT_QUERY_KEY.AI_ASSISTANT_EMPTY_STATE_CHIPS, pageType],
|
||||
({ signal }) => getEmptyStateChips(pageType, signal),
|
||||
{ enabled },
|
||||
);
|
||||
|
||||
const chips = useMemo(() => {
|
||||
if (isError) {
|
||||
return EMPTY_STATE_CHIPS_FALLBACK;
|
||||
}
|
||||
return data ?? [];
|
||||
}, [data, isError]);
|
||||
|
||||
return { chips };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
@@ -100,30 +99,6 @@ export function getAutoContexts(
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Alert detail (overview / per-rule history) — `/alerts/overview?ruleId=…`
|
||||
// or `/alerts/history?ruleId=…`. Mirrors dashboard_detail: resourceId is the
|
||||
// rule id and shared metadata carries the URL time range when present.
|
||||
if (
|
||||
matchPath(pathname, { path: ROUTES.ALERT_OVERVIEW, exact: true }) ||
|
||||
matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })
|
||||
) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
if (ruleId) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_detail',
|
||||
ruleId,
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Alert edit — `/alerts/edit?ruleId=…`.
|
||||
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
@@ -133,7 +108,7 @@ export function getAutoContexts(
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: { page: 'alert_edit', ruleId },
|
||||
metadata: { page: 'alert_edit' },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -150,7 +125,6 @@ export function getAutoContexts(
|
||||
];
|
||||
}
|
||||
|
||||
// Triggered-alerts index — `/alerts/history` without a rule id.
|
||||
if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) {
|
||||
return [
|
||||
{
|
||||
@@ -165,18 +139,13 @@ export function getAutoContexts(
|
||||
];
|
||||
}
|
||||
|
||||
// Alerts index — `/alerts` with tab query param (defaults to Alert Rules).
|
||||
if (matchPath(pathname, { path: ROUTES.LIST_ALL_ALERT, exact: true })) {
|
||||
const page = resolveAlertsIndexPage(params.get(QueryParams.tab));
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page,
|
||||
...(page === 'alerts_triggered' ? sharedMetadata : {}),
|
||||
},
|
||||
metadata: { page: 'alert_list' },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -282,9 +251,8 @@ export function getAutoContexts(
|
||||
|
||||
// ── Metrics ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Metrics explorer — `/metrics-explorer` and sub-routes (summary, explorer, views).
|
||||
if (
|
||||
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_BASE, exact: false })
|
||||
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_EXPLORER, exact: false })
|
||||
) {
|
||||
return [
|
||||
{
|
||||
@@ -299,25 +267,9 @@ export function getAutoContexts(
|
||||
];
|
||||
}
|
||||
|
||||
// NOTE: Homepage (`/home`) and infrastructure monitoring
|
||||
// (`/infrastructure-monitoring/*`) intentionally emit no auto-context here.
|
||||
// They have no resource that maps to `MessageContextDTOType`, so attaching
|
||||
// a chip would misrepresent the page (e.g. a bogus "metrics_explorer"
|
||||
// context). Their `page_type` for empty-state chips is resolved directly
|
||||
// from the route in `resolvePageType`.
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
type AlertsIndexPage = 'alert_list' | 'alerts_triggered';
|
||||
|
||||
function resolveAlertsIndexPage(tab: string | null): AlertsIndexPage {
|
||||
if (tab === AlertListTabs.TRIGGERED_ALERTS) {
|
||||
return 'alerts_triggered';
|
||||
}
|
||||
return 'alert_list';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls metadata fields that any page may carry in its query string —
|
||||
* `timeRange`, `query`, saved-view selectors, dashboard variables. Each
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { PageTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
import { getAutoContexts } from './getAutoContexts';
|
||||
|
||||
const PAGE_METADATA_TO_DTO: Record<string, PageTypeDTO> = {
|
||||
dashboard_detail: PageTypeDTO.dashboard_detail,
|
||||
dashboard_list: PageTypeDTO.dashboard_list,
|
||||
panel_edit: PageTypeDTO.panel_edit,
|
||||
panel_fullscreen: PageTypeDTO.panel_fullscreen,
|
||||
logs_explorer: PageTypeDTO.logs_explorer,
|
||||
trace_detail: PageTypeDTO.trace_detail,
|
||||
traces_explorer: PageTypeDTO.traces_explorer,
|
||||
metrics_explorer: PageTypeDTO.metrics_explorer,
|
||||
service_detail: PageTypeDTO.service_detail,
|
||||
services_list: PageTypeDTO.services_list,
|
||||
alert_edit: PageTypeDTO.alert_edit,
|
||||
alert_list: PageTypeDTO.alert_list,
|
||||
alert_new: PageTypeDTO.alert_new,
|
||||
alerts_triggered: PageTypeDTO.alerts_triggered,
|
||||
};
|
||||
|
||||
interface ResolvePageTypeOptions {
|
||||
/** Standalone `/ai-assistant` surface — no underlying observability page. */
|
||||
isStandaloneAssistant?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the current URL (and assistant surface) to the backend `page_type`
|
||||
* enum used by contextual empty-state chips.
|
||||
*/
|
||||
export function resolvePageType(
|
||||
pathname: string,
|
||||
search: string,
|
||||
options?: ResolvePageTypeOptions,
|
||||
): PageTypeDTO {
|
||||
if (options?.isStandaloneAssistant) {
|
||||
return PageTypeDTO.other;
|
||||
}
|
||||
|
||||
// Pseudo-pages with no attachable resource: resolved straight from the
|
||||
// route. They deliberately emit no auto-context chip (see `getAutoContexts`),
|
||||
// so they can't be derived from `metadata.page` like the pages below.
|
||||
if (matchPath(pathname, { path: ROUTES.HOME, exact: true })) {
|
||||
return PageTypeDTO.homepage;
|
||||
}
|
||||
if (
|
||||
matchPath(pathname, {
|
||||
path: ROUTES.INFRASTRUCTURE_MONITORING_BASE,
|
||||
exact: false,
|
||||
})
|
||||
) {
|
||||
return PageTypeDTO.infra_entity_detail;
|
||||
}
|
||||
|
||||
const contexts = getAutoContexts(pathname, search);
|
||||
const page = contexts[0]?.metadata?.page;
|
||||
if (typeof page === 'string') {
|
||||
if (page === 'logs_explorer') {
|
||||
const activeLogId = new URLSearchParams(search).get(QueryParams.activeLogId);
|
||||
if (activeLogId) {
|
||||
return PageTypeDTO.log_detail;
|
||||
}
|
||||
}
|
||||
const mapped = PAGE_METADATA_TO_DTO[page];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
return PageTypeDTO.other;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
|
||||
import type {
|
||||
ErrorResponseDTO,
|
||||
MessageActionDTO,
|
||||
MessageSummaryDTOBlocksAnyOfItem,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
@@ -35,7 +37,6 @@ import {
|
||||
MessageBlock,
|
||||
MessageRole,
|
||||
} from '../types';
|
||||
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types used by module-level helpers
|
||||
@@ -398,7 +399,6 @@ async function runStreamingLoop(
|
||||
}
|
||||
throw Object.assign(new Error(event.error.message), {
|
||||
retryAction: event.retryAction,
|
||||
code: event.error.code,
|
||||
});
|
||||
} else if (event.type === 'conversation' && event.title) {
|
||||
set((s) => {
|
||||
@@ -484,6 +484,36 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
|
||||
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
|
||||
}
|
||||
|
||||
function parseErrorBody(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return parseErrorBody(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const message = (value as ErrorResponseDTO | undefined)?.error?.message;
|
||||
return typeof message === 'string' && message.length > 0 ? message : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the backend's `error.message` when `err` is a 429 axios response
|
||||
* (typically from the threads API surface — createThread, sendMessage, approve,
|
||||
* clarify, regenerate). Returns null for any other error so callers fall
|
||||
* through to their generic copy.
|
||||
*/
|
||||
function rateLimitMessage(err: unknown): string | null {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 429) {
|
||||
return parseErrorBody(err.response.data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits an error message and removes the stream entry. When `isRateLimit`
|
||||
* is true, the committed message is flagged so the feedback/regenerate bar
|
||||
* is hidden — clicking regenerate would just 429 again.
|
||||
*/
|
||||
function finalizeStreamingError(
|
||||
conversationId: string,
|
||||
errorContent: string,
|
||||
@@ -1144,11 +1174,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
convId,
|
||||
rateLimit ??
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
finalizeStreamingError(convId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1181,11 +1214,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] approveAction failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1260,11 +1296,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1326,11 +1365,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] submitClarification failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorCodeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { resolveAssistantErrorMessage } from '../resolveAssistantErrorMessage';
|
||||
|
||||
const FALLBACK = 'Something went wrong. Please try again.';
|
||||
|
||||
describe('resolveAssistantErrorMessage', () => {
|
||||
it('returns backend message for a known error code', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.thread_busy,
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when error code is not in ErrorCodeDTO', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Backend-only message',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks HTTP 429 responses as rate limited', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.hourly_message_limit,
|
||||
message: 'Hourly limit reached.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Hourly limit reached.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses backend message for known SSE rate-limit error codes', () => {
|
||||
const err = Object.assign(new Error('Daily token limit exceeded.'), {
|
||||
code: ErrorCodeDTO.daily_token_limit,
|
||||
});
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Daily token limit exceeded.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks 429 as rate limited even when error code is unknown', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Too many requests',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
type ErrorBodyDTO,
|
||||
type ErrorResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
export interface AssistantErrorResolution {
|
||||
message: string;
|
||||
isRateLimit: boolean;
|
||||
}
|
||||
|
||||
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
|
||||
return (
|
||||
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
|
||||
);
|
||||
}
|
||||
|
||||
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
|
||||
ErrorCodeDTO.thread_message_limit,
|
||||
ErrorCodeDTO.connection_limit_exceeded,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
ErrorCodeDTO.daily_message_limit,
|
||||
ErrorCodeDTO.daily_token_limit,
|
||||
ErrorCodeDTO.daily_cost_limit,
|
||||
ErrorCodeDTO.budget_exceeded,
|
||||
]);
|
||||
|
||||
function isRateLimitError(code: string | undefined, err: unknown): boolean {
|
||||
if (isAxiosError(err) && err.response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isErrorCodeDTO(code) && RATE_LIMIT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
function getErrorBody(err: unknown): ErrorBodyDTO | null {
|
||||
if (isAxiosError(err)) {
|
||||
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
|
||||
}
|
||||
|
||||
const code = (err as { code?: string } | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (!code || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code: code as ErrorCodeDTO, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses `error.message` when `error.code` is a known `ErrorCodeDTO`;
|
||||
* otherwise returns `fallback`.
|
||||
*/
|
||||
export function resolveAssistantErrorMessage(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): AssistantErrorResolution {
|
||||
const body = getErrorBody(err);
|
||||
const isRateLimit = isRateLimitError(body?.code, err);
|
||||
|
||||
if (body && isErrorCodeDTO(body.code) && body.message.trim()) {
|
||||
return {
|
||||
message: body.message.trim(),
|
||||
isRateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: fallback, isRateLimit: Boolean(isRateLimit) };
|
||||
}
|
||||
@@ -29,7 +29,3 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
body.ai-assistant-panel-open .create-alert-v2-footer {
|
||||
right: var(--ai-assistant-panel-width, 380px);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,5 @@
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import PieArc from './PieArc';
|
||||
import PieCenterLabel from './PieCenterLabel';
|
||||
import styles from './Pie.module.scss';
|
||||
import { PieTooltipData } from './types';
|
||||
import { getDonutGeometry, getFillColor } from './utils';
|
||||
import { getFillColor } from './utils';
|
||||
|
||||
/**
|
||||
* Donut chart rendered with @visx. Splits its area into chart + legend with the
|
||||
@@ -78,12 +78,16 @@ export default function Pie({
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
|
||||
// Donut geometry derived from the allocated chart box, sized to leave room
|
||||
// for the external leader labels (see getDonutGeometry).
|
||||
const { size, radius, innerRadius } = useMemo(
|
||||
() => getDonutGeometry(width, height),
|
||||
[width, height],
|
||||
);
|
||||
// Donut geometry derived from the allocated chart box.
|
||||
const { size, radius, innerRadius } = useMemo(() => {
|
||||
const nextSize = Math.min(width, height);
|
||||
const nextRadius = nextSize * 0.35;
|
||||
return {
|
||||
size: nextSize,
|
||||
radius: nextRadius,
|
||||
innerRadius: nextRadius * 0.6,
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
const totalValue = useMemo(
|
||||
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
import {
|
||||
getArcGeometry,
|
||||
getDonutGeometry,
|
||||
getFillColor,
|
||||
getScaledFontSize,
|
||||
lightenColor,
|
||||
} from '../utils';
|
||||
|
||||
describe('Pie utils', () => {
|
||||
describe('getDonutGeometry', () => {
|
||||
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
|
||||
const { radius } = getDonutGeometry(400, 300);
|
||||
const half = Math.min(400, 300) / 2; // 150
|
||||
// The label anchor sits at radius * 1.3 and must stay within the box
|
||||
// half-extent so labels are not clipped.
|
||||
expect(radius * 1.3).toBeLessThanOrEqual(half);
|
||||
// And it should use the available room (anchor = half - 22 allowance).
|
||||
expect(radius * 1.3).toBeCloseTo(half - 22);
|
||||
});
|
||||
|
||||
it('derives size and inner radius from the outer radius', () => {
|
||||
const { size, radius, innerRadius } = getDonutGeometry(300, 300);
|
||||
expect(size).toBeCloseTo(radius * 2);
|
||||
expect(innerRadius).toBeCloseTo(radius * 0.6);
|
||||
});
|
||||
|
||||
it('sizes off the smaller dimension so it fits both axes', () => {
|
||||
expect(getDonutGeometry(1000, 200)).toStrictEqual(
|
||||
getDonutGeometry(200, 1000),
|
||||
);
|
||||
});
|
||||
|
||||
it('never returns a negative radius for a box too small for labels', () => {
|
||||
expect(getDonutGeometry(20, 20).radius).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScaledFontSize', () => {
|
||||
it('returns the base size for empty text', () => {
|
||||
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(
|
||||
|
||||
@@ -10,16 +10,6 @@ export interface ScaledFontSizeArgs {
|
||||
innerRadius: number;
|
||||
}
|
||||
|
||||
/** Donut sizing for a given chart box: the outer/inner radii and the square it spans. */
|
||||
export interface DonutGeometry {
|
||||
/** Outer diameter — feeds the visx Pie width/height and the render guard. */
|
||||
size: number;
|
||||
/** Outer radius of the donut ring. */
|
||||
radius: number;
|
||||
/** Inner radius (the hole) — also bounds the centre-total font. */
|
||||
innerRadius: number;
|
||||
}
|
||||
|
||||
export interface ArcGeometry {
|
||||
/** Outer point where the leader label sits. */
|
||||
labelX: number;
|
||||
|
||||
@@ -3,37 +3,7 @@
|
||||
* so the renderer stays declarative (per the one-component-per-file rule).
|
||||
*/
|
||||
|
||||
import {
|
||||
ArcGeometry,
|
||||
DonutGeometry,
|
||||
ParsedRgb,
|
||||
ScaledFontSizeArgs,
|
||||
} from './types';
|
||||
|
||||
// Leader-line + two-line label/value drawn outside the donut. `getArcGeometry`
|
||||
// anchors the label at `radius * LABEL_RADIUS_RATIO`; `LABEL_TEXT_ALLOWANCE` is
|
||||
// the px reserved beyond that anchor for the (10px, two-line) text so it never
|
||||
// clips against the SVG edge.
|
||||
const LABEL_RADIUS_RATIO = 1.3;
|
||||
const LABEL_TEXT_ALLOWANCE = 22;
|
||||
const INNER_RADIUS_RATIO = 0.6;
|
||||
|
||||
/**
|
||||
* Sizes the donut to fit inside a `width × height` box *with room for the
|
||||
* external leader labels*. The label anchor sits at `radius * 1.3`, so we solve
|
||||
* the outer radius back from the box's half-extent minus the text allowance —
|
||||
* guaranteeing the labels stay inside the SVG instead of being clipped (V1 used
|
||||
* a flat `0.35 * min(w,h)`, which left too little margin on small panels).
|
||||
*/
|
||||
export function getDonutGeometry(width: number, height: number): DonutGeometry {
|
||||
const half = Math.min(width, height) / 2;
|
||||
const radius = Math.max(0, (half - LABEL_TEXT_ALLOWANCE) / LABEL_RADIUS_RATIO);
|
||||
return {
|
||||
size: radius * 2,
|
||||
radius,
|
||||
innerRadius: radius * INNER_RADIUS_RATIO,
|
||||
};
|
||||
}
|
||||
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
|
||||
|
||||
/**
|
||||
* Shrinks the centre-total font as the text gets longer so it never overflows
|
||||
@@ -67,7 +37,7 @@ export function getArcGeometry(
|
||||
radius: number,
|
||||
): ArcGeometry {
|
||||
const angle = (startAngle + endAngle) / 2;
|
||||
const labelRadius = radius * LABEL_RADIUS_RATIO;
|
||||
const labelRadius = radius * 1.3;
|
||||
const lineEndRadius = radius * 1.1;
|
||||
return {
|
||||
labelX: Math.sin(angle) * labelRadius,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { calculateChartDimensions } from '../utils';
|
||||
|
||||
const labels = (count: number, length = 20): string[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
`label-${i}`.padEnd(length, 'x').slice(0, length),
|
||||
);
|
||||
|
||||
describe('calculateChartDimensions', () => {
|
||||
it('returns all zeros when the container has no space', () => {
|
||||
expect(
|
||||
calculateChartDimensions({
|
||||
containerWidth: 0,
|
||||
containerHeight: 300,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
}),
|
||||
).toStrictEqual({
|
||||
width: 0,
|
||||
height: 0,
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
|
||||
expect(dims.legendWidth).toBe(240);
|
||||
expect(dims.width).toBe(760);
|
||||
expect(dims.height).toBe(400);
|
||||
expect(dims.legendHeight).toBe(400);
|
||||
});
|
||||
|
||||
it('BOTTOM: a single row of items reserves one legend row', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
});
|
||||
// One row = line height (28) + padding (12).
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(460);
|
||||
expect(dims.legendWidth).toBe(1000);
|
||||
});
|
||||
|
||||
it('BOTTOM: many items cap at two rows on a tall container', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
|
||||
expect(dims.legendHeight).toBe(68);
|
||||
expect(dims.height).toBe(432);
|
||||
});
|
||||
|
||||
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 160,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Without the height-relative cap the legend would take 68px of a 160px
|
||||
// panel and the chart (pie especially) collapses to a sliver.
|
||||
expect(dims.legendHeight).toBe(48); // 30% of 160
|
||||
expect(dims.height).toBe(112);
|
||||
});
|
||||
});
|
||||
@@ -116,15 +116,7 @@ export function calculateChartDimensions({
|
||||
? legendRowCount * legendRowHeight - LEGEND_PADDING
|
||||
: legendRowHeight;
|
||||
|
||||
// Cap at two rows / 80px, and never more than 30% of the container height
|
||||
// (the doc above always promised the %-cap; without it, short grid panels
|
||||
// hand most of their area to the legend and the chart — the pie donut
|
||||
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
|
||||
const maxAllowedLegendHeight = Math.min(
|
||||
2 * legendRowHeight,
|
||||
80,
|
||||
Math.floor(containerHeight * 0.3),
|
||||
);
|
||||
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
|
||||
|
||||
const bottomLegendHeight = Math.min(
|
||||
idealBottomLegendHeight,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
|
||||
interface AttributeMappingHeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function AttributeMappingHeader({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>Attribute Mapping</h1>
|
||||
<p className={styles.description}>
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.pageHeaderActions}>
|
||||
{isDirty && (
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="discard-changes-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="save-changes-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingHeader;
|
||||
@@ -1,41 +0,0 @@
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import MapperGroupsTable from './MapperGroupsTable';
|
||||
import { DraftGroup } from './types';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
|
||||
interface AttributeMappingsTabProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
// "Attribute mappings" tab: the mapping-groups listing, its error state and
|
||||
// footer summary. The store is owned by the container (the header's save/
|
||||
// discard share it), so it's passed in rather than created here.
|
||||
function AttributeMappingsTab({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: AttributeMappingsTabProps): JSX.Element {
|
||||
return (
|
||||
<div data-testid="attribute-mappings-tab">
|
||||
{store.isError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapperGroupsTable
|
||||
store={store}
|
||||
onEditGroup={onEditGroup}
|
||||
onAddGroup={onAddGroup}
|
||||
/>
|
||||
|
||||
<footer className={styles.pageFooter}>
|
||||
Showing {store.groups.length} group{store.groups.length === 1 ? '' : 's'}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingsTab;
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import styles from './GroupFormDrawer.module.scss';
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import { FieldContextValue } from './types';
|
||||
|
||||
interface ConditionKeyListProps {
|
||||
label: string;
|
||||
labelHint?: string;
|
||||
keys: string[];
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
testIdPrefix: string;
|
||||
fieldContext: FieldContextValue;
|
||||
onChange: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
// Editor for one list of condition keys (the group's span-attribute or
|
||||
// resource gating keys). Substring "contains" match, order irrelevant.
|
||||
function ConditionKeyList({
|
||||
label,
|
||||
labelHint,
|
||||
keys,
|
||||
placeholder,
|
||||
addLabel,
|
||||
testIdPrefix,
|
||||
fieldContext,
|
||||
onChange,
|
||||
}: ConditionKeyListProps): JSX.Element {
|
||||
const updateKey = (index: number, value: string): void => {
|
||||
onChange(keys.map((key, i) => (i === index ? value : key)));
|
||||
};
|
||||
|
||||
const addKey = (): void => {
|
||||
onChange([...keys, '']);
|
||||
};
|
||||
|
||||
const removeKey = (index: number): void => {
|
||||
onChange(keys.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.groupFormField}>
|
||||
<span className={styles.groupFormLabel}>
|
||||
{label}
|
||||
{labelHint && (
|
||||
<span className={styles.groupFormLabelHint}> {labelHint}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{keys.length > 0 && (
|
||||
<div className={styles.groupFormKeys}>
|
||||
{keys.map((key, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className={styles.groupFormKey} key={index}>
|
||||
<KeySearchInput
|
||||
className={styles.groupFormKeyInput}
|
||||
placeholder={placeholder}
|
||||
value={key}
|
||||
fieldContext={fieldContext}
|
||||
onChange={(next): void => updateKey(index, next)}
|
||||
testId={`${testIdPrefix}-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove key"
|
||||
onClick={(): void => removeKey(index)}
|
||||
testId={`${testIdPrefix}-remove-${index}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={addKey}
|
||||
testId={`${testIdPrefix}-add`}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConditionKeyList;
|
||||
@@ -1,76 +0,0 @@
|
||||
.groupForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
padding: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
.groupFormField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormFieldRow {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.groupFormLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupFormLabelHint {
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.groupFormHint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupFormKeys {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormKey {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.groupFormKeyInput {
|
||||
flex: 1;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.groupFormError {
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.groupFormFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormFooterActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
|
||||
import ConditionKeyList from './ConditionKeyList';
|
||||
import styles from './GroupFormDrawer.module.scss';
|
||||
import { FieldContext, GroupDraft, MapperDraftMode } from './types';
|
||||
import { isGroupDraftValid } from './utils';
|
||||
|
||||
interface GroupFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function GroupFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: GroupFormDrawerProps): JSX.Element {
|
||||
const isEdit = mode === 'edit';
|
||||
const isValid = isGroupDraftValid(draft);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isEdit ? 'Edit group' : 'New group'}
|
||||
subTitle="A group gates which spans its mappings run on"
|
||||
width="wide"
|
||||
testId="group-form-drawer"
|
||||
footer={
|
||||
<div className={styles.groupFormFooter}>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
testId="group-form-delete"
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.groupFormFooterActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="group-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid || isSaving}
|
||||
testId="group-form-save"
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSaving ? 'Saving…' : isEdit ? 'Save group' : 'Create group'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.groupForm}>
|
||||
<div className={styles.groupFormField}>
|
||||
<span className={styles.groupFormLabel}>Group name</span>
|
||||
<Input
|
||||
placeholder="e.g. OpenAI gateway"
|
||||
value={draft.name}
|
||||
onChange={(event): void =>
|
||||
setDraft({ ...draft, name: event.target.value })
|
||||
}
|
||||
testId="group-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.groupFormField} ${styles.groupFormFieldRow}`}>
|
||||
<span className={styles.groupFormLabel}>Enabled</span>
|
||||
<Switch
|
||||
value={draft.enabled}
|
||||
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
|
||||
testId="group-form-enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConditionKeyList
|
||||
label="Condition · span attribute keys"
|
||||
labelHint="· runs when a span attribute key contains any of these"
|
||||
keys={draft.attributes}
|
||||
placeholder="e.g. gen_ai."
|
||||
addLabel="Add attribute key"
|
||||
testIdPrefix="group-form-attribute"
|
||||
fieldContext={FieldContext.attribute}
|
||||
onChange={(attributes): void => setDraft({ ...draft, attributes })}
|
||||
/>
|
||||
|
||||
<ConditionKeyList
|
||||
label="Condition · resource keys"
|
||||
labelHint="· or when a resource key contains any of these"
|
||||
keys={draft.resource}
|
||||
placeholder="e.g. service.name"
|
||||
addLabel="Add resource key"
|
||||
testIdPrefix="group-form-resource"
|
||||
fieldContext={FieldContext.resource}
|
||||
onChange={(resource): void => setDraft({ ...draft, resource })}
|
||||
/>
|
||||
|
||||
<span className={styles.groupFormHint}>
|
||||
Leave both empty to run this group on every span.
|
||||
</span>
|
||||
|
||||
{saveError && (
|
||||
<div className={styles.groupFormError} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupFormDrawer;
|
||||
@@ -1,51 +0,0 @@
|
||||
.keySearch {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keySearchDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-2));
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
max-width: 420px;
|
||||
max-height: 240px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.keySearchDropdownEmpty {
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.keySearchOption {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import Spinner from 'components/Spinner';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import styles from './KeySearchInput.module.scss';
|
||||
import { FieldContext, FieldContextValue } from './types';
|
||||
|
||||
const SUGGESTION_LIMIT = 50;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
interface KeySearchInputProps {
|
||||
value: string;
|
||||
fieldContext: FieldContextValue;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// Maps the mapper's attribute/resource context to the fields-endpoint context.
|
||||
function toFieldsContext(
|
||||
context: FieldContextValue,
|
||||
): TelemetrytypesFieldContextDTO {
|
||||
return context === FieldContext.resource
|
||||
? TelemetrytypesFieldContextDTO.resource
|
||||
: TelemetrytypesFieldContextDTO.attribute;
|
||||
}
|
||||
|
||||
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
|
||||
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
|
||||
function KeySearchInput({
|
||||
value,
|
||||
fieldContext,
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
testId,
|
||||
onChange,
|
||||
}: KeySearchInputProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
fieldContext: toFieldsContext(fieldContext),
|
||||
searchText: debouncedSearch,
|
||||
limit: SUGGESTION_LIMIT,
|
||||
},
|
||||
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
|
||||
);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const keys = data?.data?.keys ?? {};
|
||||
return Object.keys(keys)
|
||||
.filter((key) => key !== value)
|
||||
.slice(0, SUGGESTION_LIMIT);
|
||||
}, [data, value]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.keySearch, className)}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
onChange={(event): void => {
|
||||
onChange(event.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={(): void => setIsOpen(true)}
|
||||
onBlur={(): void => setIsOpen(false)}
|
||||
testId={testId}
|
||||
/>
|
||||
{isOpen && suggestions.length > 0 && (
|
||||
<div
|
||||
className={styles.keySearchDropdown}
|
||||
data-testid={`${testId}-dropdown`}
|
||||
>
|
||||
{suggestions.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className={styles.keySearchOption}
|
||||
// onMouseDown (not onClick) so selection runs before the input blur.
|
||||
onMouseDown={(event): void => {
|
||||
event.preventDefault();
|
||||
onChange(name);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
data-testid={`${testId}-option-${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isOpen && isFetching && suggestions.length === 0 && (
|
||||
<div
|
||||
className={cx(styles.keySearchDropdown, styles.keySearchDropdownEmpty)}
|
||||
>
|
||||
<Spinner size="small" height="auto" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeySearchInput;
|
||||
@@ -1,161 +0,0 @@
|
||||
.llmObservabilityAttributeMapping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.pageFooter {
|
||||
margin-top: var(--spacing-8);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.addRow {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.groupsTableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupsTableNameCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupsTableName {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.groupsTableFilters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
// Allow this column to shrink within the cell so long keys wrap
|
||||
// instead of forcing the cell wider than its allotted width.
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupsTableFilter {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
min-width: 0;
|
||||
|
||||
// Keep the context badge at full width; only the key text flexes.
|
||||
> *:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.groupsTableFilterKey {
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mappersTableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: var(--margin-1) 0;
|
||||
}
|
||||
|
||||
.mappersTableTarget {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.mappersTableSources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.mappersTableSourceChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
padding: 2px var(--padding-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l3-background);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mappersTableSourceMore {
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import AttributeMappingHeader from './AttributeMappingHeader';
|
||||
import AttributeMappingsTab from './AttributeMappingsTab';
|
||||
import GroupFormDrawer from './GroupFormDrawer';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import TestTab from './TestTab';
|
||||
import { useAttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { useGroupFormDrawer } from './useGroupFormDrawer';
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
const groupDrawer = useGroupFormDrawer();
|
||||
|
||||
const handleGroupSave = useCallback((): void => {
|
||||
store.upsertGroup(groupDrawer.draft);
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
const handleGroupDelete = useCallback((): void => {
|
||||
if (groupDrawer.draft.id) {
|
||||
store.removeGroup(groupDrawer.draft.id);
|
||||
}
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
label: 'Attribute mappings',
|
||||
children: (
|
||||
<AttributeMappingsTab
|
||||
store={store}
|
||||
onEditGroup={groupDrawer.openForEdit}
|
||||
onAddGroup={groupDrawer.openForAdd}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
children: <TestTab store={store} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityAttributeMapping}
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<AttributeMappingHeader
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
onDiscard={store.discard}
|
||||
onSave={store.save}
|
||||
/>
|
||||
|
||||
{store.saveError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
{store.saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
items={tabItems}
|
||||
/>
|
||||
{groupDrawer.isOpen && (
|
||||
<GroupFormDrawer
|
||||
isOpen={groupDrawer.isOpen}
|
||||
mode={groupDrawer.mode}
|
||||
draft={groupDrawer.draft}
|
||||
setDraft={groupDrawer.setDraft}
|
||||
onClose={groupDrawer.close}
|
||||
onSave={handleGroupSave}
|
||||
onDelete={handleGroupDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMapping;
|
||||
@@ -1,104 +0,0 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.labelHint {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.fieldContext {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sourceHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sourceIndex {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.sourceInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.sourceSelect {
|
||||
flex: 0 0 auto;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import styles from './MapperFormDrawer.module.scss';
|
||||
import SourceAttributeRow from './SourceAttributeRow';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperDraft,
|
||||
MapperDraftMode,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
import { createEmptySource, isMapperDraftValid } from './utils';
|
||||
|
||||
const FIELD_CONTEXT_OPTIONS = [
|
||||
{ value: FieldContext.attribute, label: 'Span attribute' },
|
||||
{ value: FieldContext.resource, label: 'Resource' },
|
||||
];
|
||||
|
||||
interface MapperFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: MapperDraft;
|
||||
setDraft: (next: MapperDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function MapperFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: MapperFormDrawerProps): JSX.Element {
|
||||
const isEdit = mode === 'edit';
|
||||
const isValid = isMapperDraftValid(draft);
|
||||
|
||||
// Stable per-row ids for the sortable list. These are UI-only (never sent to
|
||||
// the API and excluded from the draft), so dnd-kit can track rows reliably
|
||||
// even though sources are stored as a plain array. Re-seeded each time the
|
||||
// drawer opens; kept in lockstep with the sources array on add/remove/drag.
|
||||
const [rowIds, setRowIds] = useState<string[]>(() =>
|
||||
draft.sources.map(() => uuid()),
|
||||
);
|
||||
const sourceIds = draft.sources.map(
|
||||
(_, index) => rowIds[index] ?? `pending-${index}`,
|
||||
);
|
||||
|
||||
// 5px activation distance so clicking into the input never starts a drag.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
|
||||
const sources = draft.sources.map((source, i) =>
|
||||
i === index ? { ...source, ...patch } : source,
|
||||
);
|
||||
setDraft({ ...draft, sources });
|
||||
};
|
||||
|
||||
const addSource = (): void => {
|
||||
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
|
||||
setRowIds((prev) => [...prev, uuid()]);
|
||||
};
|
||||
|
||||
const removeSource = (index: number): void => {
|
||||
const sources = draft.sources.filter((_, i) => i !== index);
|
||||
if (sources.length === 0) {
|
||||
setDraft({ ...draft, sources: [createEmptySource()] });
|
||||
setRowIds([uuid()]);
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, sources });
|
||||
setRowIds((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const from = sourceIds.indexOf(String(active.id));
|
||||
const to = sourceIds.indexOf(String(over.id));
|
||||
if (from === -1 || to === -1) {
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
|
||||
setRowIds((prev) => arrayMove(prev, from, to));
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
|
||||
subTitle="Map source attributes onto a canonical target attribute"
|
||||
width="wide"
|
||||
testId="mapper-form-drawer"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
testId="mapper-form-delete"
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="mapper-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid || isSaving}
|
||||
testId="mapper-form-save"
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Target attribute</span>
|
||||
<KeySearchInput
|
||||
placeholder="e.g. gen_ai.content.prompt"
|
||||
value={draft.name}
|
||||
fieldContext={draft.fieldContext}
|
||||
disabled={isEdit}
|
||||
onChange={(name): void => setDraft({ ...draft, name })}
|
||||
testId="mapper-form-target"
|
||||
/>
|
||||
{isEdit && (
|
||||
<span className={styles.hint}>
|
||||
The target attribute can't be changed after creation.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Write target to</span>
|
||||
<SelectSimple
|
||||
className={styles.fieldContext}
|
||||
items={FIELD_CONTEXT_OPTIONS}
|
||||
value={draft.fieldContext}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
setDraft({ ...draft, fieldContext: next as FieldContextValue })
|
||||
}
|
||||
testId="mapper-form-field-context"
|
||||
/>
|
||||
<span className={styles.hint}>
|
||||
Where the standardized attribute is written.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
Source attributes
|
||||
<span className={styles.labelHint}>
|
||||
{' '}
|
||||
· priority: top → bottom · drag to reorder
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
|
||||
<div className={styles.sources}>
|
||||
{draft.sources.map((source, index) => (
|
||||
<SourceAttributeRow
|
||||
key={sourceIds[index]}
|
||||
id={sourceIds[index]}
|
||||
index={index}
|
||||
value={source}
|
||||
canRemove={draft.sources.length > 1}
|
||||
onChange={updateSource}
|
||||
onRemove={removeSource}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={addSource}
|
||||
testId="mapper-form-add-source"
|
||||
>
|
||||
Add another source
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperFormDrawer;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import { getMapperGroupsColumns } from './mapperGroups.config';
|
||||
import MappersTable from './MappersTable';
|
||||
import { DraftGroup } from './types';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
|
||||
const SKELETON_ROW_COUNT = 5;
|
||||
|
||||
interface MapperGroupsTableProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
// Top-level listing of mapping groups. Each row expands to reveal its mappers,
|
||||
// which MappersTable fetches lazily on first open. Built on the shared
|
||||
// TanStackTable with virtual scroll disabled — this is a small, content-height
|
||||
// list, and nested expanded tables need to grow with their content rather than
|
||||
// live inside a fixed-height viewport. Row actions (edit/delete/toggle) live in
|
||||
// the column config; the add-group affordance sits below the table.
|
||||
function MapperGroupsTable({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: MapperGroupsTableProps): JSX.Element {
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getMapperGroupsColumns({
|
||||
onEditGroup,
|
||||
onRemoveGroup: store.removeGroup,
|
||||
onToggleGroup: store.toggleGroup,
|
||||
}),
|
||||
[onEditGroup, store.removeGroup, store.toggleGroup],
|
||||
);
|
||||
|
||||
const isEmpty = !store.isLoading && store.groups.length === 0;
|
||||
|
||||
return (
|
||||
<div className={styles.groupsTableWrapper}>
|
||||
{isEmpty ? (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
) : (
|
||||
<TanStackTable<DraftGroup>
|
||||
data={store.groups}
|
||||
columns={columns}
|
||||
isLoading={store.isLoading}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.localId}
|
||||
getRowCanExpand={(): boolean => true}
|
||||
renderExpandedRow={(row): JSX.Element => (
|
||||
<MappersTable group={row} store={store} />
|
||||
)}
|
||||
disableVirtualScroll
|
||||
testId="mapper-groups-table"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
className={styles.addRow}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={store.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperGroupsTable;
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { useListSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import MapperFormDrawer from './MapperFormDrawer';
|
||||
import { getMappersColumns } from './mappers.config';
|
||||
import { DraftGroup, DraftMapper, Mapper } from './types';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { useMapperFormDrawer } from './useMapperFormDrawer';
|
||||
|
||||
const SKELETON_ROW_COUNT = 3;
|
||||
|
||||
interface MappersTableProps {
|
||||
group: DraftGroup;
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
// Nested table of a group's mappers, rendered inside the group's expanded row.
|
||||
// This component only mounts when its group row is expanded, so the fetch is
|
||||
// lazy by construction — a group's mappers load on first open, are folded into
|
||||
// the store's draft so they're editable, and are then cached by react-query.
|
||||
// New (unsaved) groups have no serverId, so skip the fetch.
|
||||
function MappersTable({ group, store }: MappersTableProps): JSX.Element {
|
||||
const drawer = useMapperFormDrawer();
|
||||
const { hydrateGroupMappers, upsertMapper, removeMapper, toggleMapper } = store;
|
||||
|
||||
const hasServerId = group.serverId !== null;
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
{ groupId: group.serverId ?? '' },
|
||||
{ query: { enabled: hasServerId } },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const items = data?.data?.items;
|
||||
if (group.serverId && items) {
|
||||
// The generated schema mis-types this list response with the groups DTO;
|
||||
// the runtime payload is mappers.
|
||||
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
|
||||
}
|
||||
}, [group.serverId, data, hydrateGroupMappers]);
|
||||
|
||||
const isLoadingMappers = hasServerId && isLoading;
|
||||
const isErrorMappers = hasServerId && isError;
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
upsertMapper(group.localId, drawer.draft);
|
||||
drawer.close();
|
||||
}, [upsertMapper, group.localId, drawer]);
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
if (drawer.draft.id) {
|
||||
removeMapper(group.localId, drawer.draft.id);
|
||||
}
|
||||
drawer.close();
|
||||
}, [removeMapper, group.localId, drawer]);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getMappersColumns({
|
||||
onEdit: drawer.openForEdit,
|
||||
onRemove: (localId): void => removeMapper(group.localId, localId),
|
||||
onToggle: (localId, enabled): void =>
|
||||
toggleMapper(group.localId, localId, enabled),
|
||||
}),
|
||||
[drawer.openForEdit, removeMapper, toggleMapper, group.localId],
|
||||
);
|
||||
|
||||
let content: JSX.Element;
|
||||
if (isErrorMappers) {
|
||||
content = (
|
||||
<div
|
||||
className={styles.tableEmpty}
|
||||
data-testid={`mappers-error-${group.localId}`}
|
||||
>
|
||||
Failed to load mappings. Please try again.
|
||||
</div>
|
||||
);
|
||||
} else if (!isLoadingMappers && group.mappers.length === 0) {
|
||||
content = (
|
||||
<div
|
||||
className={styles.tableEmpty}
|
||||
data-testid={`mappers-empty-${group.localId}`}
|
||||
>
|
||||
No mappings in this group yet.
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<TanStackTable<DraftMapper>
|
||||
data={group.mappers}
|
||||
columns={columns}
|
||||
isLoading={isLoadingMappers}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.localId}
|
||||
disableVirtualScroll
|
||||
testId={`mappers-table-${group.localId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.mappersTableWrapper}>
|
||||
{content}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
className={styles.addRow}
|
||||
onClick={drawer.openForAdd}
|
||||
testId={`add-mapper-${group.localId}`}
|
||||
>
|
||||
Add mapping
|
||||
</Button>
|
||||
|
||||
{drawer.isOpen && (
|
||||
<MapperFormDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappersTable;
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, X } from '@signozhq/icons';
|
||||
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import styles from './MapperFormDrawer.module.scss';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperOperation,
|
||||
MapperOperationValue,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
const CONTEXT_OPTIONS = [
|
||||
{ value: FieldContext.attribute, label: 'Attribute' },
|
||||
{ value: FieldContext.resource, label: 'Resource' },
|
||||
];
|
||||
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: MapperOperation.move, label: 'Move' },
|
||||
{ value: MapperOperation.copy, label: 'Copy' },
|
||||
];
|
||||
|
||||
interface SourceAttributeRowProps {
|
||||
id: string;
|
||||
index: number;
|
||||
value: SourceConfig;
|
||||
canRemove: boolean;
|
||||
onChange: (index: number, patch: Partial<SourceConfig>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
// A single draggable source row. Order = priority (top wins). Each source can
|
||||
// be read from a span attribute or the resource, and moved (delete source) or
|
||||
// copied (keep source). Only the grip is a drag handle.
|
||||
function SourceAttributeRow({
|
||||
id,
|
||||
index,
|
||||
value,
|
||||
canRemove,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: SourceAttributeRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.source} ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className={styles.sourceHandle}
|
||||
data-testid={`mapper-form-source-handle-${index}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<span className={styles.sourceIndex}>{index + 1}</span>
|
||||
<KeySearchInput
|
||||
className={styles.sourceInput}
|
||||
placeholder="Source attribute key"
|
||||
value={value.key}
|
||||
fieldContext={value.context}
|
||||
onChange={(key): void => onChange(index, { key })}
|
||||
testId={`mapper-form-source-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.sourceSelect}
|
||||
items={CONTEXT_OPTIONS}
|
||||
value={value.context}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
onChange(index, { context: next as FieldContextValue })
|
||||
}
|
||||
testId={`mapper-form-source-context-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.sourceSelect}
|
||||
items={OPERATION_OPTIONS}
|
||||
value={value.operation}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
onChange(index, { operation: next as MapperOperationValue })
|
||||
}
|
||||
testId={`mapper-form-source-operation-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove source"
|
||||
disabled={!canRemove}
|
||||
onClick={(): void => onRemove(index)}
|
||||
testId={`mapper-form-source-remove-${index}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourceAttributeRow;
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AttrChangeStatus, diffSpanAttributes } from './testPayload';
|
||||
import styles from './TestTab.module.scss';
|
||||
|
||||
interface TestResultProps {
|
||||
index: number;
|
||||
span: SpantypesSpanMapperTestSpanDTO;
|
||||
inputAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Partial<
|
||||
Record<
|
||||
AttrChangeStatus,
|
||||
{ color: 'success' | 'robin' | 'sienna'; label: string }
|
||||
>
|
||||
> = {
|
||||
added: { color: 'success', label: 'populated' },
|
||||
changed: { color: 'robin', label: 'remapped' },
|
||||
removed: { color: 'sienna', label: 'moved out' },
|
||||
};
|
||||
|
||||
// Only added/removed rows carry a background treatment; the rest render plain.
|
||||
// Kept as an explicit map so we never do dynamic `styles[...]` access.
|
||||
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
|
||||
added: styles.added,
|
||||
removed: styles.removed,
|
||||
};
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Renders one transformed span as a key/value list, highlighting which target
|
||||
// attributes the mappers populated and which source keys a move consumed.
|
||||
function TestResult({
|
||||
index,
|
||||
span,
|
||||
inputAttributes,
|
||||
}: TestResultProps): JSX.Element {
|
||||
const entries = useMemo(
|
||||
() => diffSpanAttributes(inputAttributes, span.attributes ?? {}),
|
||||
[inputAttributes, span.attributes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
|
||||
<div className={styles.resultTitle}>Resulting attributes</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>This span has no attributes.</div>
|
||||
) : (
|
||||
<div className={styles.attrRows}>
|
||||
{entries.map((entry) => {
|
||||
const badge = STATUS_BADGE[entry.status];
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
|
||||
>
|
||||
<span className={styles.attrKey} title={entry.key}>
|
||||
{entry.key}
|
||||
</span>
|
||||
<span className={styles.attrValue} title={formatValue(entry.value)}>
|
||||
{formatValue(entry.value)}
|
||||
</span>
|
||||
{badge ? (
|
||||
<Badge color={badge.color} variant="outline">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestResult;
|
||||
@@ -1,129 +0,0 @@
|
||||
.testTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.resultEmpty {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.attrRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.attrRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--padding-3);
|
||||
border-radius: var(--radius-2);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&.added {
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
&.removed {
|
||||
opacity: 0.65;
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attrKey {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.attrValue {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Play } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './TestTab.module.scss';
|
||||
import TestResult from './TestResult';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { useTestSpanMapper } from './useTestSpanMapper';
|
||||
|
||||
interface TestTabProps {
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
// "Test" tab: paste a sample span, run it through the working draft's mappers,
|
||||
// and see which target attributes get populated. Only groups whose mappers
|
||||
// changed are sent in full — unchanged groups are backfilled server-side.
|
||||
function TestTab({ store }: TestTabProps): JSX.Element {
|
||||
const { input, setInput, run, isRunning, result, testedAttributes, error } =
|
||||
useTestSpanMapper(store.snapshot, store.groups);
|
||||
|
||||
return (
|
||||
<div className={styles.testTab} data-testid="test-tab">
|
||||
<h3 className={styles.heading}>Test with sample span</h3>
|
||||
<p className={styles.description}>
|
||||
Paste a JSON span object to see which target attributes get populated and
|
||||
which source key matched.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
data-testid="test-span-input"
|
||||
value={input}
|
||||
onChange={(event): void => setInput(event.target.value)}
|
||||
spellCheck={false}
|
||||
aria-label="Sample span JSON"
|
||||
/>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
testId="run-test-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Play size={14} />}
|
||||
onClick={run}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert" data-testid="test-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={styles.results} data-testid="test-results">
|
||||
{result.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>
|
||||
No spans returned. The mappers produced no output for this input.
|
||||
</div>
|
||||
) : (
|
||||
result.map((span, index) => (
|
||||
<TestResult
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
span={span}
|
||||
inputAttributes={testedAttributes ?? {}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestTab;
|
||||
@@ -1,128 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { ChevronDown, ChevronRight, Pencil, Trash2 } from '@signozhq/icons';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import type { DraftGroup } from './types';
|
||||
import { conditionFiltersFromGroup } from './utils';
|
||||
|
||||
interface ColumnsConfig {
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onRemoveGroup: (localId: string) => void;
|
||||
onToggleGroup: (localId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the mapping-groups TanStackTable. Sorting is off across
|
||||
// the board — the groups list API returns the full set unordered, so there's no
|
||||
// server-side ordering to back a sortable header yet.
|
||||
export function getMapperGroupsColumns({
|
||||
onEditGroup,
|
||||
onRemoveGroup,
|
||||
onToggleGroup,
|
||||
}: ColumnsConfig): TableColumnDef<DraftGroup>[] {
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Group name',
|
||||
accessorFn: (row): string => row.name,
|
||||
width: { min: 240, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row, isExpanded, toggleExpanded }): JSX.Element => (
|
||||
<div className={styles.groupsTableNameCell}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
|
||||
onClick={(): void => toggleExpanded()}
|
||||
testId={`group-expand-${row.localId}`}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</Button>
|
||||
<span
|
||||
className={styles.groupsTableName}
|
||||
data-testid={`group-name-${row.localId}`}
|
||||
>
|
||||
{row.name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'filters',
|
||||
header: 'Filters',
|
||||
width: { min: 200, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => {
|
||||
const filters = conditionFiltersFromGroup(row);
|
||||
if (filters.length === 0) {
|
||||
return <span className={styles.muted}>No condition · always runs</span>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.groupsTableFilters}
|
||||
data-testid={`group-filters-${row.localId}`}
|
||||
>
|
||||
{filters.map((filter) => (
|
||||
<div
|
||||
className={styles.groupsTableFilter}
|
||||
key={`${filter.context}:${filter.key}`}
|
||||
>
|
||||
<Badge
|
||||
color={filter.context === 'resource' ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
>
|
||||
{filter.context}
|
||||
</Badge>
|
||||
<span className={styles.groupsTableFilterKey}>
|
||||
contains {filter.key}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
// Compact, right-aligned action cluster — opt out of the "last column
|
||||
// fills 100%" rule so the spare width flows into Group name / Filters.
|
||||
width: { fixed: '160px', ignoreLastColumnFill: true },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Edit group"
|
||||
onClick={(): void => onEditGroup(row)}
|
||||
testId={`group-edit-${row.localId}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label="Delete group"
|
||||
onClick={(): void => onRemoveGroup(row.localId)}
|
||||
testId={`group-delete-${row.localId}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Switch
|
||||
value={row.enabled}
|
||||
onChange={(checked): void => onToggleGroup(row.localId, checked)}
|
||||
testId={`group-enabled-${row.localId}`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Pencil, Trash2 } from '@signozhq/icons';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import { DraftMapper, FieldContext } from './types';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
|
||||
interface ColumnsConfig {
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
onToggle: (localId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the per-group mappers TanStackTable (rendered inside an
|
||||
// expanded group row). Sorting is off — priority order is positional (top wins).
|
||||
export function getMappersColumns({
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: ColumnsConfig): TableColumnDef<DraftMapper>[] {
|
||||
return [
|
||||
{
|
||||
id: 'target',
|
||||
header: 'Target',
|
||||
accessorFn: (row): string => row.name,
|
||||
width: { min: 200, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<span
|
||||
className={styles.mappersTableTarget}
|
||||
data-testid={`mapper-target-${row.localId}`}
|
||||
>
|
||||
{row.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sources',
|
||||
header: 'Sources',
|
||||
width: { min: 220, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => {
|
||||
// Skeleton placeholder rows reach the cell before real data, so
|
||||
// `sources` can be undefined — default to empty.
|
||||
const sources = row.sources ?? [];
|
||||
if (sources.length === 0) {
|
||||
return <span className={styles.muted}>—</span>;
|
||||
}
|
||||
const visible = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remaining = sources.length - visible.length;
|
||||
return (
|
||||
<div
|
||||
className={styles.mappersTableSources}
|
||||
data-testid={`mapper-sources-${row.localId}`}
|
||||
>
|
||||
{visible.map((source) => (
|
||||
<span
|
||||
className={styles.mappersTableSourceChip}
|
||||
key={`${source.context}:${source.key}`}
|
||||
title={source.key}
|
||||
>
|
||||
{source.key}
|
||||
</span>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<span className={cx(styles.mappersTableSourceMore, styles.muted)}>
|
||||
+{remaining} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'writesTo',
|
||||
header: 'Writes to',
|
||||
width: { min: 130 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge
|
||||
color={row.fieldContext === FieldContext.resource ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
>
|
||||
{row.fieldContext}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
// Compact, right-aligned action cluster — opt out of the "last column
|
||||
// fills 100%" rule so the spare width flows into Target / Sources.
|
||||
width: { fixed: '160px', ignoreLastColumnFill: true },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Edit mapping"
|
||||
onClick={(): void => onEdit(row)}
|
||||
testId={`mapper-edit-${row.localId}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label="Delete mapping"
|
||||
onClick={(): void => onRemove(row.localId)}
|
||||
testId={`mapper-delete-${row.localId}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Switch
|
||||
value={row.enabled}
|
||||
onChange={(checked): void => onToggle(row.localId, checked)}
|
||||
testId={`mapper-enabled-${row.localId}`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DraftGroup, DraftMapper, SourceConfig } from './types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
buildUpdatableGroup,
|
||||
buildUpdatableMapper,
|
||||
} from './utils';
|
||||
|
||||
// Thin persistence surface the store wires to the generated mutations.
|
||||
// createGroup returns the new server id so its mappers can be created under it.
|
||||
export interface SaveMutations {
|
||||
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
|
||||
updateGroup: (
|
||||
groupId: string,
|
||||
data: SpantypesUpdatableSpanMapperGroupDTO,
|
||||
) => Promise<void>;
|
||||
deleteGroup: (groupId: string) => Promise<void>;
|
||||
createMapper: (
|
||||
groupId: string,
|
||||
data: SpantypesPostableSpanMapperDTO,
|
||||
) => Promise<void>;
|
||||
updateMapper: (
|
||||
groupId: string,
|
||||
mapperId: string,
|
||||
data: SpantypesUpdatableSpanMapperDTO,
|
||||
) => Promise<void>;
|
||||
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(source, index) =>
|
||||
source.key === b[index].key &&
|
||||
source.context === b[index].context &&
|
||||
source.operation === b[index].operation,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
|
||||
return (
|
||||
snapshot.name !== draft.name ||
|
||||
snapshot.enabled !== draft.enabled ||
|
||||
!arraysEqual(snapshot.attributes, draft.attributes) ||
|
||||
!arraysEqual(snapshot.resource, draft.resource)
|
||||
);
|
||||
}
|
||||
|
||||
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
|
||||
return (
|
||||
snapshot.enabled !== draft.enabled ||
|
||||
snapshot.fieldContext !== draft.fieldContext ||
|
||||
!sourcesEqual(snapshot.sources, draft.sources)
|
||||
);
|
||||
}
|
||||
|
||||
function groupDraftOf(
|
||||
node: DraftGroup,
|
||||
): Parameters<typeof buildPostableGroup>[0] {
|
||||
return {
|
||||
id: node.serverId,
|
||||
name: node.name,
|
||||
attributes: node.attributes,
|
||||
resource: node.resource,
|
||||
enabled: node.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function mapperDraftOf(
|
||||
node: DraftMapper,
|
||||
): Parameters<typeof buildPostableMapper>[0] {
|
||||
return {
|
||||
id: node.serverId,
|
||||
name: node.name,
|
||||
fieldContext: node.fieldContext,
|
||||
sources: node.sources,
|
||||
enabled: node.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistMappers(
|
||||
groupServerId: string,
|
||||
snapshotMappers: DraftMapper[],
|
||||
draftMappers: DraftMapper[],
|
||||
m: SaveMutations,
|
||||
): Promise<void> {
|
||||
const snapById = new Map(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId)
|
||||
.map((mapper) => [mapper.serverId as string, mapper]),
|
||||
);
|
||||
const draftServerIds = new Set(
|
||||
draftMappers
|
||||
.filter((mapper) => mapper.serverId)
|
||||
.map((mapper) => mapper.serverId as string),
|
||||
);
|
||||
|
||||
// Deleted mappers.
|
||||
await Promise.all(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
|
||||
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
|
||||
);
|
||||
|
||||
// Created + updated mappers (sequential to keep ordering deterministic).
|
||||
for (const mapper of draftMappers) {
|
||||
if (!mapper.serverId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.createMapper(
|
||||
groupServerId,
|
||||
buildPostableMapper(mapperDraftOf(mapper)),
|
||||
);
|
||||
} else {
|
||||
const snap = snapById.get(mapper.serverId);
|
||||
if (!snap || mapperChanged(snap, mapper)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.updateMapper(
|
||||
groupServerId,
|
||||
mapper.serverId,
|
||||
buildUpdatableMapper(mapperDraftOf(mapper)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diffs the staged tree against the server snapshot and issues the minimal set
|
||||
// of create/update/delete calls to reconcile them.
|
||||
export async function persistDraft(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
m: SaveMutations,
|
||||
): Promise<void> {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
const draftServerIds = new Set(
|
||||
draft
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => group.serverId as string),
|
||||
);
|
||||
|
||||
// Apply additive work (creates/updates) before deletes, so a failure here
|
||||
// leaves at worst an incomplete set of additions rather than groups that
|
||||
// were deleted with no replacement persisted. Deletes are irreversible
|
||||
// (they cascade mappers server-side), so we do them last, once everything
|
||||
// else has succeeded.
|
||||
for (const group of draft) {
|
||||
if (!group.serverId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await persistMappers(newId, [], group.mappers, m);
|
||||
continue;
|
||||
}
|
||||
|
||||
const snap = snapById.get(group.serverId);
|
||||
if (!snap || groupChanged(snap, group)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.updateGroup(
|
||||
group.serverId,
|
||||
buildUpdatableGroup(groupDraftOf(group)),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
|
||||
}
|
||||
|
||||
// Deleted groups (cascades mappers server-side).
|
||||
await Promise.all(
|
||||
snapshot
|
||||
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
|
||||
.map((group) => m.deleteGroup(group.serverId as string)),
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesPostableSpanMapperTestGroupDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DraftGroup } from './types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
groupDraftFromNode,
|
||||
mapperDraftFromNode,
|
||||
} from './utils';
|
||||
|
||||
// A group's mappers must be sent in full when the group is new (the backend has
|
||||
// no saved group of that name to backfill from) or when its mapper set has
|
||||
// diverged from the saved baseline. Otherwise we omit them and let the backend
|
||||
// load the saved mappers by group name — which also covers groups whose rows
|
||||
// were never expanded (their draft carries no mappers yet).
|
||||
function shouldSendMappers(
|
||||
snap: DraftGroup | undefined,
|
||||
group: DraftGroup,
|
||||
): boolean {
|
||||
if (!snap) {
|
||||
return true;
|
||||
}
|
||||
return JSON.stringify(snap.mappers) !== JSON.stringify(group.mappers);
|
||||
}
|
||||
|
||||
// Builds the `groups` portion of the test request from the working draft,
|
||||
// sending each group's name/enabled/condition from the current draft and its
|
||||
// `mappers` only when they changed (null otherwise → backend backfills).
|
||||
export function buildTestGroups(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): SpantypesPostableSpanMapperTestGroupDTO[] {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
|
||||
return draft.map((group) => {
|
||||
const base = buildPostableGroup(groupDraftFromNode(group));
|
||||
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
|
||||
return {
|
||||
...base,
|
||||
mappers: shouldSendMappers(snap, group)
|
||||
? group.mappers.map((mapper) =>
|
||||
buildPostableMapper(mapperDraftFromNode(mapper)),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Parses the pasted JSON into a single test span. The pasted object is treated
|
||||
// as the span's attribute map (matching the sample shown to the user); resource
|
||||
// is left empty. Throws a friendly error on anything that isn't a JSON object.
|
||||
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Paste a JSON span object to run the test.');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid JSON — check for trailing commas or missing quotes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Span must be a JSON object of attribute key-value pairs.');
|
||||
}
|
||||
|
||||
return { attributes: parsed as Record<string, unknown>, resource: {} };
|
||||
}
|
||||
|
||||
export function buildTestRequest(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
input: string,
|
||||
): SpantypesPostableSpanMapperTestDTO {
|
||||
return {
|
||||
groups: buildTestGroups(snapshot, draft),
|
||||
spans: [parseSpanInput(input)],
|
||||
};
|
||||
}
|
||||
|
||||
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
|
||||
|
||||
export interface AttrDiffEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
status: AttrChangeStatus;
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
// Diffs the span attributes a user pasted against the attributes the mappers
|
||||
// produced, so the UI can highlight which target keys got populated ('added'),
|
||||
// which source keys were consumed by a move ('removed'), and what stayed.
|
||||
// Added keys (the populated targets) sort first as the primary signal.
|
||||
export function diffSpanAttributes(
|
||||
inputAttributes: Record<string, unknown>,
|
||||
resultAttributes: Record<string, unknown>,
|
||||
): AttrDiffEntry[] {
|
||||
const added: AttrDiffEntry[] = [];
|
||||
const changed: AttrDiffEntry[] = [];
|
||||
const unchanged: AttrDiffEntry[] = [];
|
||||
const removed: AttrDiffEntry[] = [];
|
||||
|
||||
Object.entries(resultAttributes).forEach(([key, value]) => {
|
||||
if (!(key in inputAttributes)) {
|
||||
added.push({ key, value, status: 'added' });
|
||||
} else if (!valuesEqual(inputAttributes[key], value)) {
|
||||
changed.push({ key, value, status: 'changed' });
|
||||
} else {
|
||||
unchanged.push({ key, value, status: 'unchanged' });
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(inputAttributes).forEach(([key, value]) => {
|
||||
if (!(key in resultAttributes)) {
|
||||
removed.push({ key, value, status: 'removed' });
|
||||
}
|
||||
});
|
||||
|
||||
return [...added, ...changed, ...unchanged, ...removed];
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperConfigDTO,
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupConditionDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type MapperGroup = SpantypesSpanMapperGroupDTO;
|
||||
export type MapperGroupCondition =
|
||||
NonNullable<SpantypesSpanMapperGroupConditionDTO>;
|
||||
export type Mapper = SpantypesSpanMapperDTO;
|
||||
export type MapperConfig = SpantypesSpanMapperConfigDTO;
|
||||
export type MapperSource = SpantypesSpanMapperSourceDTO;
|
||||
|
||||
export const FieldContext = SpantypesFieldContextDTO;
|
||||
export const MapperOperation = SpantypesSpanMapperOperationDTO;
|
||||
|
||||
export type FieldContextValue = SpantypesFieldContextDTO;
|
||||
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
|
||||
|
||||
// A single human-readable condition clause shown in the group's Filters column.
|
||||
export interface ConditionFilter {
|
||||
context: 'attribute' | 'resource';
|
||||
key: string;
|
||||
}
|
||||
|
||||
export type MapperDraftMode = 'add' | 'edit';
|
||||
|
||||
// One source candidate. `context` is where the key is read from (span
|
||||
// attribute or resource); `operation` is move (delete source) or copy (keep).
|
||||
// Priority is implicit in list order (top wins), derived on save.
|
||||
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: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Editable form state for a group. The group runs when a span carries a
|
||||
// span-attribute key matching `attributes` OR a resource key matching
|
||||
// `resource` (plain substring match).
|
||||
export interface GroupDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Working-copy node for a mapper. `localId` is a stable client key (the server
|
||||
// id once persisted, or a temporary id for not-yet-saved rows). `serverId` is
|
||||
// null until the row has been persisted.
|
||||
export interface DraftMapper {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Working-copy node for a group, holding its mappers inline so the whole tree
|
||||
// can be staged locally and diffed against the server snapshot on save.
|
||||
export interface DraftGroup {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
mappers: DraftMapper[];
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
useCreateSpanMapper,
|
||||
useCreateSpanMapperGroup,
|
||||
useDeleteSpanMapper,
|
||||
useDeleteSpanMapperGroup,
|
||||
useListSpanMapperGroups,
|
||||
useUpdateSpanMapper,
|
||||
useUpdateSpanMapperGroup,
|
||||
} from 'api/generated/services/spanmapper';
|
||||
|
||||
import { persistDraft, SaveMutations } from './saveDraft';
|
||||
import {
|
||||
DraftGroup,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
} from './types';
|
||||
import {
|
||||
buildDraftGroup,
|
||||
buildDraftMapper,
|
||||
nodeFromGroupDraft,
|
||||
nodeFromMapperDraft,
|
||||
} from './utils';
|
||||
|
||||
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
|
||||
|
||||
function clone(groups: DraftGroup[]): DraftGroup[] {
|
||||
return JSON.parse(JSON.stringify(groups)) as DraftGroup[];
|
||||
}
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: DraftGroup[];
|
||||
// The last-saved server baseline the working `groups` are diffed against.
|
||||
// Exposed so the Test tab can send only the groups whose mappers changed.
|
||||
snapshot: DraftGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveError: string | null;
|
||||
upsertGroup: (draft: GroupDraft) => void;
|
||||
removeGroup: (localId: string) => void;
|
||||
toggleGroup: (localId: string, enabled: boolean) => void;
|
||||
hydrateGroupMappers: (groupServerId: string, mappers: Mapper[]) => void;
|
||||
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
|
||||
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
|
||||
toggleMapper: (
|
||||
groupLocalId: string,
|
||||
mapperLocalId: string,
|
||||
enabled: boolean,
|
||||
) => void;
|
||||
save: () => Promise<void>;
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
const serverGroups: MapperGroup[] = useMemo(
|
||||
() => groupsQuery.data?.data?.items ?? [],
|
||||
[groupsQuery.data],
|
||||
);
|
||||
|
||||
const ready = !groupsQuery.isLoading;
|
||||
|
||||
// A group's mappers are fetched lazily when its row is first expanded (see
|
||||
// MappersTable -> hydrateGroupMappers) and cached here, keyed by group server
|
||||
// id. Page load stays a single groups request — never an N+1 fan-out across
|
||||
// every group.
|
||||
const [loadedMappers, setLoadedMappers] = useState<Record<string, Mapper[]>>(
|
||||
{},
|
||||
);
|
||||
const loadedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const snapshot = useMemo<DraftGroup[]>(() => {
|
||||
if (!ready) {
|
||||
return [];
|
||||
}
|
||||
return serverGroups.map((group) =>
|
||||
buildDraftGroup(group, loadedMappers[group.id] ?? []),
|
||||
);
|
||||
}, [ready, serverGroups, loadedMappers]);
|
||||
|
||||
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
|
||||
|
||||
// Initialise the working copy once data is ready (and re-init after a save
|
||||
// clears it). Never clobbers in-flight edits — only runs when draft is null.
|
||||
useEffect(() => {
|
||||
if (ready && draft === null) {
|
||||
setDraft(clone(snapshot));
|
||||
}
|
||||
}, [ready, draft, snapshot]);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
|
||||
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
|
||||
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
|
||||
const { mutateAsync: createMapper } = useCreateSpanMapper();
|
||||
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
|
||||
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
|
||||
|
||||
const mutations: SaveMutations = useMemo(
|
||||
() => ({
|
||||
createGroup: async (data): Promise<string> => {
|
||||
const result = await createGroup({ data });
|
||||
return result.data.id;
|
||||
},
|
||||
updateGroup: async (groupId, data): Promise<void> => {
|
||||
await updateGroup({ pathParams: { groupId }, data });
|
||||
},
|
||||
deleteGroup: async (groupId): Promise<void> => {
|
||||
await deleteGroup({ pathParams: { groupId } });
|
||||
},
|
||||
createMapper: async (groupId, data): Promise<void> => {
|
||||
await createMapper({ pathParams: { groupId }, data });
|
||||
},
|
||||
updateMapper: async (groupId, mapperId, data): Promise<void> => {
|
||||
await updateMapper({ pathParams: { groupId, mapperId }, data });
|
||||
},
|
||||
deleteMapper: async (groupId, mapperId): Promise<void> => {
|
||||
await deleteMapper({ pathParams: { groupId, mapperId } });
|
||||
},
|
||||
}),
|
||||
[
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
createMapper,
|
||||
updateMapper,
|
||||
deleteMapper,
|
||||
],
|
||||
);
|
||||
|
||||
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
|
||||
setDraft((prev) => {
|
||||
const groups = prev ?? [];
|
||||
if (groupDraft.id) {
|
||||
return groups.map((group) =>
|
||||
group.localId === groupDraft.id
|
||||
? nodeFromGroupDraft(groupDraft, group)
|
||||
: group,
|
||||
);
|
||||
}
|
||||
return [...groups, nodeFromGroupDraft(groupDraft)];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeGroup = useCallback((localId: string): void => {
|
||||
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
|
||||
}, []);
|
||||
|
||||
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === localId ? { ...group, enabled } : group,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Fold a group's freshly-fetched server mappers into both the snapshot
|
||||
// baseline and the working draft, exactly once per group (the guard skips
|
||||
// re-expands). The "Add mapping" affordance renders while the fetch is still
|
||||
// in flight, so a user can stage a new mapper (serverId === null) before the
|
||||
// server list arrives — those unsaved rows are preserved, with the server
|
||||
// mappers folded in ahead of them, so a racing add isn't clobbered.
|
||||
const hydrateGroupMappers = useCallback(
|
||||
(groupServerId: string, mappers: Mapper[]): void => {
|
||||
if (loadedRef.current.has(groupServerId)) {
|
||||
return;
|
||||
}
|
||||
loadedRef.current.add(groupServerId);
|
||||
setLoadedMappers((prev) => ({ ...prev, [groupServerId]: mappers }));
|
||||
setDraft((prev) =>
|
||||
prev === null
|
||||
? prev
|
||||
: prev.map((group) =>
|
||||
group.serverId === groupServerId
|
||||
? {
|
||||
...group,
|
||||
mappers: [
|
||||
...mappers.map(buildDraftMapper),
|
||||
...group.mappers.filter((mapper) => mapper.serverId === null),
|
||||
],
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const upsertMapper = useCallback(
|
||||
(groupLocalId: string, mapperDraft: MapperDraft): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) => {
|
||||
if (group.localId !== groupLocalId) {
|
||||
return group;
|
||||
}
|
||||
if (mapperDraft.id) {
|
||||
return {
|
||||
...group,
|
||||
mappers: group.mappers.map((mapper) =>
|
||||
mapper.localId === mapperDraft.id
|
||||
? nodeFromMapperDraft(mapperDraft, mapper)
|
||||
: mapper,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeMapper = useCallback(
|
||||
(groupLocalId: string, mapperLocalId: string): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === groupLocalId
|
||||
? {
|
||||
...group,
|
||||
mappers: group.mappers.filter(
|
||||
(mapper) => mapper.localId !== mapperLocalId,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleMapper = useCallback(
|
||||
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === groupLocalId
|
||||
? {
|
||||
...group,
|
||||
mappers: group.mappers.map((mapper) =>
|
||||
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const discard = useCallback((): void => {
|
||||
setSaveError(null);
|
||||
setDraft(clone(snapshot));
|
||||
}, [snapshot]);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await persistDraft(snapshot, draft, mutations);
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
typeof query.queryKey?.[0] === 'string' &&
|
||||
(query.queryKey[0] as string).startsWith(GROUPS_KEY_PREFIX),
|
||||
});
|
||||
// Drop lazily-loaded mappers and re-initialise the working copy from the
|
||||
// freshly-fetched server data; expanded rows re-hydrate on next render.
|
||||
loadedRef.current = new Set();
|
||||
setLoadedMappers({});
|
||||
setDraft(null);
|
||||
toast.success('Attribute mapping changes saved');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
toast.error(`Failed to save changes: ${message}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draft, snapshot, mutations, queryClient]);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => draft !== null && JSON.stringify(draft) !== JSON.stringify(snapshot),
|
||||
[draft, snapshot],
|
||||
);
|
||||
|
||||
return {
|
||||
groups: draft ?? [],
|
||||
snapshot,
|
||||
isLoading: !ready || draft === null,
|
||||
isError: groupsQuery.isError,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveError,
|
||||
upsertGroup,
|
||||
removeGroup,
|
||||
toggleGroup,
|
||||
hydrateGroupMappers,
|
||||
upsertMapper,
|
||||
removeMapper,
|
||||
toggleMapper,
|
||||
save,
|
||||
discard,
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftGroup, GroupDraft, MapperDraftMode } from './types';
|
||||
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from './utils';
|
||||
|
||||
interface UseGroupFormDrawer {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
openForAdd: () => void;
|
||||
openForEdit: (group: DraftGroup) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
// Form state for the group drawer. Persistence is staged through the store,
|
||||
// so this hook only owns open/draft/mode.
|
||||
export function useGroupFormDrawer(): UseGroupFormDrawer {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<MapperDraftMode>('add');
|
||||
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setDraft(EMPTY_GROUP_DRAFT);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((group: DraftGroup): void => {
|
||||
setMode('edit');
|
||||
setDraft(groupDraftFromNode(group));
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftMapper, MapperDraft, MapperDraftMode } from './types';
|
||||
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from './utils';
|
||||
|
||||
interface UseMapperFormDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: MapperDraft;
|
||||
setDraft: (next: MapperDraft) => void;
|
||||
openForAdd: () => void;
|
||||
openForEdit: (mapper: DraftMapper) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
// Form state for the mapper drawer. Persistence is staged through the store,
|
||||
// so this hook only owns open/draft/mode.
|
||||
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<MapperDraftMode>('add');
|
||||
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setDraft(EMPTY_MAPPER_DRAFT);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((mapper: DraftMapper): void => {
|
||||
setMode('edit');
|
||||
setDraft(mapperDraftFromNode(mapper));
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { buildTestRequest } from './testPayload';
|
||||
import { DraftGroup } from './types';
|
||||
|
||||
// Pre-filled sample so the tab is runnable on first open (mirrors the design).
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
}`;
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
|
||||
return (
|
||||
axiosError?.response?.data?.error?.message ??
|
||||
(error instanceof Error ? error.message : 'Test failed. Please try again.')
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseTestSpanMapper {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
run: () => void;
|
||||
reset: () => void;
|
||||
isRunning: boolean;
|
||||
result: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
// The attributes that were actually submitted with the last successful run,
|
||||
// so the result diff stays stable even if the textarea is edited afterwards.
|
||||
testedAttributes: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Owns the Test tab's local state: the pasted span JSON, the parsed/built
|
||||
// request, and the result/error of the test mutation. Builds the request from
|
||||
// the working draft (sending only changed groups' mappers — see testPayload).
|
||||
export function useTestSpanMapper(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): UseTestSpanMapper {
|
||||
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
|
||||
null,
|
||||
);
|
||||
const [testedAttributes, setTestedAttributes] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
|
||||
const { mutate, isLoading } = useTestSpanMappers();
|
||||
|
||||
const run = useCallback((): void => {
|
||||
setError(null);
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = buildTestRequest(snapshot, draft, input);
|
||||
} catch (parseError) {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(parseError));
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedAttributes = (body.spans?.[0]?.attributes ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
mutate(
|
||||
{ data: body },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setTestedAttributes(submittedAttributes);
|
||||
setResult(response.data?.spans ?? []);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(mutationError));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [snapshot, draft, input, mutate]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTestedAttributes(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
reset,
|
||||
isRunning: isLoading,
|
||||
result,
|
||||
testedAttributes,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
ConditionFilter,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
|
||||
// with a server UUID and is easy to spot in logs.
|
||||
export function genLocalId(prefix: 'group' | 'mapper'): string {
|
||||
return `local-${prefix}-${uuid()}`;
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order.
|
||||
export function cleanKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
keys.forEach((raw) => {
|
||||
const key = raw.trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Display clauses for a group's condition keys (span attribute keys first,
|
||||
// then resource keys).
|
||||
export function conditionFiltersFromGroup(group: {
|
||||
attributes?: string[];
|
||||
resource?: string[];
|
||||
}): ConditionFilter[] {
|
||||
// TanStackTable renders skeleton placeholder rows through the cells on first
|
||||
// render, so these arrays can be undefined before real data lands — default
|
||||
// to empty rather than crashing the cell.
|
||||
return [
|
||||
...(group.attributes ?? []).map((key) => ({
|
||||
context: 'attribute' as const,
|
||||
key,
|
||||
})),
|
||||
...(group.resource ?? []).map((key) => ({
|
||||
context: 'resource' as const,
|
||||
key,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
// Source configs for a mapper, highest priority first (first match wins at
|
||||
// evaluation time).
|
||||
export function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
const sources = mapper.config?.sources ?? [];
|
||||
return [...sources]
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.map((source) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createEmptySource(): SourceConfig {
|
||||
return {
|
||||
key: '',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [createEmptySource()],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Trimmed, de-duplicated (by context+key), non-empty sources in priority order,
|
||||
// preserving each source's context and operation. A key identifies a different
|
||||
// source per context (span attribute vs resource), so both can coexist; only an
|
||||
// exact (context, key) repeat is collapsed, keeping the higher-priority row.
|
||||
export function getCleanSources(draft: MapperDraft): SourceConfig[] {
|
||||
const seen = new Set<string>();
|
||||
const result: SourceConfig[] = [];
|
||||
draft.sources.forEach((source) => {
|
||||
const key = source.key.trim();
|
||||
const dedupeKey = `${source.context}:${key}`;
|
||||
if (key && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...source, key });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isMapperDraftValid(draft: MapperDraft): boolean {
|
||||
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
|
||||
}
|
||||
|
||||
// Priority is derived from list order so the first row wins.
|
||||
function buildSources(
|
||||
draft: MapperDraft,
|
||||
): SpantypesPostableSpanMapperDTO['config']['sources'] {
|
||||
const sources = getCleanSources(draft);
|
||||
return sources.map((source, index) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
priority: sources.length - index,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPostableMapper(
|
||||
draft: MapperDraft,
|
||||
): SpantypesPostableSpanMapperDTO {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
fieldContext: draft.fieldContext,
|
||||
enabled: draft.enabled,
|
||||
config: { sources: buildSources(draft) },
|
||||
};
|
||||
}
|
||||
|
||||
// The target name is immutable on update (UpdatableSpanMapper has no name).
|
||||
export function buildUpdatableMapper(
|
||||
draft: MapperDraft,
|
||||
): SpantypesUpdatableSpanMapperDTO {
|
||||
return {
|
||||
fieldContext: draft.fieldContext,
|
||||
enabled: draft.enabled,
|
||||
config: { sources: buildSources(draft) },
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
attributes: [''],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function isGroupDraftValid(draft: GroupDraft): boolean {
|
||||
return draft.name.trim().length > 0;
|
||||
}
|
||||
|
||||
export function buildPostableGroup(
|
||||
draft: GroupDraft,
|
||||
): SpantypesPostableSpanMapperGroupDTO {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
enabled: draft.enabled,
|
||||
condition: {
|
||||
attributes: cleanKeys(draft.attributes),
|
||||
resource: cleanKeys(draft.resource),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A full group payload is also a valid partial-update payload (all updatable
|
||||
// fields are present), so we reuse the postable builder.
|
||||
export function buildUpdatableGroup(
|
||||
draft: GroupDraft,
|
||||
): SpantypesUpdatableSpanMapperGroupDTO {
|
||||
return buildPostableGroup(draft);
|
||||
}
|
||||
|
||||
// ---- working-copy (draft tree) helpers ----
|
||||
|
||||
export function buildDraftMapper(mapper: Mapper): DraftMapper {
|
||||
return {
|
||||
localId: mapper.id,
|
||||
serverId: mapper.id,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources: getMapperSources(mapper),
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftGroup(
|
||||
group: MapperGroup,
|
||||
mappers: Mapper[],
|
||||
): DraftGroup {
|
||||
return {
|
||||
localId: group.id,
|
||||
serverId: group.id,
|
||||
name: group.name,
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
enabled: group.enabled,
|
||||
mappers: mappers.map(buildDraftMapper),
|
||||
};
|
||||
}
|
||||
|
||||
// DraftGroup -> editable form state (id carries the localId).
|
||||
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
|
||||
return {
|
||||
id: group.localId,
|
||||
name: group.name,
|
||||
attributes: group.attributes.length > 0 ? group.attributes : [''],
|
||||
resource: group.resource,
|
||||
enabled: group.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// DraftMapper -> editable form state (id carries the localId).
|
||||
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
|
||||
return {
|
||||
id: mapper.localId,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources:
|
||||
mapper.sources.length > 0
|
||||
? mapper.sources.map((source) => ({ ...source }))
|
||||
: [createEmptySource()],
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
|
||||
// staged tree already holds normalized values.
|
||||
export function nodeFromGroupDraft(
|
||||
draft: GroupDraft,
|
||||
existing?: DraftGroup,
|
||||
): DraftGroup {
|
||||
return {
|
||||
localId: existing?.localId ?? genLocalId('group'),
|
||||
serverId: existing?.serverId ?? null,
|
||||
name: draft.name.trim(),
|
||||
attributes: cleanKeys(draft.attributes),
|
||||
resource: cleanKeys(draft.resource),
|
||||
enabled: draft.enabled,
|
||||
mappers: existing?.mappers ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeFromMapperDraft(
|
||||
draft: MapperDraft,
|
||||
existing?: DraftMapper,
|
||||
): DraftMapper {
|
||||
return {
|
||||
localId: existing?.localId ?? genLocalId('mapper'),
|
||||
serverId: existing?.serverId ?? null,
|
||||
name: draft.name.trim(),
|
||||
fieldContext: draft.fieldContext,
|
||||
sources: getCleanSources(draft),
|
||||
enabled: draft.enabled,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -123,14 +124,24 @@ function ServiceOverview({
|
||||
/>
|
||||
<Card data-testid="service_latency">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
onDragSelect={onDragSelect}
|
||||
widget={latencyWidget}
|
||||
onClickHandler={handleGraphClick('Service')}
|
||||
isQueryEnabled={isQueryEnabled}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
{topLevelOperationsIsLoading && (
|
||||
<Skeleton
|
||||
style={{
|
||||
height: '100%',
|
||||
padding: '16px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!topLevelOperationsIsLoading && (
|
||||
<Graph
|
||||
onDragSelect={onDragSelect}
|
||||
widget={latencyWidget}
|
||||
onClickHandler={handleGraphClick('Service')}
|
||||
isQueryEnabled={isQueryEnabled}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
)}
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -28,14 +29,24 @@ function TopLevelOperation({
|
||||
</Typography>
|
||||
) : (
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={widget}
|
||||
onClickHandler={handleGraphClick(opName)}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={!topLevelOperationsIsLoading}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
{topLevelOperationsIsLoading && (
|
||||
<Skeleton
|
||||
style={{
|
||||
height: '100%',
|
||||
padding: '16px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!topLevelOperationsIsLoading && (
|
||||
<Graph
|
||||
widget={widget}
|
||||
onClickHandler={handleGraphClick(opName)}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={!topLevelOperationsIsLoading}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
)}
|
||||
</GraphContainer>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
.right-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { adjustQueryForV5 } from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -821,11 +820,6 @@ function NewWidget({
|
||||
</Flex>
|
||||
</div>
|
||||
<div className="right-header">
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare={false}
|
||||
enableFeedback={false}
|
||||
/>
|
||||
{showSwitchToViewModeButton && (
|
||||
<Button
|
||||
color="primary"
|
||||
|
||||
@@ -206,7 +206,6 @@ export const routesToSkip = [
|
||||
ROUTES.METER,
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -68,6 +68,10 @@
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
|
||||
.new-view-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PageTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import type { AIAssistantVariant } from 'container/AIAssistant/VariantContext';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { useResolvePageType } from '../useResolvePageType';
|
||||
|
||||
const mockUseLocation = jest.fn();
|
||||
const mockUseVariant = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): unknown => mockUseLocation(),
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/VariantContext', () => ({
|
||||
useVariant: (): unknown => mockUseVariant(),
|
||||
}));
|
||||
|
||||
function setup(
|
||||
pathname: string,
|
||||
search: string,
|
||||
variant: AIAssistantVariant,
|
||||
): PageTypeDTO {
|
||||
mockUseLocation.mockReturnValue({ pathname, search });
|
||||
mockUseVariant.mockReturnValue(variant);
|
||||
|
||||
return renderHook(() => useResolvePageType()).result.current;
|
||||
}
|
||||
|
||||
describe('useResolvePageType', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns other for the standalone "page" assistant surface', () => {
|
||||
const pathname = ROUTES.DASHBOARD.replace(':dashboardId', 'dash-123');
|
||||
|
||||
expect(setup(pathname, '', 'page')).toBe(PageTypeDTO.other);
|
||||
});
|
||||
|
||||
it('resolves the underlying page type for embedded variants', () => {
|
||||
const pathname = ROUTES.DASHBOARD.replace(':dashboardId', 'dash-123');
|
||||
|
||||
expect(setup(pathname, '', 'panel')).toBe(PageTypeDTO.dashboard_detail);
|
||||
expect(setup(pathname, '', 'modal')).toBe(PageTypeDTO.dashboard_detail);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { PageTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { resolvePageType } from 'container/AIAssistant/resolvePageType';
|
||||
import { useVariant } from 'container/AIAssistant/VariantContext';
|
||||
|
||||
/**
|
||||
* React hook wrapper around `resolvePageType` that derives the current
|
||||
* `page_type` from the active location and assistant variant.
|
||||
*/
|
||||
export function useResolvePageType(): PageTypeDTO {
|
||||
const location = useLocation();
|
||||
const variant = useVariant();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
resolvePageType(location.pathname, location.search, {
|
||||
isStandaloneAssistant: variant === 'page',
|
||||
}),
|
||||
[location.pathname, location.search, variant],
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user