mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-18 06:20:34 +01:00
Compare commits
1 Commits
feat/panel
...
issue_5365
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb9d1d25be |
@@ -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:
|
||||
@@ -10253,6 +10191,15 @@ paths:
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: q
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: isOverride
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -13382,231 +13329,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 +13722,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.
|
||||
@@ -29,18 +29,6 @@ if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function (): void {};
|
||||
}
|
||||
|
||||
// jsdom doesn't implement the Pointer Capture API, which Radix UI primitives
|
||||
// (e.g. @signozhq/ui Select) call when opening. Stub them so those components
|
||||
// can be exercised in tests.
|
||||
if (!HTMLElement.prototype.hasPointerCapture) {
|
||||
HTMLElement.prototype.hasPointerCapture = function (): boolean {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
if (!HTMLElement.prototype.releasePointerCapture) {
|
||||
HTMLElement.prototype.releasePointerCapture = function (): void {};
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@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",
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
696
frontend/pnpm-lock.yaml
generated
696
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'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -122,13 +115,6 @@ export const DashboardWidget = Loadable(
|
||||
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
|
||||
);
|
||||
|
||||
export const DashboardPanelEditorPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const EditRulesPage = Loadable(
|
||||
() => import(/* webpackChunkName: "Alerts Edit Page" */ 'pages/EditRules'),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
CreateAlertChannelAlerts,
|
||||
CreateNewAlerts,
|
||||
DashboardPage,
|
||||
DashboardPanelEditorPage,
|
||||
DashboardsListPage,
|
||||
DashboardWidget,
|
||||
EditRulesPage,
|
||||
@@ -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',
|
||||
},
|
||||
@@ -197,13 +198,6 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_WIDGET',
|
||||
},
|
||||
{
|
||||
path: ROUTES.DASHBOARD_PANEL_EDITOR,
|
||||
exact: true,
|
||||
component: DashboardPanelEditorPage,
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_PANEL_EDITOR',
|
||||
},
|
||||
{
|
||||
path: ROUTES.EDIT_ALERTS,
|
||||
exact: true,
|
||||
|
||||
@@ -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
|
||||
@@ -9450,6 +9388,16 @@ export type ListLLMPricingRulesParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
q?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
* @description undefined
|
||||
*/
|
||||
isOverride?: boolean | null;
|
||||
};
|
||||
|
||||
export type ListLLMPricingRules200 = {
|
||||
@@ -9891,36 +9839,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
|
||||
@@ -9999,17 +9917,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;
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,6 @@ const ROUTES = {
|
||||
ALL_DASHBOARD: '/dashboard',
|
||||
DASHBOARD: '/dashboard/:dashboardId',
|
||||
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
|
||||
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
|
||||
EDIT_ALERTS: '/alerts/edit',
|
||||
LIST_ALL_ALERT: '/alerts',
|
||||
ALERTS_NEW: '/alerts/new',
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -408,9 +408,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
|
||||
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
|
||||
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
|
||||
// The V2 panel editor is a chromeless full-page route (no side nav / top nav),
|
||||
// like the onboarding and public-dashboard screens.
|
||||
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
|
||||
|
||||
const renderFullScreen =
|
||||
pathname === ROUTES.GET_STARTED ||
|
||||
@@ -421,8 +418,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
|
||||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
|
||||
isPublicDashboard ||
|
||||
isPanelEditorV2;
|
||||
isPublicDashboard;
|
||||
|
||||
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
|
||||
.chart-layout__legend-wrapper {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__legend-wrapper {
|
||||
// The inline height is the legend rectangle from calculateChartDimensions;
|
||||
// border-box keeps the padding inside it so the wrapper doesn't grow past
|
||||
// that height and steal space from the chart. overflow:hidden clips to the
|
||||
// rectangle so the virtualized legend scrolls within it.
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -68,6 +68,10 @@
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
|
||||
.new-view-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useConfirmableAction } from '../useConfirmableAction';
|
||||
|
||||
describe('useConfirmableAction', () => {
|
||||
it('starts closed and idle', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfirmableAction(jest.fn().mockResolvedValue(undefined)),
|
||||
);
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('request() opens the prompt without running the action', () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirm() runs the action and closes on success', async () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
await act(async () => {
|
||||
await result.current.confirm();
|
||||
});
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the prompt open and resets pending when the action rejects', async () => {
|
||||
const action = jest.fn().mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
await act(async () => {
|
||||
await expect(result.current.confirm()).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('cancel() closes the prompt without running the action', () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
act(() => result.current.cancel());
|
||||
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
export interface ConfirmableAction {
|
||||
/** Whether the confirmation prompt is open. */
|
||||
open: boolean;
|
||||
/** The confirmed action is in flight. */
|
||||
isPending: boolean;
|
||||
/** Open the confirmation prompt (e.g. from a menu item / button). */
|
||||
request: () => void;
|
||||
/** Run the action, tracking the in-flight flag; closes the prompt on success. */
|
||||
confirm: () => Promise<void>;
|
||||
/** Dismiss the prompt without acting. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic two-step confirm flow for a (usually destructive) async action.
|
||||
* `request()` opens the prompt, `confirm()` runs `action` while tracking an
|
||||
* in-flight flag and closes on success, `cancel()` dismisses it. Owns only the
|
||||
* confirm state machine — what renders the prompt (dialog, popover) is the
|
||||
* caller's concern, so it stays reusable across confirm surfaces.
|
||||
*/
|
||||
export function useConfirmableAction(
|
||||
action: () => Promise<void>,
|
||||
): ConfirmableAction {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const request = useCallback((): void => setOpen(true), []);
|
||||
const cancel = useCallback((): void => setOpen(false), []);
|
||||
const confirm = useCallback(async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
await action();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}, [action]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ open, isPending, request, confirm, cancel }),
|
||||
[open, isPending, request, confirm, cancel],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
@@ -17,10 +15,6 @@
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Allow the flex children to shrink below their content height so the
|
||||
// virtualized grid scrolls within the capped legend height instead of
|
||||
// overflowing the wrapper (default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
|
||||
&:has(.legend-item-focused) .legend-item {
|
||||
opacity: 0.3;
|
||||
@@ -39,11 +33,6 @@
|
||||
}
|
||||
|
||||
.legend-virtuoso-container {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// search box (RIGHT legend) and lets it scroll instead of growing to fit
|
||||
// every row — without this the grid overflows a BOTTOM legend's fixed height.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -55,6 +44,7 @@
|
||||
auto-fill,
|
||||
minmax(var(--legend-average-width, 240px), 1fr)
|
||||
);
|
||||
row-gap: 4px;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
@@ -78,7 +68,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@include custom-scrollbar;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +109,6 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
// Include padding within the width so a full-width row (legend-item-right) fits its
|
||||
// column instead of overflowing by the 16px horizontal padding — there is no global
|
||||
// border-box reset, so the default content-box would make it overflow.
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -87,7 +87,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
if (fillMode === FillMode.Solid) {
|
||||
lineConfig.fill = `${finalFillColor}70`;
|
||||
lineConfig.fill = finalFillColor;
|
||||
} else if (fillMode === FillMode.Gradient) {
|
||||
lineConfig.fill = (self: uPlot): CanvasGradient =>
|
||||
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--l1-background-60);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
const [isDiscardOpen, setIsDiscardOpen] = useState(false);
|
||||
|
||||
// Closing with unsaved edits prompts for confirmation; a pristine panel closes
|
||||
// straight away.
|
||||
const handleCloseClick = useCallback((): void => {
|
||||
if (isDirty) {
|
||||
setIsDiscardOpen(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [isDirty, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
suffix={<X size={14} />}
|
||||
data-testid="panel-editor-v2-close"
|
||||
onClick={handleCloseClick}
|
||||
/>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isDiscardOpen}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
setIsDiscardOpen(false);
|
||||
}
|
||||
}}
|
||||
title="Discard changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Keep editing"
|
||||
onConfirm={onClose}
|
||||
onCancel={(): void => setIsDiscardOpen(false)}
|
||||
data-testid="panel-editor-v2-discard-modal"
|
||||
>
|
||||
<Typography>Your unsaved edits to this panel will be lost.</Typography>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -1,28 +0,0 @@
|
||||
// Full-page editor: fills the route's content area as a header-over-split
|
||||
// column (the editor is its own page now, not a modal overlay).
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: var(--l1-border);
|
||||
&:hover {
|
||||
background: var(--l2-border);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
@use '../../../../../styles/scrollbar' as *;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background-color: var(--l1-background);
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
:global(.ant-tabs-tab-active) {
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
:global(.ant-tabs-nav) {
|
||||
&::before {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
.queryTypeTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.runQueryBtnContainer {
|
||||
padding: 4px 0 8px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
|
||||
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
panelType: PANEL_TYPES;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
onStageRunQuery: () => void;
|
||||
/** Abort the in-flight preview fetch (the button's cancel action). */
|
||||
onCancelQuery: () => void;
|
||||
/** Optional content pinned below the builder (e.g. the List columns editor). */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder for the V2 panel editor's left pane — the queryType tabs
|
||||
* (Query Builder / ClickHouse / PromQL) over the shared `QueryBuilderV2` and the
|
||||
* V1 ClickHouse/PromQL containers, plus the Stage & Run button. All of these
|
||||
* read/write the global `QueryBuilderProvider`; `usePanelEditorQuerySync` owns
|
||||
* seeding the provider from the panel and pushing Stage-&-Run results back into
|
||||
* the editor draft, so this component is purely the builder UI.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelType,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
footer,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
});
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
// ⌘↵ / Ctrl+↵ stages and runs the query while a query-builder field is
|
||||
// focused. The global keyboard-hotkeys provider deliberately ignores keydowns
|
||||
// originating in inputs / the query editor, so this is handled locally. Uses
|
||||
// the capture phase so it fires even for fields that stop the event from
|
||||
// bubbling (e.g. the filter search, CodeMirror) — the container sees the
|
||||
// keydown on the way down to the focused field.
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onStageRunQuery();
|
||||
}
|
||||
},
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
|
||||
[],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
icon: <Atom size={14} />,
|
||||
label: 'Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={panelType !== PANEL_TYPES.LIST}
|
||||
version="v3"
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
queryComponents={{}}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: {
|
||||
icon: <Terminal size={14} />,
|
||||
label: 'ClickHouse Query',
|
||||
component: <ClickHouseQueryContainer />,
|
||||
},
|
||||
[EQueryType.PROM]: {
|
||||
icon: (
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
),
|
||||
label: 'PromQL',
|
||||
component: <PromQLQueryContainer />,
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
key: queryType,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
{queryTypeComponents[queryType].icon}
|
||||
<Typography>{queryTypeComponents[queryType].label}</Typography>
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelType, filterConfigs, isDarkMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
data-testid="panel-editor-v2-query-builder"
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
role="presentation"
|
||||
>
|
||||
<div className={styles.scrollArea}>
|
||||
<Tabs
|
||||
type="card"
|
||||
className={styles.tabsContainer}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
<RunQueryBtn
|
||||
className="run-query-dashboard-btn"
|
||||
label="Stage & Run Query"
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={onCancelQuery}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorQueryBuilder;
|
||||
@@ -1,59 +0,0 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
|
||||
background-size: 20px 20px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.queryType {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px 4px 6px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--l3-background);
|
||||
backdrop-filter: blur(6px);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.surface {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--l2-forground);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Loader, Spline } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
|
||||
panelDef: RenderablePanelDefinition | undefined;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Drag-to-zoom on a time-axis chart → updates the (URL-synced) time window. */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Presentational: the draft panel renders through the
|
||||
* same registry the dashboard grid uses (`panelDef.Renderer`), so the preview is the
|
||||
* production renderer — only `panelMode` differs (DASHBOARD_EDIT). The query result is
|
||||
* owned by the editor root (`usePanelQuery`) and passed in, so the same result is shared
|
||||
* with the config pane.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDef,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
onDragSelect,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on render state */}
|
||||
{!panelDef ? (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
) : isLoading && !data.response ? (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
) : (
|
||||
<panelDef.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
enableDrillDown={false}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewPane;
|
||||
@@ -1,93 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorDraft } from '../usePanelEditorDraft';
|
||||
|
||||
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name, description },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorDraft', () => {
|
||||
it('exposes the panel spec and starts clean', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
expect(result.current.spec).toBe(result.current.draft.spec);
|
||||
expect(result.current.spec.display?.name).toBe('CPU');
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('flags dirty and writes through on a display (title) edit via setSpec', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
display: { ...result.current.spec.display, name: 'Memory' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
expect(result.current.draft.spec?.display?.name).toBe('Memory');
|
||||
});
|
||||
|
||||
it('flags dirty on a plugin-spec (non-display) edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'bytes' } },
|
||||
},
|
||||
} as typeof result.current.spec),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
expect(
|
||||
(
|
||||
result.current.draft.spec?.plugin?.spec as {
|
||||
formatting?: { unit?: string };
|
||||
}
|
||||
)?.formatting?.unit,
|
||||
).toBe('bytes');
|
||||
});
|
||||
|
||||
it('does not flag spec-dirty when only spec.queries changes (owned by the builder)', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
queries: [{ id: 'committed-by-builder' }],
|
||||
} as unknown as typeof result.current.spec),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('reset restores the spec and clears dirty after an edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'ms' } },
|
||||
},
|
||||
} as typeof result.current.spec),
|
||||
);
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
expect(result.current.spec.display?.name).toBe('CPU');
|
||||
});
|
||||
});
|
||||
@@ -1,331 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
|
||||
useShareBuilderUrl: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
getIsQueryModified: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
fromPerses: jest.fn(),
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
// commitQuery's no-op guard compares queries at the envelope level; with the
|
||||
// adapters mocked, unwrap identity-style so the opaque fixtures stay distinct
|
||||
// (CONVERTED vs SAVED) and the commit decisions are what's under test.
|
||||
jest.mock('../../../queryV5/buildQueryRangeRequest', () => ({
|
||||
toQueryEnvelopes: jest.fn((queries: unknown) => queries),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockUseShareBuilderUrl = useShareBuilderUrl as unknown as jest.Mock;
|
||||
const mockGetIsQueryModified = getIsQueryModified as unknown as jest.Mock;
|
||||
const mockFromPerses = fromPerses as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
|
||||
// Opaque fixtures — the adapters are mocked, so only identity matters here.
|
||||
const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
|
||||
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
|
||||
|
||||
function makeDraft(
|
||||
queries = SAVED_QUERIES,
|
||||
kind = 'signoz/TimeSeriesPanel',
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
function builderState(
|
||||
overrides: Partial<{
|
||||
currentQuery: Query;
|
||||
stagedQuery: Query | null;
|
||||
handleRunQuery: jest.Mock;
|
||||
}> = {},
|
||||
): {
|
||||
currentQuery: Query;
|
||||
stagedQuery: Query | null;
|
||||
handleRunQuery: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
|
||||
stagedQuery: STAGED_V1,
|
||||
handleRunQuery: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePanelEditorQuerySync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFromPerses.mockReturnValue(SEED_V1);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
mockUseQueryBuilder.mockReturnValue(builderState());
|
||||
});
|
||||
|
||||
function setup(
|
||||
opts: {
|
||||
draft?: DashboardtypesPanelDTO;
|
||||
setSpec?: jest.Mock;
|
||||
refetch?: jest.Mock;
|
||||
} = {},
|
||||
): {
|
||||
result: {
|
||||
current: {
|
||||
runQuery: () => void;
|
||||
isQueryDirty: boolean;
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
};
|
||||
};
|
||||
setSpec: jest.Mock;
|
||||
refetch: jest.Mock;
|
||||
rerender: () => void;
|
||||
} {
|
||||
const setSpec = opts.setSpec ?? jest.fn();
|
||||
const refetch = opts.refetch ?? jest.fn();
|
||||
const draft = opts.draft ?? makeDraft();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
refetch,
|
||||
}),
|
||||
);
|
||||
return { result, setSpec, refetch, rerender };
|
||||
}
|
||||
|
||||
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
|
||||
setup();
|
||||
expect(mockFromPerses).toHaveBeenCalledWith(
|
||||
SAVED_QUERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
|
||||
defaultValue: SEED_V1,
|
||||
forceReset: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch the draft on mount for an unedited panel', () => {
|
||||
const { setSpec, refetch } = setup();
|
||||
// Mount runs the type-change effect once; an unedited query must no-op.
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('compares the live query against the saved query (seed), not the staged query', () => {
|
||||
const currentQuery = { id: 'current', queryType: 'builder' } as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery }));
|
||||
|
||||
const { result } = setup();
|
||||
result.current.runQuery();
|
||||
|
||||
// Baseline is the saved seed — a stale staged/URL query must not be the
|
||||
// reference, or a real datasource switch would read as "unchanged".
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(currentQuery, SEED_V1);
|
||||
});
|
||||
|
||||
describe('runQuery', () => {
|
||||
it('stages the query (handleRunQuery)', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ handleRunQuery }));
|
||||
const { result } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(handleRunQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits a modified query into the draft and does not force a refetch', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces a refetch and leaves the draft alone when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits a datasource switch even when the staged query is stale (no revert to saved)', () => {
|
||||
// A stale staged query (e.g. URL-restored after refresh) must not be used
|
||||
// as the baseline; the switch is detected against the saved seed and the
|
||||
// live query is committed so the preview fetches it.
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ stagedQuery: null }));
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query-type switch', () => {
|
||||
it('commits the active query when the query type changes', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Switch query type → the effect should commit.
|
||||
state.currentQuery = { id: 'b', queryType: 'promql' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when the active query type is unchanged', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Same query type, different object → effect must not re-fire.
|
||||
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('datasource switch', () => {
|
||||
const withSource = (id: string, dataSource: string): Query =>
|
||||
({
|
||||
id,
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ dataSource }] },
|
||||
}) as unknown as Query;
|
||||
|
||||
it('commits the active query when a query datasource changes', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'logs') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Switch datasource logs → traces → the effect should commit (→ refetch).
|
||||
state.currentQuery = withSource('b', 'traces');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when the datasource is unchanged', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'logs') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Same datasource, different object → effect must not re-fire.
|
||||
state.currentQuery = withSource('b', 'logs');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
|
||||
// Baseline is the builder's own normalized staged query — immune to the
|
||||
// raw-seed vs builder-normalized serialization drift.
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
STAGED_V1,
|
||||
);
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is not query-dirty when the live query matches the baseline', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('buildSaveSpec bakes the live query in when dirty', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toBe(spec);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorSave } from '../usePanelEditorSave';
|
||||
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
usePatchDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
|
||||
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
const mutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
const spec = {
|
||||
display: { name: 'New title', description: 'desc' },
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'bytes' } },
|
||||
},
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'dash-1' },
|
||||
data: [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec',
|
||||
value: spec,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith([
|
||||
'/api/v2/dashboards/dash-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces the mutation loading state as isSaving', () => {
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
expect(result.current.isSaving).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import type { PanelEditorDraftApi } from '../types';
|
||||
|
||||
/**
|
||||
* Owns the editable draft of a single panel. Seeded once from the loaded panel
|
||||
* (`useState` initializer), then mutated locally until the user saves. Keeping
|
||||
* the draft in the perses `DashboardtypesPanelDTO` shape lets the preview pane
|
||||
* render it through the same renderer registry the dashboard uses, and lets the
|
||||
* save hook patch it without any conversion.
|
||||
*
|
||||
* Everything the config pane edits — title/description, the per-kind plugin spec
|
||||
* (formatting, axes, …), legend colors, context links — flows through the single
|
||||
* `spec`/`setSpec` pair (the ConfigPane registry lens), so there is one editing path.
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
const setSpec = useCallback((next: DashboardtypesPanelSpecDTO): void => {
|
||||
setDraft((prev) => ({ ...prev, spec: next }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(initialPanel);
|
||||
}, [initialPanel]);
|
||||
|
||||
// Deep compare, ignoring `spec.queries`: the live query is owned by the shared
|
||||
// query builder and re-serialized into the draft purely as a preview cache, so
|
||||
// its representation drifts (builder-filled defaults, regenerated ids, wrapper
|
||||
// kind) without a real edit. Query dirtiness is tracked separately against the
|
||||
// builder; here we only flag divergence in display + plugin spec slices
|
||||
// (formatting, axes, thresholds, links, list columns, …).
|
||||
const isSpecDirty = useMemo(
|
||||
() =>
|
||||
!isEqual(
|
||||
{ ...draft, spec: { ...draft.spec, queries: null } },
|
||||
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
|
||||
),
|
||||
[draft, initialPanel],
|
||||
);
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec: draft.spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import { fromPerses, toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
interface UsePanelEditorQuerySyncArgs {
|
||||
draft: DashboardtypesPanelDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
/** Run the current query (Stage & Run / ⌘↵). */
|
||||
runQuery: () => void;
|
||||
/**
|
||||
* True when the live builder query differs from the saved query. Compared
|
||||
* builder-normalized vs builder-normalized (against the builder's own baseline),
|
||||
* so re-serialization noise never reads as an edit.
|
||||
*/
|
||||
isQueryDirty: boolean;
|
||||
/**
|
||||
* Bake the live query into a spec for saving — so unstaged edits still persist.
|
||||
* Returns the spec untouched when the query is unchanged from saved.
|
||||
*/
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the shared query builder (global `QueryBuilderProvider`, URL-synced) and
|
||||
* the V2 editor draft: seeds the builder from the saved panel, then commits the
|
||||
* active query into `draft.spec.queries` (what the preview fetches) on a query-type
|
||||
* or datasource switch and on Stage & Run.
|
||||
*/
|
||||
export function usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
|
||||
const seedQuery = useMemo(
|
||||
() => fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType],
|
||||
);
|
||||
// Open the builder from the SAVED panel, discarding any stale URL query left by
|
||||
// a prior edit/refresh — otherwise the QB shows the URL query while the preview
|
||||
// keeps fetching the saved one, and the dirty baseline gets captured from the URL
|
||||
// (so switching back to that datasource reads as "unchanged" and never commits).
|
||||
// Force-reset on the first render only; after that the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
forceReset: isInitialRenderRef.current,
|
||||
});
|
||||
useEffect(() => {
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty
|
||||
// check compares against the SAVED query (`seedQuery`), not the URL-synced
|
||||
// staged query — a staged query can carry stale state across a refresh, which
|
||||
// would make a real datasource switch read as "unchanged" and silently revert.
|
||||
// Unchanged from saved → restore the saved queries (don't dirty the draft);
|
||||
// changed → commit the live query. Returns whether the draft changed.
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
? toPerses(query, panelType)
|
||||
: savedQueries;
|
||||
// No-op guard at the V5 envelope level: an unchanged query re-serialized
|
||||
// to a different but equivalent wrapper (a bare `signoz/BuilderQuery` vs a
|
||||
// `signoz/CompositeQuery`) unwraps to the same envelopes — comparing the
|
||||
// wrappers structurally would falsely dirty the draft on Stage & Run.
|
||||
const current = draft.spec?.queries ?? [];
|
||||
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
|
||||
return false;
|
||||
}
|
||||
setSpec({ ...draft.spec, queries: next });
|
||||
return true;
|
||||
},
|
||||
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
|
||||
);
|
||||
|
||||
// Latest query/commit, read by the structural-change effect without re-subscribing.
|
||||
const commitRef = useRef(commitQuery);
|
||||
commitRef.current = commitQuery;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type or datasource switch so the preview refetches the
|
||||
// structurally-changed query. Skip mount: the draft already holds the saved
|
||||
// queries and the builder is being force-reset to them.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
[currentQuery.builder],
|
||||
);
|
||||
const didMountRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
return;
|
||||
}
|
||||
commitRef.current(queryRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so the same query
|
||||
// can be re-run.
|
||||
const runQuery = useCallback((): void => {
|
||||
handleRunQuery();
|
||||
if (!commitQuery(currentQuery)) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline for the query: the builder's OWN normalized form of the saved
|
||||
// query — the first non-null `stagedQuery` the builder produces after the mount
|
||||
// force-reset. Capturing it (instead of the raw `seedQuery`) makes the dirty
|
||||
// check builder-normalized vs builder-normalized, immune to the serialization
|
||||
// drift that otherwise reads an untouched query as modified. Held in state (not
|
||||
// a ref) so capturing it re-renders and `isQueryDirty` recomputes; captured once
|
||||
// and never moved by Stage & Run, so it stays anchored to the saved query.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
setQueryBaseline(stagedQuery);
|
||||
}
|
||||
}, [queryBaseline, stagedQuery]);
|
||||
|
||||
const isQueryDirty =
|
||||
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
isQueryDirty
|
||||
? { ...spec, queries: toPerses(currentQuery, panelType) }
|
||||
: spec,
|
||||
[isQueryDirty, currentQuery, panelType],
|
||||
);
|
||||
|
||||
return { runQuery, isQueryDirty, buildSaveSpec };
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch.
|
||||
*
|
||||
* Replaces the whole panel spec in one `add` op against `/spec/panels/{panelId}/spec`
|
||||
* with the editor's draft spec — so every edit the config pane makes (display,
|
||||
* formatting/axes/legend/chart-appearance under `plugin.spec`, `legend.customColors`,
|
||||
* context links) is persisted, not just the title/description. `add` doubles as
|
||||
* create-or-replace, so panels that loaded without a sub-object are handled without a
|
||||
* separate existence check. The draft carries `queries` unchanged until the V2 query
|
||||
* builder lands, so replacing the whole spec is safe.
|
||||
*/
|
||||
export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
);
|
||||
},
|
||||
[dashboardId, panelId, mutateAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor page body. Rendered by the `DASHBOARD_PANEL_EDITOR` route
|
||||
* (`PanelEditorPage`) as a full page — a resizable split holds the live preview
|
||||
* + query builder on the left and the configuration pane on the right. Owns the
|
||||
* draft state and the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
const {
|
||||
defaultLayout: mainDefaultLayout,
|
||||
onLayoutChanged: onMainLayoutChanged,
|
||||
} = useDefaultLayout({
|
||||
id: 'panel-editor-v2-main',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
// Panel kind → V1 panel type (drives the query builder + preview).
|
||||
const fullKind = draft.spec?.plugin?.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor: the preview renders it.
|
||||
const panelDef = getPanelDefinition(draft.spec?.plugin?.kind);
|
||||
const { data, isLoading, isFetching, error, cancelQuery, refetch } =
|
||||
usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
});
|
||||
|
||||
// Seed the shared query builder from the draft and expose the Stage-&-Run
|
||||
// action (writes the query into the draft → preview re-fetches, or forces a
|
||||
// re-fetch when unchanged).
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
});
|
||||
|
||||
// Dirty = an edited config slice (display/plugin spec) OR an edited query. The
|
||||
// two are tracked independently so query re-serialization never false-dirties.
|
||||
const isDirty = isSpecDirty || isQueryDirty;
|
||||
|
||||
// Drag-to-zoom on the preview chart updates the (URL-synced) time window,
|
||||
// exactly as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [save, buildSaveSpec, draft.spec, onSaved]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
|
||||
<div className={styles.left}>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2-main"
|
||||
orientation="vertical"
|
||||
defaultLayout={mainDefaultLayout}
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDef={panelDef}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelType={panelType}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel
|
||||
minSize="20%"
|
||||
maxSize="25%"
|
||||
defaultSize="20%"
|
||||
className={styles.right}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorContainer;
|
||||
@@ -1,17 +0,0 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
|
||||
/**
|
||||
* `Storage`-shaped adapter (just `getItem`/`setItem`, which is all
|
||||
* `useDefaultLayout` consumes) backed by the scoped localStorage wrappers. The
|
||||
* wrappers prefix keys with the URL base path, so the persisted resizable
|
||||
* layout stays isolated per deployment instead of touching the raw global.
|
||||
*/
|
||||
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
|
||||
getItem: (key: string): string | null => getLocalStorageApi(key),
|
||||
setItem: (key: string, value: string): void => {
|
||||
setLocalStorageApi(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
export default layoutStorage;
|
||||
@@ -1,30 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Local draft state for the panel being edited. The draft is kept as a perses
|
||||
* `DashboardtypesPanelDTO` so the live preview (which feeds the panel renderer)
|
||||
* and the save patch share a single shape — no intermediate translation.
|
||||
*/
|
||||
export interface PanelEditorDraftApi {
|
||||
/** The current (possibly edited) panel. Always a defined object once seeded. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/**
|
||||
* The panel spec (`draft.spec`) — the single editing surface for the config pane.
|
||||
* Title/description live at `spec.display`; the section registry reads its slices
|
||||
* from here (plugin-level via `spec.plugin.spec.<key>`, panel-level via `spec.links`).
|
||||
*/
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
/** Replace the whole panel spec (the registry lens returns a new one per edit). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/**
|
||||
* True when the draft's display/plugin-spec slices diverge from the loaded
|
||||
* panel. Excludes `spec.queries` — the query is owned by the shared builder and
|
||||
* its dirtiness is tracked there (`usePanelEditorQuerySync.isQueryDirty`).
|
||||
*/
|
||||
isSpecDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
|
||||
interface NoDataProps {
|
||||
/** Message to display. Defaults to "No data". */
|
||||
label?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared empty-state for panel renderers, shown when a query resolves but
|
||||
* returns nothing to plot. Centred in the panel body so every panel kind
|
||||
* surfaces the same "No data" affordance instead of each renderer (or its
|
||||
* underlying chart) inventing its own copy and casing.
|
||||
*/
|
||||
function NoData({
|
||||
label = 'No data',
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.noData} data-testid={testId}>
|
||||
<Typography.Text className={styles.noDataText}>{label}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Builds a record keyed by builder-query name to that query's groupBy keys
|
||||
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
|
||||
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
|
||||
* call site that needs the V1 shape; the rest of V2 panel code stays on
|
||||
* v5 types.
|
||||
*/
|
||||
export function useGroupByPerQuery(
|
||||
builderQueries: BuilderQuery[],
|
||||
): Record<string, BaseAutocompleteData[]> {
|
||||
return useMemo(() => {
|
||||
const result: Record<string, BaseAutocompleteData[]> = {};
|
||||
builderQueries.forEach((q) => {
|
||||
if (!q.name) {
|
||||
return;
|
||||
}
|
||||
result[q.name] = (q.groupBy ?? []).map((g) => ({
|
||||
key: g.name,
|
||||
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
|
||||
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
|
||||
id: '',
|
||||
}));
|
||||
});
|
||||
return result;
|
||||
}, [builderQueries]);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const MIN_FONT_PX = 16;
|
||||
const MAX_FONT_PX = 60;
|
||||
// The value font is sized to a fraction of the container's smaller dimension so
|
||||
// it scales with the panel without overflowing.
|
||||
const FONT_SCALE_DIVISOR = 5;
|
||||
|
||||
/**
|
||||
* Sizes a single large value to its container, recomputing on resize via a
|
||||
* ResizeObserver. Returns the ref to attach to the container and the current
|
||||
* font size (px) to apply to the value text.
|
||||
*/
|
||||
export function useResponsiveFontSize(): {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
fontSize: string;
|
||||
} {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSize, setFontSize] = useState('2.5vw');
|
||||
|
||||
useEffect(() => {
|
||||
const updateFontSize = (): void => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
const minDimension = Math.min(width, height);
|
||||
const newSize = Math.max(
|
||||
Math.min(minDimension / FONT_SCALE_DIVISOR, MAX_FONT_PX),
|
||||
MIN_FONT_PX,
|
||||
);
|
||||
setFontSize(`${newSize}px`);
|
||||
};
|
||||
|
||||
updateFontSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateFontSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { containerRef, fontSize };
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildBarChartConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data (falls back
|
||||
// to the global picker inside the helper). The generated request DTO is
|
||||
// structurally the hand-written V5 request; the cast is the boundary.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
const groupByPerQuery = useGroupByPerQuery(builderQueries);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildBarChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
stepIntervals: getExecStats(data.response)?.stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
flatSeries,
|
||||
data.response,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(() => {
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
// The uPlot key prop is the only way to force a full teardown and re-mount
|
||||
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
|
||||
// to these preferences trigger a fresh chart instance, preventing stale
|
||||
// sync wiring from being inherited.
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={key}
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarPanelRenderer;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
},
|
||||
headerControls: { search: false },
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// Bar stacking lives in `visualization.stackedBarChart` (a different spec key from the
|
||||
// time-series `chartAppearance`), so it's a control on the `visualization` section, not
|
||||
// `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { toClickPluginPayload } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface BuildBarChartConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
/**
|
||||
* Flat list of builder queries on this panel (see `getBuilderQueries`).
|
||||
* Powers per-query legend resolution; empty for non-builder panels.
|
||||
*/
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
|
||||
*
|
||||
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
|
||||
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
|
||||
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
|
||||
* one bar series per result row.
|
||||
*/
|
||||
export function buildBarChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
isLogScale: spec.axes?.isLogScale,
|
||||
softMin: spec.axes?.softMin ?? undefined,
|
||||
softMax: spec.axes?.softMax ?? undefined,
|
||||
formatting: spec.formatting,
|
||||
thresholds: spec.thresholds,
|
||||
stepIntervals,
|
||||
clickPayload: toClickPluginPayload(series),
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
});
|
||||
|
||||
addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
|
||||
* when `spec.visualization.stackedBarChart` is set. Each series receives its
|
||||
* own per-query step interval so bar widths line up with the actual
|
||||
* sampling cadence reported by the backend.
|
||||
*
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
|
||||
if (spec.visualization?.stackedBarChart) {
|
||||
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
|
||||
// `+1` keeps the band targets aligned with the series we're about to add.
|
||||
builder.setBands(getInitialStackedBands(series.length + 1));
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
const stepInterval = s.queryName ? stepIntervals?.[s.queryName] : undefined;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping,
|
||||
isDarkMode,
|
||||
stepInterval,
|
||||
metric: s.labels,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { resolveLegendPosition } from '../../utils/chartAppearance/resolvers';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildHistogramConfig } from './utils/buildConfig';
|
||||
import { prepareHistogramData } from './prepareData';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildHistogramConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
}),
|
||||
[panelId, spec, builderQueries, flatSeries, isDarkMode, timezone, panelMode],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
prepareHistogramData({
|
||||
series: flatSeries,
|
||||
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
|
||||
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
|
||||
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
|
||||
}),
|
||||
[
|
||||
flatSeries,
|
||||
spec.histogramBuckets?.bucketWidth,
|
||||
spec.histogramBuckets?.bucketCount,
|
||||
spec.histogramBuckets?.mergeAllActiveQueries,
|
||||
],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(
|
||||
() => resolveLegendPosition(spec.legend?.position),
|
||||
[spec.legend?.position],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter
|
||||
id={panelId}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
canDrilldown={false}
|
||||
/>
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
<Histogram
|
||||
key={panelId}
|
||||
config={config}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
canPinTooltip
|
||||
isQueriesMerged={isQueriesMerged}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanelRenderer;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
},
|
||||
headerControls: { search: false },
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
import { histogramBucketSizes } from '@grafana/data';
|
||||
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
|
||||
import {
|
||||
buildHistogramBuckets,
|
||||
mergeAlignedDataTables,
|
||||
prependNullBinToFirstHistogramSeries,
|
||||
replaceUndefinedWithNullInAlignedData,
|
||||
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { AlignedData } from 'uplot';
|
||||
import { incrRoundDn, roundDecimals } from 'utils/round';
|
||||
|
||||
export interface PrepareHistogramDataArgs {
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
bucketWidth?: number;
|
||||
bucketCount?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
}
|
||||
|
||||
const BUCKET_OFFSET = 0;
|
||||
const sortAscending = (a: number, b: number): number => a - b;
|
||||
|
||||
/**
|
||||
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
|
||||
* either from `bucketWidth` (explicit override) or the smallest predefined
|
||||
* Grafana bucket that fits the data's `range / bucketCount` target while
|
||||
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
|
||||
* the resolution of the input).
|
||||
*
|
||||
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
|
||||
*/
|
||||
export function prepareHistogramData({
|
||||
series,
|
||||
bucketWidth,
|
||||
bucketCount = DEFAULT_BUCKET_COUNT,
|
||||
mergeAllActiveQueries = false,
|
||||
}: PrepareHistogramDataArgs): AlignedData {
|
||||
const values = extractNumericValues(series);
|
||||
if (values.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const sorted = [...values].sort(sortAscending);
|
||||
const range = sorted[sorted.length - 1] - sorted[0];
|
||||
const smallestDelta = computeSmallestDelta(sorted);
|
||||
let bucketSize = selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride: bucketWidth,
|
||||
});
|
||||
if (bucketSize <= 0) {
|
||||
bucketSize = range > 0 ? range / bucketCount : 1;
|
||||
}
|
||||
|
||||
const getBucket = (v: number): number =>
|
||||
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
|
||||
|
||||
const frames = buildFrames(series, mergeAllActiveQueries);
|
||||
// Merged mode folds every query into frame 0 and leaves trailing empty
|
||||
// frames — drop those. Per-query mode must keep one column per result row
|
||||
// (even empty queries), or the data column count drifts below the series
|
||||
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
|
||||
const histograms: AlignedData[] = frames
|
||||
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
|
||||
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
|
||||
|
||||
if (histograms.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const merged = mergeAlignedDataTables(histograms);
|
||||
replaceUndefinedWithNullInAlignedData(merged);
|
||||
prependNullBinToFirstHistogramSeries(merged, bucketSize);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
|
||||
function toBinnableValue(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function extractNumericValues(series: PanelSeries[]): number[] {
|
||||
const values: number[] = [];
|
||||
for (const s of series) {
|
||||
for (const point of s.values) {
|
||||
values.push(toBinnableValue(point.value));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function computeSmallestDelta(sortedValues: number[]): number {
|
||||
if (sortedValues.length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
let smallest = Infinity;
|
||||
for (let i = 1; i < sortedValues.length; i++) {
|
||||
const delta = sortedValues[i] - sortedValues[i - 1];
|
||||
if (delta > 0) {
|
||||
smallest = Math.min(smallest, delta);
|
||||
}
|
||||
}
|
||||
return smallest === Infinity ? 0 : smallest;
|
||||
}
|
||||
|
||||
function selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride,
|
||||
}: {
|
||||
range: number;
|
||||
bucketCount: number;
|
||||
smallestDelta: number;
|
||||
bucketWidthOverride?: number;
|
||||
}): number {
|
||||
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
|
||||
return bucketWidthOverride;
|
||||
}
|
||||
const targetSize = range / bucketCount;
|
||||
for (const candidate of histogramBucketSizes) {
|
||||
if (targetSize < candidate && candidate >= smallestDelta) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// When merging is on, fold all frames into the first; the trailing empty
|
||||
// frames stay in the array so downstream `.filter(length > 0)` drops them.
|
||||
function buildFrames(
|
||||
series: PanelSeries[],
|
||||
mergeAllActiveQueries: boolean,
|
||||
): number[][] {
|
||||
const frames: number[][] = series.map((s) =>
|
||||
s.values.map((point) => toBinnableValue(point.value)),
|
||||
);
|
||||
if (mergeAllActiveQueries && frames.length > 1) {
|
||||
const first = frames[0];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
first.push(...frames[i]);
|
||||
frames[i] = [];
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'legend',
|
||||
controls: { position: true },
|
||||
// Merging all queries collapses the histogram to a single distribution with no
|
||||
// legend — so hide the legend settings when that's on.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
(spec.plugin?.spec as DashboardtypesHistogramPanelSpecDTO | undefined)
|
||||
?.histogramBuckets?.mergeAllActiveQueries,
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'buckets',
|
||||
controls: { count: true, width: true, mergeQueries: true },
|
||||
},
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const POINT_SIZE = 5;
|
||||
const BAR_WIDTH_FACTOR = 1;
|
||||
// Merged-series colors mirror the V1 default — single histogram bin gets a
|
||||
// fixed blue-ish pair so the merged view looks the same as before.
|
||||
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
|
||||
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
|
||||
|
||||
export interface BuildHistogramConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesHistogramPanelSpecDTO;
|
||||
/** Builder queries on this panel — used to resolve per-series labels. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
|
||||
*
|
||||
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
|
||||
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
|
||||
* axes, click plugin) but then override the X/Y scales to be auto-linear
|
||||
* (`time: false, auto: true`) and install a histogram-specific cursor that
|
||||
* disables drag-pan and tightens focus proximity.
|
||||
*/
|
||||
export function buildHistogramConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
|
||||
// Histograms have no time axis — no stepIntervals, and no click plugin
|
||||
// (the renderer passes no onClick), so the base config needs no response.
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
});
|
||||
|
||||
builder.setCursor({
|
||||
drag: { x: false, y: false, setScale: true },
|
||||
focus: { prox: 1e3 },
|
||||
});
|
||||
|
||||
// Override the time-axis scales from `buildBaseConfig` — histograms are
|
||||
// distribution plots, not time series.
|
||||
builder.addScale({ scaleKey: 'x', time: false, auto: true });
|
||||
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
|
||||
|
||||
addSeries({ builder, spec, builderQueries, series, isDarkMode });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesHistogramPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
|
||||
* set, `prepareHistogramData` produces a single Y column, so we add exactly
|
||||
* one series with the fixed merged-mode colors. Otherwise one series per
|
||||
* result row, with labels resolved via the standard legend matrix.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
const mergeAllActiveQueries =
|
||||
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
if (mergeAllActiveQueries) {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label: '',
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
colorMapping,
|
||||
barWidthFactor: BAR_WIDTH_FACTOR,
|
||||
pointSize: POINT_SIZE,
|
||||
lineColor: MERGED_SERIES_LINE_COLOR,
|
||||
fillColor: MERGED_SERIES_FILL_COLOR,
|
||||
isDarkMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label,
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
colorMapping,
|
||||
barWidthFactor: BAR_WIDTH_FACTOR,
|
||||
pointSize: POINT_SIZE,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { formatPanelValue } from '../../utils/formatPanelValue';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
|
||||
import { prepareNumberData } from './prepareData';
|
||||
import { mapNumberThresholds } from './utils';
|
||||
import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() =>
|
||||
prepareNumberData(
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
),
|
||||
[data.response, data.legendMap, data.requestPayload],
|
||||
);
|
||||
|
||||
const thresholds = useMemo(
|
||||
() => mapNumberThresholds(spec.thresholds),
|
||||
[spec.thresholds],
|
||||
);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const unit = spec.formatting?.unit;
|
||||
|
||||
// Precision is applied regardless of whether a unit is set (see
|
||||
// `formatPanelValue`), so decimal-precision changes always take effect.
|
||||
const formattedValue = useMemo(
|
||||
() => (value === null ? '' : formatPanelValue(value, unit, decimalPrecision)),
|
||||
[value, unit, decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="number-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" />
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
rawValue={value}
|
||||
thresholds={thresholds}
|
||||
unit={unit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberPanelRenderer;
|
||||
@@ -1,163 +0,0 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesNumberPanelSpecDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import BaseNumberPanelRenderer from '../Renderer';
|
||||
|
||||
// The kind's interaction map is `Record<string, never>`, which makes the strict
|
||||
// `PanelRendererProps<'signoz/NumberPanel'>` intersection impossible to satisfy
|
||||
// with a literal. NumberPanel reads no interaction props, so render it against
|
||||
// the base prop surface.
|
||||
const NumberPanelRenderer =
|
||||
BaseNumberPanelRenderer as React.FC<BaseRendererProps>;
|
||||
|
||||
// ValueDisplay observes its container to size the font.
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesNumberPanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/NumberPanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
// V5 scalar response: one table per query, value in the aggregation column.
|
||||
function dataWith(value: string | number): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: [
|
||||
{
|
||||
name: '__result',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: [[value]],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyData: PanelQueryData = {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: { type: 'scalar', data: { results: [] } },
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
|
||||
// `data` is always present per the renderer contract; an absent fetch surfaces
|
||||
// as a missing `response`, not a missing `data`.
|
||||
const absentResponseData: PanelQueryData = {
|
||||
response: undefined,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
|
||||
// NumberPanel adds no interaction props (its interaction map is
|
||||
// `Record<string, never>`), so the base renderer props fully describe it.
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
...props,
|
||||
};
|
||||
return render(<NumberPanelRenderer {...baseProps} />);
|
||||
}
|
||||
|
||||
describe('NumberPanelRenderer', () => {
|
||||
it('renders the value with its y-axis unit', () => {
|
||||
const { getByText } = renderPanel({
|
||||
panel: panelWith({ formatting: { unit: 'ms' } }),
|
||||
data: dataWith('295.4299833508185'),
|
||||
});
|
||||
|
||||
expect(getByText('295.43')).toBeInTheDocument();
|
||||
expect(getByText('ms')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression: with no unit configured, decimal precision must still apply.
|
||||
// Previously the renderer fell back to `value.toString()` whenever the unit
|
||||
// was empty, so precision changes had no effect on unitless panels.
|
||||
it('applies decimal precision even when no unit is set', () => {
|
||||
const { getByText, queryByText } = renderPanel({
|
||||
panel: panelWith({}),
|
||||
data: dataWith('3.14159'),
|
||||
});
|
||||
|
||||
expect(getByText('3.14')).toBeInTheDocument();
|
||||
expect(queryByText('3.14159')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders No Data when the response has no scalar results', () => {
|
||||
const { getByTestId } = renderPanel({ data: emptyData });
|
||||
|
||||
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders No Data when the response is absent', () => {
|
||||
const { getByTestId } = renderPanel({ data: absentResponseData });
|
||||
|
||||
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the conflicting-thresholds indicator when a value matches multiple thresholds', () => {
|
||||
const { getByTestId } = renderPanel({
|
||||
panel: panelWith({
|
||||
thresholds: [
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 0,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
{
|
||||
color: '#0f0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 100,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
],
|
||||
}),
|
||||
data: dataWith('295.4299833508185'),
|
||||
});
|
||||
|
||||
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { prepareNumberData } from '../prepareData';
|
||||
|
||||
function tableWith(
|
||||
columns: PanelTable['columns'],
|
||||
rows: PanelTable['rows'],
|
||||
): PanelTable {
|
||||
return { queryName: 'A', legend: '', columns, rows };
|
||||
}
|
||||
|
||||
describe('prepareNumberData', () => {
|
||||
it('returns null for no tables', () => {
|
||||
expect(prepareNumberData([])).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the first row of the value column', () => {
|
||||
const table = tableWith(
|
||||
[
|
||||
{ name: 'group', queryName: 'A', isValueColumn: false, id: 'group' },
|
||||
{ name: 'value', queryName: 'A', isValueColumn: true, id: 'val' },
|
||||
],
|
||||
[
|
||||
{ data: { group: 'prod', val: '295.4299833508185' } },
|
||||
{ data: { group: 'dev', val: '7' } },
|
||||
],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBeCloseTo(295.43, 2);
|
||||
});
|
||||
|
||||
it('falls back to the row first value when no column is tagged isValueColumn', () => {
|
||||
const table = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: false, id: 'value' }],
|
||||
[{ data: { value: '7' } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBe(7);
|
||||
});
|
||||
|
||||
it('skips empty tables and reads the first one with rows', () => {
|
||||
const empty = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
|
||||
[],
|
||||
);
|
||||
const filled = tableWith(
|
||||
[{ name: 'value', queryName: 'B', isValueColumn: true, id: 'B' }],
|
||||
[{ data: { B: 42 } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([empty, filled])).toBe(42);
|
||||
});
|
||||
|
||||
it('returns null when the value is non-numeric', () => {
|
||||
const table = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
|
||||
[{ data: { A: 'n/a' } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { mapNumberThresholds } from '../utils';
|
||||
|
||||
describe('mapNumberThresholds', () => {
|
||||
it('returns [] for null / undefined / empty', () => {
|
||||
expect(mapNumberThresholds(null)).toStrictEqual([]);
|
||||
expect(mapNumberThresholds(undefined)).toStrictEqual([]);
|
||||
expect(mapNumberThresholds([])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('maps comparison operators to symbol operators', () => {
|
||||
const thresholds: DashboardtypesComparisonThresholdDTO[] = [
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
color: '#0f0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
color: '#00f',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above_or_equal,
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
color: '#ff0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below_or_equal,
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
color: '#0ff',
|
||||
operator: DashboardtypesComparisonOperatorDTO.equal,
|
||||
value: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const mapped = mapNumberThresholds(thresholds);
|
||||
|
||||
expect(mapped.map((t) => t.operator)).toStrictEqual([
|
||||
'>',
|
||||
'<',
|
||||
'>=',
|
||||
'<=',
|
||||
'=',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps not_equal to !=', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.not_equal,
|
||||
value: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0].operator).toBe('!=');
|
||||
});
|
||||
|
||||
it('maps format and carries value/unit/color', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#abcdef',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 100,
|
||||
unit: 'ms',
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0]).toStrictEqual({
|
||||
color: '#abcdef',
|
||||
operator: '>',
|
||||
value: 100,
|
||||
unit: 'ms',
|
||||
format: 'background',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps text format to text', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#000',
|
||||
value: 1,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0].format).toBe('text');
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.valueText {
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.conflictBackground {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
|
||||
.conflictText {
|
||||
margin-left: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.conflictIcon {
|
||||
color: var(--warning-background);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from 'antd';
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { PanelThreshold } from '../../../../types/threshold';
|
||||
import { resolveActiveThreshold } from '../../../../utils/evaluateThresholds';
|
||||
|
||||
import { parseFormattedValue } from '../../../../utils/parseFormattedValue';
|
||||
import styles from './ValueDisplay.module.scss';
|
||||
import { useResponsiveFontSize } from '../../../../hooks/useResponsiveFontSize';
|
||||
import ValueUnit from '../ValueUnit/ValueUnit';
|
||||
|
||||
interface ValueDisplayProps {
|
||||
/** The pre-formatted value string (may include a unit label). */
|
||||
value: string;
|
||||
/** The raw numeric value, used for threshold evaluation. */
|
||||
rawValue: number;
|
||||
thresholds: PanelThreshold[];
|
||||
/** The panel's unit, used to convert threshold units before comparison. */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single large scalar with optional prefix/suffix units and threshold
|
||||
* recoloring (text or background). A V2-native replacement for the V1
|
||||
* `ValueGraph` — depends only on V2 threshold utilities and the shared icon/
|
||||
* typography primitives.
|
||||
*/
|
||||
function ValueDisplay({
|
||||
value,
|
||||
rawValue,
|
||||
thresholds,
|
||||
unit,
|
||||
}: ValueDisplayProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
const { containerRef, fontSize } = useResponsiveFontSize();
|
||||
|
||||
const { numericValue, prefixUnit, suffixUnit } = useMemo(
|
||||
() => parseFormattedValue(value),
|
||||
[value],
|
||||
);
|
||||
|
||||
const { threshold, isConflicting } = useMemo(
|
||||
() => resolveActiveThreshold(thresholds, rawValue, unit),
|
||||
[thresholds, rawValue, unit],
|
||||
);
|
||||
|
||||
const isBackground = threshold?.format === 'background';
|
||||
const textColor = threshold?.format === 'text' ? threshold.color : undefined;
|
||||
const backgroundColor = isBackground ? threshold?.color : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.container}
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<div className={styles.textContainer}>
|
||||
{prefixUnit && (
|
||||
<ValueUnit
|
||||
type="prefix"
|
||||
unit={prefixUnit}
|
||||
color={textColor}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
)}
|
||||
<Typography.Text
|
||||
className={styles.valueText}
|
||||
data-testid="number-panel-value"
|
||||
style={{ color: textColor, fontSize }}
|
||||
>
|
||||
{numericValue}
|
||||
</Typography.Text>
|
||||
{suffixUnit && (
|
||||
<ValueUnit
|
||||
type="suffix"
|
||||
unit={suffixUnit}
|
||||
color={textColor}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isConflicting && (
|
||||
<div
|
||||
className={isBackground ? styles.conflictBackground : styles.conflictText}
|
||||
>
|
||||
<Tooltip title={t('this_value_satisfies_multiple_thresholds')}>
|
||||
<CircleAlert
|
||||
className={styles.conflictIcon}
|
||||
data-testid="conflicting-thresholds"
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueDisplay;
|
||||
@@ -1,5 +0,0 @@
|
||||
.unit {
|
||||
margin-left: 4px;
|
||||
font-weight: 300;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ValueUnit.module.scss';
|
||||
|
||||
interface ValueUnitProps {
|
||||
type: 'prefix' | 'suffix';
|
||||
unit: string;
|
||||
/** Text color, set only when a "text" threshold is active. */
|
||||
color?: string;
|
||||
fontSize: string;
|
||||
}
|
||||
|
||||
/** A prefix/suffix unit label rendered alongside the numeric value. */
|
||||
function ValueUnit({
|
||||
type,
|
||||
unit,
|
||||
color,
|
||||
fontSize,
|
||||
}: ValueUnitProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text
|
||||
className={styles.unit}
|
||||
data-testid={`value-display-${type}-unit`}
|
||||
style={{ color, fontSize: `calc(${fontSize} * 0.7)` }}
|
||||
>
|
||||
{unit}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueUnit;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user