Compare commits

..

16 Commits

Author SHA1 Message Date
Naman Verma
b0a065591f Merge branch 'main' into nv/schema-changes 2026-06-17 07:29:11 +05:30
Naman Verma
eb4b11295b feat: add clone dashboard API for perses dashboards (#11742)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: add clone dashboard API for perses dashboards

* fix: allow integration dashboards to be cloned

* test: add integration test for cloning of integration dashboards

* test: fix lint in test

* test: revert the integration dashboard test
2026-06-17 01:35:07 +00:00
Yunus M
bdb0091c87 feat: use empty suggestions api for empty ui state of ai assistant (#11744)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: use empty suggestions api for empty ui state of ai assistant

* feat: add useResolvePageType hook

* feat: update resolver to use all from DTO

* feat: update getAutoContexts and resolvePageType to omit auto-context for homepage and infra
2026-06-16 18:58:37 +00:00
Gaurav Tewari
5198530056 chore(traces): remove legacy trace view toggle and redirect /trace-old to /trace (#11732)
* chore: remove legacy trace view redirect

* refactor: review  changes

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-16 16:46:16 +00:00
Yunus M
6a4e694a34 fix: AI Assistant improvements (#11722)
* fix: wire open view and open channel flows

* feat: show current alert name in context in alert details page

* feat: links should open in new tab

* fix: show Noz in dashboard edit panel view

* fix: reduce size of fixed footer when assistant side panel is open

* feat: streamline error handling with resolveAssistantErrorMessage utility

* feat: hide suggestions and share in edit dashboard panel
2026-06-16 16:42:33 +00:00
swapnil-signoz
bd9a6cc17d chore: bump cloud integration agent version to v0.0.12 (#11741) 2026-06-16 16:15:12 +00:00
Nikhil Mantri
51f180453e feat(infra-monitoring): add default group by namespace name, cluster name for workloads. (#11716)
* chore: deployments -> add default namespace group by

* chore: added integration tests for statefulsets

* chore: namespace group by for jobs

* chore: namespace group by for daemonsets

* chore: added group by clustername for all workloads and integration tests for the same

* chore: fix py fmt for integration tests
2026-06-16 15:51:38 +00:00
Vinicius Lourenço
2ad5cb19c3 chore(sentry): bump packages to v10 & v5 for vite-plugin (#11723) 2026-06-16 15:34:48 +00:00
Naman Verma
c0e0ebab4a Merge branch 'main' into nv/schema-changes 2026-06-16 20:20:28 +05:30
swapnil-signoz
5d711377ec feat: adding support for Azure DB services (#11679)
* feat: adding support for Azure SQL DB

* refactor: adding missing metrics in data collected and panels in dash

* feat: adding support for Azure SQL DB Managed Instances

* feat: generating openapi spec

* feat: adding nosql and cache services in azure integration
2026-06-16 14:20:49 +00:00
Vinicius Lourenço
c5d0fd8966 chore(oxc): bump oxfmt and oxlint/tsgolint to 0.54 and 1.69/0.23 (#11724) 2026-06-16 12:27:05 +00:00
Vinicius Lourenço
ed04ff09ff chore(css-modules): add good practices for writing css module files (#11587)
* chore(css-modules): add good practices for writing css module files

* fix(guide): add more guidelines based on notion doc

* fix(pr): address pr comments
2026-06-16 12:26:27 +00:00
Srikanth Chekuri
e1e9d516ac chore: add firing alert count and system/k8s metric existance status (#11730)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-16 10:28:22 +00:00
Naman Verma
9e8464f3eb Merge branch 'main' into nv/schema-changes 2026-06-16 11:40:23 +05:30
Naman Verma
845c88ec45 Merge branch 'main' into nv/schema-changes 2026-06-15 16:55:36 +05:30
Naman Verma
9b53561c31 fix: change schema properties based on UI integration review 2026-06-15 15:37:29 +05:30
149 changed files with 31815 additions and 4887 deletions

View File

@@ -1357,6 +1357,14 @@ components:
- appservice
- containerapp
- aks
- sqldatabase
- sqldatabasemi
- mysqlflexibleserver
- postgresqlflexibleserver
- mongodb
- cosmosdb
- cassandradb
- redis
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -2769,9 +2777,16 @@ components:
type: string
nullable: true
type: object
mode:
$ref: '#/components/schemas/DashboardtypesLegendMode'
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendMode:
enum:
- list
- table
type: string
DashboardtypesLegendPosition:
enum:
- bottom
@@ -3321,8 +3336,13 @@ components:
DashboardtypesSpanGaps:
properties:
fillLessThan:
description: The maximum gap size to connect when fillOnlyBelow is true.
Gaps larger than this duration are left disconnected.
type: string
fillOnlyBelow:
description: Controls whether lines connect across null values. When false
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
are connected.
type: boolean
type: object
DashboardtypesStorableDashboardData:
@@ -3389,7 +3409,6 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:
@@ -13713,6 +13732,74 @@ 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

View File

@@ -217,6 +217,10 @@ 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)
}

View File

@@ -291,6 +291,8 @@
// 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",
{

View File

@@ -2,9 +2,33 @@
const path = require('path');
module.exports = {
plugins: [path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js')],
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'),
],
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' }],
},
},
],
};

View File

@@ -23,6 +23,8 @@ 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`

View File

@@ -0,0 +1,513 @@
# 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.

View File

@@ -45,8 +45,8 @@
"@dnd-kit/utilities": "3.2.2",
"@grafana/data": "^11.6.14",
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "8.41.0",
"@sentry/vite-plugin": "2.22.6",
"@sentry/react": "10.57.0",
"@sentry/vite-plugin": "5.3.0",
"@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.47.0",
"oxlint": "1.62.0",
"oxlint-tsgolint": "0.22.1",
"oxfmt": "0.54.0",
"oxlint": "1.69.0",
"oxlint-tsgolint": "0.23.0",
"postcss": "8.5.14",
"postcss-scss": "4.0.9",
"react-resizable": "3.0.4",

View File

@@ -0,0 +1,144 @@
/**
* 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('');
}

View File

@@ -11,6 +11,7 @@ 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: {
@@ -23,5 +24,6 @@ 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

File diff suppressed because it is too large Load Diff

View File

@@ -64,10 +64,17 @@ export const TraceDetail = Loadable(
),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailOldRedirect" */ 'pages/TraceDetailOldRedirect/index'
),
);
export const TraceDetailV3 = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailsV3/index'
),
);
@@ -323,10 +330,3 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -22,7 +22,6 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -48,7 +47,7 @@ import {
SomethingWentWrong,
StatusPage,
SupportPage,
TraceDetail,
TraceDetailOldRedirect,
TraceDetailV3,
TraceFilter,
TracesExplorer,
@@ -140,13 +139,11 @@ const routes: AppRoutes[] = [
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
// 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.
// Legacy /trace-old/:id redirects to the current /trace/:id view.
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetail,
component: TraceDetailOldRedirect,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
},
@@ -508,13 +505,6 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -55,6 +55,9 @@ import type {
ThreadDetailResponseDTO,
ThreadListResponseDTO,
ThreadSummaryDTO,
ChipDTO,
ChipsResponseDTO,
PageTypeDTO,
ToolCallEventDTO,
ToolResultEventDTO,
} from './sigNozAIAssistantAPI.schemas';
@@ -541,3 +544,19 @@ 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;
}

View File

@@ -26,6 +26,7 @@ import type {
CancelApiV1AssistantCancelPostHeaders,
CancelRequestDTO,
CancelResponseDTO,
ChipsResponseDTO,
ClarifyApiV1AssistantClarifyPostHeaders,
ClarifyRequestDTO,
ClarifyResponseDTO,
@@ -39,8 +40,11 @@ import type {
ErrorResponseDTO,
FeedbackRequestDTO,
FeedbackResponseDTO,
GetChipsApiV1AssistantEmptyStateChipsGetHeaders,
GetChipsApiV1AssistantEmptyStateChipsGetParams,
GetThreadApiV1AssistantThreadsThreadIdGetHeaders,
GetThreadApiV1AssistantThreadsThreadIdGetPathParameters,
GetUsageApiV1AssistantUsageGetHeaders,
HTTPValidationErrorDTO,
HealthResponseDTO,
ListThreadsApiV1AssistantThreadsGetHeaders,
@@ -65,93 +69,89 @@ import type {
UpdateThreadApiV1AssistantThreadsThreadIdPatchHeaders,
UpdateThreadApiV1AssistantThreadsThreadIdPatchPathParameters,
UpdateThreadRequestDTO,
UsageResponseDTO,
} from './sigNozAIAssistantAPI.schemas';
import {
GeneratedAPIInstance,
getGeneratedAPIQueryKeyHeaders,
} from '../generatedAPIInstance';
import { GeneratedAPIInstance } from '../generatedAPIInstance';
import type { ErrorType, BodyType } from '../generatedAPIInstance';
/**
* @summary Health
* @summary Healthz
*/
export const healthHealthGet = (signal?: AbortSignal) => {
export const healthzHealthzGet = (signal?: AbortSignal) => {
return GeneratedAPIInstance<HealthResponseDTO>({
url: `/health`,
url: `/healthz`,
method: 'GET',
signal,
});
};
export const getHealthHealthGetQueryKey = () => {
return [`/health`] as const;
export const getHealthzHealthzGetQueryKey = () => {
return [`/healthz`] as const;
};
export const getHealthHealthGetQueryOptions = <
TData = Awaited<ReturnType<typeof healthHealthGet>>,
export const getHealthzHealthzGetQueryOptions = <
TData = Awaited<ReturnType<typeof healthzHealthzGet>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
Awaited<ReturnType<typeof healthzHealthzGet>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getHealthHealthGetQueryKey();
const queryKey = queryOptions?.queryKey ?? getHealthzHealthzGetQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof healthHealthGet>>> = ({
signal,
}) => healthHealthGet(signal);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof healthzHealthzGet>>
> = ({ signal }) => healthzHealthzGet(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
Awaited<ReturnType<typeof healthzHealthzGet>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type HealthHealthGetQueryResult = NonNullable<
Awaited<ReturnType<typeof healthHealthGet>>
export type HealthzHealthzGetQueryResult = NonNullable<
Awaited<ReturnType<typeof healthzHealthzGet>>
>;
export type HealthHealthGetQueryError = ErrorType<unknown>;
export type HealthzHealthzGetQueryError = ErrorType<unknown>;
/**
* @summary Health
* @summary Healthz
*/
export function useHealthHealthGet<
TData = Awaited<ReturnType<typeof healthHealthGet>>,
export function useHealthzHealthzGet<
TData = Awaited<ReturnType<typeof healthzHealthzGet>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
Awaited<ReturnType<typeof healthzHealthzGet>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getHealthHealthGetQueryOptions(options);
const queryOptions = getHealthzHealthzGetQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Health
* @summary Healthz
*/
export const invalidateHealthHealthGet = async (
export const invalidateHealthzHealthzGet = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getHealthHealthGetQueryKey() },
{ queryKey: getHealthzHealthzGetQueryKey() },
options,
);
@@ -159,84 +159,82 @@ export const invalidateHealthHealthGet = async (
};
/**
* @summary Ready
* @summary Readyz
*/
export const readyReadyGet = (signal?: AbortSignal) => {
export const readyzReadyzGet = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ReadinessResponseDTO>({
url: `/ready`,
url: `/readyz`,
method: 'GET',
signal,
});
};
export const getReadyReadyGetQueryKey = () => {
return [`/ready`] as const;
export const getReadyzReadyzGetQueryKey = () => {
return [`/readyz`] as const;
};
export const getReadyReadyGetQueryOptions = <
TData = Awaited<ReturnType<typeof readyReadyGet>>,
export const getReadyzReadyzGetQueryOptions = <
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
TError = ErrorType<ReadinessResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof readyReadyGet>>,
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getReadyReadyGetQueryKey();
const queryKey = queryOptions?.queryKey ?? getReadyzReadyzGetQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof readyReadyGet>>> = ({
const queryFn: QueryFunction<Awaited<ReturnType<typeof readyzReadyzGet>>> = ({
signal,
}) => readyReadyGet(signal);
}) => readyzReadyzGet(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof readyReadyGet>>,
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ReadyReadyGetQueryResult = NonNullable<
Awaited<ReturnType<typeof readyReadyGet>>
export type ReadyzReadyzGetQueryResult = NonNullable<
Awaited<ReturnType<typeof readyzReadyzGet>>
>;
export type ReadyReadyGetQueryError = ErrorType<ReadinessResponseDTO>;
export type ReadyzReadyzGetQueryError = ErrorType<ReadinessResponseDTO>;
/**
* @summary Ready
* @summary Readyz
*/
export function useReadyReadyGet<
TData = Awaited<ReturnType<typeof readyReadyGet>>,
export function useReadyzReadyzGet<
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
TError = ErrorType<ReadinessResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof readyReadyGet>>,
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getReadyReadyGetQueryOptions(options);
const queryOptions = getReadyzReadyzGetQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Ready
* @summary Readyz
*/
export const invalidateReadyReadyGet = async (
export const invalidateReadyzReadyzGet = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getReadyReadyGetQueryKey() },
{ queryKey: getReadyzReadyzGetQueryKey() },
options,
);
@@ -247,7 +245,7 @@ export const invalidateReadyReadyGet = async (
* @summary Create a new thread
*/
export const createThreadApiV1AssistantThreadsPost = (
createThreadApiV1AssistantThreadsPostBody: BodyType<CreateThreadApiV1AssistantThreadsPostBody>,
createThreadApiV1AssistantThreadsPostBody?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>,
headers?: CreateThreadApiV1AssistantThreadsPostHeaders,
signal?: AbortSignal,
) => {
@@ -268,7 +266,7 @@ export const getCreateThreadApiV1AssistantThreadsPostMutationOptions = <
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
TError,
{
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
},
TContext
@@ -277,7 +275,7 @@ export const getCreateThreadApiV1AssistantThreadsPostMutationOptions = <
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
TError,
{
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
},
TContext
@@ -294,7 +292,7 @@ export const getCreateThreadApiV1AssistantThreadsPostMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
{
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
}
> = (props) => {
@@ -310,7 +308,8 @@ export type CreateThreadApiV1AssistantThreadsPostMutationResult = NonNullable<
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>
>;
export type CreateThreadApiV1AssistantThreadsPostMutationBody =
BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
| BodyType<CreateThreadApiV1AssistantThreadsPostBody>
| undefined;
export type CreateThreadApiV1AssistantThreadsPostMutationError = ErrorType<
ErrorResponseDTO | HTTPValidationErrorDTO
>;
@@ -326,7 +325,7 @@ export const useCreateThreadApiV1AssistantThreadsPost = <
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
TError,
{
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
},
TContext
@@ -335,15 +334,14 @@ export const useCreateThreadApiV1AssistantThreadsPost = <
Awaited<ReturnType<typeof createThreadApiV1AssistantThreadsPost>>,
TError,
{
data: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
data?: BodyType<CreateThreadApiV1AssistantThreadsPostBody>;
headers?: CreateThreadApiV1AssistantThreadsPostHeaders;
},
TContext
> => {
const mutationOptions =
getCreateThreadApiV1AssistantThreadsPostMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(
getCreateThreadApiV1AssistantThreadsPostMutationOptions(options),
);
};
/**
* Cursor-based pagination, sorted by updatedAt desc. Use `archived=true|false|all` to filter.
@@ -365,13 +363,8 @@ export const listThreadsApiV1AssistantThreadsGet = (
export const getListThreadsApiV1AssistantThreadsGetQueryKey = (
params?: ListThreadsApiV1AssistantThreadsGetParams,
headers?: ListThreadsApiV1AssistantThreadsGetHeaders,
) => {
return [
`/api/v1/assistant/threads`,
...(params ? [params] : []),
...getGeneratedAPIQueryKeyHeaders(headers),
] as const;
return [`/api/v1/assistant/threads`, ...(params ? [params] : [])] as const;
};
export const getListThreadsApiV1AssistantThreadsGetQueryOptions = <
@@ -392,7 +385,7 @@ export const getListThreadsApiV1AssistantThreadsGetQueryOptions = <
const queryKey =
queryOptions?.queryKey ??
getListThreadsApiV1AssistantThreadsGetQueryKey(params, headers);
getListThreadsApiV1AssistantThreadsGetQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listThreadsApiV1AssistantThreadsGet>>
@@ -441,9 +434,7 @@ export function useListThreadsApiV1AssistantThreadsGet<
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
return { ...query, queryKey: queryOptions.queryKey };
}
/**
@@ -456,7 +447,7 @@ export const invalidateListThreadsApiV1AssistantThreadsGet = async (
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListThreadsApiV1AssistantThreadsGetQueryKey(params, headers) },
{ queryKey: getListThreadsApiV1AssistantThreadsGetQueryKey(params) },
options,
);
@@ -480,14 +471,10 @@ export const getThreadApiV1AssistantThreadsThreadIdGet = (
});
};
export const getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey = (
{ threadId }: GetThreadApiV1AssistantThreadsThreadIdGetPathParameters,
headers?: GetThreadApiV1AssistantThreadsThreadIdGetHeaders,
) => {
return [
`/api/v1/assistant/threads/${threadId}`,
...getGeneratedAPIQueryKeyHeaders(headers),
] as const;
export const getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey = ({
threadId,
}: GetThreadApiV1AssistantThreadsThreadIdGetPathParameters) => {
return [`/api/v1/assistant/threads/${threadId}`] as const;
};
export const getGetThreadApiV1AssistantThreadsThreadIdGetQueryOptions = <
@@ -508,7 +495,7 @@ export const getGetThreadApiV1AssistantThreadsThreadIdGetQueryOptions = <
const queryKey =
queryOptions?.queryKey ??
getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey({ threadId }, headers);
getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey({ threadId });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getThreadApiV1AssistantThreadsThreadIdGet>>
@@ -562,9 +549,7 @@ export function useGetThreadApiV1AssistantThreadsThreadIdGet<
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
return { ...query, queryKey: queryOptions.queryKey };
}
/**
@@ -578,10 +563,7 @@ export const invalidateGetThreadApiV1AssistantThreadsThreadIdGet = async (
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{
queryKey: getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey(
{ threadId },
headers,
),
queryKey: getGetThreadApiV1AssistantThreadsThreadIdGetQueryKey({ threadId }),
},
options,
);
@@ -596,12 +578,14 @@ 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,
});
};
@@ -695,10 +679,9 @@ export const useUpdateThreadApiV1AssistantThreadsThreadIdPatch = <
},
TContext
> => {
const mutationOptions =
getUpdateThreadApiV1AssistantThreadsThreadIdPatchMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(
getUpdateThreadApiV1AssistantThreadsThreadIdPatchMutationOptions(options),
);
};
/**
* 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.
@@ -825,12 +808,11 @@ export const useCreateMessageApiV1AssistantThreadsThreadIdMessagesPost = <
},
TContext
> => {
const mutationOptions =
return useMutation(
getCreateMessageApiV1AssistantThreadsThreadIdMessagesPostMutationOptions(
options,
);
return useMutation(mutationOptions);
),
);
};
/**
* Clean-slate regeneration. Starts a fresh execution with conversation history up to (excluding) the original assistant response.
@@ -961,12 +943,11 @@ export const useRegenerateMessageApiV1AssistantMessagesMessageIdRegeneratePost =
},
TContext
> => {
const mutationOptions =
return useMutation(
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.
@@ -1066,10 +1047,9 @@ export const useApproveApiV1AssistantApprovePost = <
},
TContext
> => {
const mutationOptions =
getApproveApiV1AssistantApprovePostMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(
getApproveApiV1AssistantApprovePostMutationOptions(options),
);
};
/**
* Marks the approval as rejected. The execution completes with no tool execution.
@@ -1169,10 +1149,7 @@ export const useRejectApiV1AssistantRejectPost = <
},
TContext
> => {
const mutationOptions =
getRejectApiV1AssistantRejectPostMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(getRejectApiV1AssistantRejectPostMutationOptions(options));
};
/**
* 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.
@@ -1272,10 +1249,9 @@ export const useClarifyApiV1AssistantClarifyPost = <
},
TContext
> => {
const mutationOptions =
getClarifyApiV1AssistantClarifyPostMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(
getClarifyApiV1AssistantClarifyPostMutationOptions(options),
);
};
/**
* Cooperative cancel. The agent loop finishes its current step, emits a truncated message if streaming, and transitions to canceled.
@@ -1375,10 +1351,7 @@ export const useCancelApiV1AssistantCancelPost = <
},
TContext
> => {
const mutationOptions =
getCancelApiV1AssistantCancelPostMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(getCancelApiV1AssistantCancelPostMutationOptions(options));
};
/**
* Deletes the resource that was created by the assistant.
@@ -1477,9 +1450,7 @@ export const useUndoApiV1AssistantUndoPost = <
},
TContext
> => {
const mutationOptions = getUndoApiV1AssistantUndoPostMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(getUndoApiV1AssistantUndoPostMutationOptions(options));
};
/**
* Rolls back the resource to its pre-change snapshot.
@@ -1579,10 +1550,7 @@ export const useRevertApiV1AssistantRevertPost = <
},
TContext
> => {
const mutationOptions =
getRevertApiV1AssistantRevertPostMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(getRevertApiV1AssistantRevertPostMutationOptions(options));
};
/**
* Recreates the resource from its pre-delete snapshot.
@@ -1682,10 +1650,9 @@ export const useRestoreApiV1AssistantRestorePost = <
},
TContext
> => {
const mutationOptions =
getRestoreApiV1AssistantRestorePostMutationOptions(options);
return useMutation(mutationOptions);
return useMutation(
getRestoreApiV1AssistantRestorePostMutationOptions(options),
);
};
/**
* @summary Submit feedback on an assistant message
@@ -1811,10 +1778,221 @@ export const useSubmitFeedbackApiV1AssistantMessagesMessageIdFeedbackPost = <
},
TContext
> => {
const mutationOptions =
return useMutation(
getSubmitFeedbackApiV1AssistantMessagesMessageIdFeedbackPostMutationOptions(
options,
);
return useMutation(mutationOptions);
),
);
};
/**
* @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;
};

View File

@@ -159,6 +159,25 @@ 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;
@@ -386,15 +405,74 @@ 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 {
/**
* @type string
* @pattern ^[a-z_]+$
*/
code: string;
code: ErrorCodeDTO;
/**
* @type string
*/
@@ -490,6 +568,23 @@ 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',
@@ -500,7 +595,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.
* 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.
*/
export interface MessageActionDTO {
kind: MessageActionKindDTO;
@@ -517,6 +612,7 @@ export interface MessageActionDTO {
signal?: MessageActionDTOSignal;
query?: MessageActionDTOQuery;
url?: MessageActionDTOUrl;
entity?: MessageActionDTOEntity;
}
export enum MessageContentTypeDTO {
@@ -590,6 +686,26 @@ 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',
@@ -990,8 +1106,10 @@ 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.
* 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.
*/
export interface MessageActionEventDTO {
kind: MessageActionKindDTO;
@@ -1008,6 +1126,7 @@ export interface MessageActionEventDTO {
signal?: MessageActionEventDTOSignal;
query?: MessageActionEventDTOQuery;
url?: MessageActionEventDTOUrl;
entity?: MessageActionEventDTOEntity;
}
export type MessageEventDTOActions = MessageActionEventDTO[] | null;
@@ -1385,3 +1504,21 @@ 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;
};

View File

@@ -18,6 +18,8 @@ import type {
} from 'react-query';
import type {
CloneDashboardV2201,
CloneDashboardV2PathParameters,
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
@@ -1206,6 +1208,85 @@ 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)

View File

@@ -2645,6 +2645,14 @@ 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 = {
/**
@@ -3208,6 +3216,10 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
table = 'table',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3227,6 +3239,7 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3238,7 +3251,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label: string;
label?: string;
/**
* @type string
*/
@@ -3903,10 +3916,12 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
@@ -9907,6 +9922,17 @@ export type UpdateDashboardV2200 = {
status: string;
};
export type CloneDashboardV2PathParameters = {
id: string;
};
export type CloneDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UnlockDashboardV2PathParameters = {
id: string;
};

View File

@@ -0,0 +1,24 @@
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}`);

View File

@@ -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',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',

View File

@@ -113,4 +113,7 @@ 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;

View File

@@ -91,7 +91,6 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
} as const;
export default ROUTES;

View File

@@ -0,0 +1,150 @@
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([]);
});
});

View File

@@ -0,0 +1,87 @@
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,
);
});
});

View File

@@ -47,6 +47,15 @@ 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 {
@@ -55,20 +64,6 @@ 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) {
@@ -78,6 +73,8 @@ 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:
@@ -140,39 +137,6 @@ 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:
*
@@ -484,6 +448,35 @@ 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,
@@ -502,6 +495,31 @@ 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: {
@@ -542,21 +560,9 @@ export default function ActionsSection({
}
break;
}
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);
}
}
case MessageActionKindDTO.open_resource:
handleOpenResource(key, action);
break;
}
case MessageActionKindDTO.undo:
case MessageActionKindDTO.revert:
case MessageActionKindDTO.restore: {

View File

@@ -0,0 +1,283 @@
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');
});
});

View File

@@ -0,0 +1,117 @@
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;

View File

@@ -0,0 +1,203 @@
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;
}

View File

@@ -0,0 +1,50 @@
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;
}
}

View File

@@ -101,6 +101,8 @@ 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':

View File

@@ -0,0 +1,26 @@
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>
);
}

View File

@@ -11,6 +11,7 @@ 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';
@@ -37,7 +38,11 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
const MD_COMPONENTS = {
code: RichCodeBlock,
pre: SmartPre,
a: MarkdownExternalLink,
};
type RenderGroup =
| { kind: 'text'; id: string; content: string }

View File

@@ -50,7 +50,7 @@
font-size: 10px;
color: var(--l3-foreground);
white-space: nowrap;
padding-left: 2px;
padding-left: 8px;
border-left: 1px solid var(--l2-border);
}

View File

@@ -13,6 +13,7 @@ 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';
@@ -30,7 +31,11 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
const MD_COMPONENTS = {
code: RichCodeBlock,
pre: SmartPre,
a: MarkdownExternalLink,
};
type RenderGroup =
| { kind: 'text'; id: string; content: string }

View File

@@ -45,7 +45,7 @@
line-height: 1.45;
}
.emptySuggestions {
.suggestions {
display: flex;
flex-direction: column;
gap: 6px;
@@ -53,11 +53,8 @@
max-width: 360px;
}
.emptyChip {
display: flex;
align-items: center;
justify-content: flex-start !important;
gap: 8px;
.suggestion {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
@@ -66,21 +63,16 @@
font-size: 12.5px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 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);
}
}

View File

@@ -1,13 +1,5 @@
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';
@@ -20,29 +12,7 @@ import MessageBubble from '../MessageBubble';
import StreamingMessage from '../StreamingMessage';
import styles from './VirtualizedMessages.module.scss';
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?',
},
];
import { useEmptyStateChips } from './useEmptyStateChips';
const EMPTY_EVENTS: StreamingEventItem[] = [];
@@ -173,8 +143,10 @@ export default function VirtualizedMessages({
const showStreamingSlot =
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
const isEmptyState = messages.length === 0 && !showStreamingSlot;
const { chips: emptyStateChips } = useEmptyStateChips(isEmptyState);
if (messages.length === 0 && !showStreamingSlot) {
if (isEmptyState) {
return (
<div className={styles.empty}>
<div className={`${styles.emptyIcon} noz-wave`}>
@@ -184,24 +156,22 @@ export default function VirtualizedMessages({
<p className={styles.emptySubtitle}>
Ask questions about your traces, logs, metrics, and infrastructure.
</p>
<div className={styles.emptySuggestions}>
{SUGGESTIONS.map((s) => (
<Button
key={s.text}
variant="outlined"
color="secondary"
className={styles.emptyChip}
<div className={styles.suggestions}>
{emptyStateChips.map((chip) => (
<div
key={chip.id}
className={styles.suggestion}
onClick={(): void => {
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
promptId: s.text,
promptId: chip.id,
category: SuggestedPromptCategory.EmptyState,
});
onSendSuggestedPrompt(s.text);
onSendSuggestedPrompt(chip.text);
}}
prefix={<s.icon size={14} />}
data-testid={`empty-state-chip-${chip.id}`}
>
{s.text}
</Button>
{chip.text}
</div>
))}
</div>
</div>

View File

@@ -0,0 +1,25 @@
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?',
},
];

View File

@@ -0,0 +1,33 @@
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 };
}

View File

@@ -1,6 +1,7 @@
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';
/**
@@ -99,6 +100,30 @@ 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);
@@ -108,7 +133,7 @@ export function getAutoContexts(
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit' },
metadata: { page: 'alert_edit', ruleId },
},
];
}
@@ -125,6 +150,7 @@ export function getAutoContexts(
];
}
// Triggered-alerts index — `/alerts/history` without a rule id.
if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) {
return [
{
@@ -139,13 +165,18 @@ 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: 'alert_list' },
metadata: {
page,
...(page === 'alerts_triggered' ? sharedMetadata : {}),
},
},
];
}
@@ -251,8 +282,9 @@ export function getAutoContexts(
// ── Metrics ───────────────────────────────────────────────────────────────
// Metrics explorer — `/metrics-explorer` and sub-routes (summary, explorer, views).
if (
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_EXPLORER, exact: false })
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_BASE, exact: false })
) {
return [
{
@@ -267,9 +299,25 @@ 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

View File

@@ -0,0 +1,74 @@
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;
}

View File

@@ -1,12 +1,10 @@
/* 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';
@@ -37,6 +35,7 @@ import {
MessageBlock,
MessageRole,
} from '../types';
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
// ---------------------------------------------------------------------------
// Types used by module-level helpers
@@ -399,6 +398,7 @@ 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,36 +484,6 @@ 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,
@@ -1174,14 +1144,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] sendMessage failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
convId,
rateLimit ??
'Something went wrong while fetching the response. Please try again.',
set,
rateLimit !== null,
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while fetching the response. Please try again.',
);
finalizeStreamingError(convId, message, set, isRateLimit);
}
},
@@ -1214,14 +1181,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] approveAction failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while processing the approval. Please try again.',
set,
rateLimit !== null,
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while processing the approval. Please try again.',
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
},
@@ -1296,14 +1260,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while regenerating the response. Please try again.',
set,
rateLimit !== null,
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while regenerating the response. Please try again.',
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
},
@@ -1365,14 +1326,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] submitClarification failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while processing your answers. Please try again.',
set,
rateLimit !== null,
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while processing your answers. Please try again.',
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
},
})),

View File

@@ -0,0 +1,91 @@
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,
});
});
});

View File

@@ -0,0 +1,71 @@
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) };
}

View File

@@ -29,3 +29,7 @@
color: var(--l2-foreground);
}
}
body.ai-assistant-panel-open .create-alert-v2-footer {
right: var(--ai-assistant-panel-width, 380px);
}

View File

@@ -1,148 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Plus, Trash2 } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import { CACHE_BUCKETS, CACHE_MODE_OPTIONS } from './constants';
import { parsePricingAmount } from './utils';
import type { CacheBucketKey, DrawerDraft } from './types';
type Pricing = DrawerDraft['pricing'];
interface ExtraPricingBucketsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
// Optional, add-on-demand pricing buckets. A bucket is "added" once its value
// is non-null; adding seeds it at 0 and removing clears it back to null. Only
// the cache buckets are backed by the API today (pricing.cache.read/write).
function ExtraPricingBuckets({
pricing,
isReadOnly,
onChange,
}: ExtraPricingBucketsProps): JSX.Element {
const [isPicking, setIsPicking] = useState<boolean>(false);
const addedBuckets = CACHE_BUCKETS.filter((b) => pricing[b.key] !== null);
const availableBuckets = CACHE_BUCKETS.filter((b) => pricing[b.key] === null);
const addBucket = (key: CacheBucketKey): void => {
onChange({ [key]: 0 } as Partial<Pricing>);
// Close the picker once nothing is left to add.
if (availableBuckets.length <= 1) {
setIsPicking(false);
}
};
const removeBucket = (key: CacheBucketKey): void => {
onChange({ [key]: null } as Partial<Pricing>);
};
return (
<div className="extra-buckets-section drawer-section">
<div className="extra-buckets-section__head">
<span className="field-label">Extra pricing buckets</span>
<span className="optional-label">optional</span>
</div>
{addedBuckets.map((bucket) => (
<div className="bucket-row" key={bucket.key}>
<span className="bucket-row__name">{bucket.label}</span>
<Input
type="number"
min={0}
step={0.01}
value={pricing[bucket.key] ?? 0}
placeholder="0.00"
disabled={isReadOnly}
onChange={(e): void =>
// Empty coerces to 0 (not null) so editing never makes the row
// vanish — removal is explicit via the trash button.
onChange({
[bucket.key]: parsePricingAmount(e.target.value) ?? 0,
} as Partial<Pricing>)
}
testId={`drawer-${bucket.testId}-cost`}
/>
<span className="bucket-row__unit">/ 1M</span>
{!isReadOnly && (
<button
type="button"
className="bucket-row__remove"
onClick={(): void => removeBucket(bucket.key)}
aria-label={`Remove ${bucket.label}`}
data-testid={`drawer-remove-${bucket.testId}`}
>
<Trash2 size={14} />
</button>
)}
</div>
))}
{addedBuckets.length > 0 && (
<div className="pricing-field cache-mode-field">
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}
items={CACHE_MODE_OPTIONS}
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className="full-width"
withPortal={false}
testId="drawer-cache-mode"
/>
</div>
)}
{!isReadOnly && !isPicking && availableBuckets.length > 0 && (
<Button
variant="dashed"
color="secondary"
className="bucket-add-btn"
prefix={<Plus size={14} />}
onClick={(): void => setIsPicking(true)}
testId="drawer-add-bucket-btn"
>
Add pricing bucket
</Button>
)}
{!isReadOnly && isPicking && (
<div className="bucket-picker" data-testid="drawer-bucket-picker">
<div className="bucket-picker__title">Add a pricing bucket</div>
<div className="bucket-picker__chips">
{availableBuckets.map((bucket) => (
<Button
key={bucket.key}
variant="outlined"
color="secondary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => addBucket(bucket.key)}
testId={`drawer-add-bucket-${bucket.testId}`}
>
{bucket.label}
</Button>
))}
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={(): void => setIsPicking(false)}
testId="drawer-add-bucket-cancel"
>
Cancel
</Button>
</div>
)}
</div>
);
}
export default ExtraPricingBuckets;

View File

@@ -1,172 +0,0 @@
.llm-observability-model-pricing {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
&__title {
h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 13px;
}
}
&__actions {
display: flex;
gap: 8px;
}
}
.page-tabs {
.ant-tabs-nav {
margin-bottom: 0;
}
}
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
&__search {
flex: 1;
max-width: 360px;
}
&__source,
&__currency {
min-width: 160px;
}
&__add {
margin-left: auto;
}
}
.page-error {
padding: 12px 16px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 13px;
}
.page-pagination {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.page-footer {
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
.model-costs-table {
.ant-table-thead > tr > th {
color: var(--bg-vanilla-400) !important;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ant-table-tbody > tr > td {
color: var(--bg-vanilla-400);
}
.model-cell {
display: flex;
flex-direction: column;
gap: 2px;
// Allow the flex children to shrink below their content width so the
// table's fixed-layout / nowrap cells truncate instead of overflowing
// into the Provider column.
min-width: 0;
&__name {
color: var(--bg-vanilla-100);
font-weight: 600;
}
&__name,
&__canonical-id {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__canonical-id {
color: var(--bg-vanilla-400);
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
}
.price-cell {
font-family: var(--code-font-family, monospace);
color: var(--bg-vanilla-400);
}
.extra-buckets {
display: flex;
flex-wrap: wrap;
gap: 6px;
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
}
&__key {
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
&__price {
font-family: var(--code-font-family, monospace);
font-weight: 600;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.source-badge {
margin: 0;
&--auto {
background: rgba(78, 116, 248, 0.12);
color: var(--bg-robin-400);
border-color: rgba(78, 116, 248, 0.24);
}
&--override {
background: rgba(245, 175, 25, 0.12);
color: var(--bg-amber-400);
border-color: rgba(245, 175, 25, 0.24);
}
}
&__row--selected {
background: rgba(78, 116, 248, 0.06);
}
}

View File

@@ -1,185 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Pagination } from '@signozhq/ui/pagination';
import { SelectSimple } from '@signozhq/ui/select';
import { Tabs } from '@signozhq/ui/tabs';
import { Plus, Search } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebounce from 'hooks/useDebounce';
import { useAppContext } from 'providers/App/App';
import ModelCostDrawer from './ModelCostDrawer';
import ModelCostsTable from './ModelCostsTable';
import { useModelCostDrawer } from './useModelCostDrawer';
import { useModelPricingFilters } from './useModelPricingFilters';
import type {
ListModelPricingParams,
PricingRule,
SourceFilter,
} from './types';
import './LLMObservabilityModelPricing.styles.scss';
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'Source: All' },
{ value: 'auto', label: 'Auto-populated' },
{ value: 'override', label: 'User override' },
];
const CURRENCY_OPTIONS = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR', disabled: true },
{ value: 'INR', label: 'INR', disabled: true },
];
const PAGE_SIZE = 20;
function LLMObservabilityModelPricing(): JSX.Element {
const { search, source, page, setSearch, setSource, setPage } =
useModelPricingFilters();
const [currency, setCurrency] = useState<string>('USD');
// Controlled locally for instant typing feedback; the URL `q` param (which
// drives the request) is updated on a debounce so we don't fire a request
// per keystroke.
const [searchInput, setSearchInput] = useState<string>(search);
const debouncedSearch = useDebounce(searchInput, 400);
useEffect(() => {
if (debouncedSearch.trim() !== search) {
setSearch(debouncedSearch);
}
}, [debouncedSearch, search, setSearch]);
const listParams: ListModelPricingParams = {
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE,
...(search ? { q: search } : {}),
...(source !== 'all' ? { source } : {}),
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const drawer = useModelCostDrawer();
return (
<div
className="llm-observability-model-pricing"
data-testid="llm-observability-model-pricing-page"
>
<header className="page-header">
<div className="page-header__title">
<h1>Configuration</h1>
<p>Model pricing and cost estimation settings</p>
</div>
</header>
<Tabs
className="page-tabs"
defaultValue="model-costs"
items={[
{ key: 'model-costs', label: 'Model costs', children: null },
{
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
<div className="filters-bar">
<Input
className="filters-bar__search"
placeholder="Search by model or provider…"
prefix={<Search size={14} />}
value={searchInput}
onChange={(event): void => setSearchInput(event.target.value)}
testId="search-input"
/>
<SelectSimple
className="filters-bar__source"
value={source}
onChange={(value): void => setSource(value as SourceFilter)}
items={SOURCE_OPTIONS}
testId="source-select"
/>
<SelectSimple
className="filters-bar__currency"
value={currency}
onChange={(value): void => setCurrency(value as string)}
items={CURRENCY_OPTIONS}
testId="currency-select"
/>
{canManagePricing && (
<Button
variant="solid"
color="primary"
className="filters-bar__add"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className="page-error" role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
/>
{total > PAGE_SIZE && (
<Pagination
className="page-pagination"
total={total}
pageSize={PAGE_SIZE}
current={page}
onPageChange={setPage}
/>
)}
<footer className="page-footer">
Showing {rules.length} of {total} model{total === 1 ? '' : 's'}
{' · '}All prices per 1M tokens (USD)
</footer>
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={drawer.save}
onDelete={drawer.deleteRule}
isSaving={drawer.isSaving}
isDeleting={drawer.isDeleting}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -1,388 +0,0 @@
.model-cost-drawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: 20px 24px;
--dialog-footer-padding: 16px 24px;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 24px;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
display: flex;
overflow-y: auto;
&__title {
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
&-right {
display: flex;
gap: 8px;
margin-left: auto;
}
}
.drawer-section {
display: flex;
flex-direction: column;
gap: 6px;
label,
.field-label {
font-size: 12px;
font-weight: 600;
color: var(--bg-vanilla-200);
}
.help {
margin: 0;
font-size: 11px;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.required {
color: var(--bg-cherry-400);
}
.full-width {
width: 100%;
}
.drawer-surface {
padding: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-300);
}
}
}
.managed-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--bg-vanilla-400);
}
.pattern-box {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
}
.pattern-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.pattern-chip {
display: inline-flex;
align-items: center;
gap: 4px;
&__remove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--bg-cherry-400);
}
}
}
.pattern-add {
display: flex;
gap: 6px;
}
.help {
code {
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
font-family: var(--code-font-family, monospace);
font-size: 10px;
}
}
.source-radio-group {
// @signozhq/ui's RadioGroupItem defaults its unchecked border to
// --l3-background, which matches the drawer surface and makes the dot
// invisible. Override with a contrasting border so users can see the
// unchecked state.
--radio-group-item-border-color: var(--bg-slate-200);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
// Layout overrides for @signozhq/ui's RadioGroupItem wrapper. The
// library injects single-class CSS at runtime (after our bundled
// stylesheet loads), so we use a two-class selector to win the
// cascade and force the wrapper to lay the dot on the left with the
// label text flush beside it.
.source-radio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with
// the title baseline (margin-top compensates for align-items:
// flex-start vs the title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawer-section label typography
// leak (set earlier in this file) so the title/desc divs use
// their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&__title {
font-weight: 600;
font-size: 13px;
color: var(--bg-vanilla-100);
}
&__desc {
margin-top: 2px;
font-size: 12px;
color: var(--bg-vanilla-400);
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.source-radio--auto:has(button[data-state='checked']) {
background: rgba(78, 116, 248, 0.1);
border-color: rgba(78, 116, 248, 0.3);
}
&.source-radio--override:has(button[data-state='checked']) {
background: rgba(245, 175, 25, 0.1);
border-color: rgba(245, 175, 25, 0.3);
}
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
}
.reset-confirm {
margin-top: 12px;
padding: 12px;
border-radius: 4px;
background: rgba(78, 116, 248, 0.06);
border: 1px solid rgba(78, 116, 248, 0.2);
p {
margin: 0 0 10px;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
.pricing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.pricing-field {
display: flex;
flex-direction: column;
gap: 4px;
input {
width: 100%;
}
}
.cache-mode-field {
margin-top: 10px;
}
.extra-buckets-section {
margin-top: 14px;
gap: 10px;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
.optional-label {
font-size: 11px;
font-weight: 400;
color: var(--bg-vanilla-400);
}
}
}
.bucket-row {
display: flex;
align-items: center;
gap: 10px;
&__name {
flex: 0 0 110px;
font-size: 12px;
color: var(--bg-vanilla-200);
font-family: var(--code-font-family, monospace);
}
input {
flex: 1 1 auto;
min-width: 0;
}
&__unit {
flex: 0 0 auto;
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__remove {
flex: 0 0 auto;
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
color: var(--bg-vanilla-400);
display: inline-flex;
align-items: center;
&:hover {
color: var(--bg-cherry-400);
}
}
}
.bucket-add-btn {
width: 100%;
}
.bucket-picker {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
&__title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.drawer-error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 12px;
}
}

View File

@@ -1,177 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Trash2 } from '@signozhq/icons';
import PatternEditor from './PatternEditor';
import PricingFields from './PricingFields';
import SourceSelector from './SourceSelector';
import { PROVIDER_OPTIONS } from './constants';
import { validateDraft } from './utils';
import type { DrawerDraft, DrawerMode } from './types';
import './ModelCostDrawer.styles.scss';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !draft.isOverride;
const validation = validateDraft(draft, mode);
const showValidationTooltip =
canManage && !validation.ok && !!validation.message;
const update = (patch: Partial<DrawerDraft>): void => {
setDraft({ ...draft, ...patch });
};
const footer = (
<div className="model-cost-drawer__footer">
{mode === 'edit' && canManage && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
loading={isDeleting}
testId="drawer-delete-btn"
>
Delete
</Button>
)}
<div className="model-cost-drawer__footer-right">
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<TooltipSimple
title={showValidationTooltip ? validation.message : ''}
withPortal={false}
>
{/* span wrapper so the tooltip fires even when the button is disabled */}
<span className="model-cost-drawer__save-wrap">
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!validation.ok}
testId="drawer-save-btn"
>
Save
</Button>
</span>
</TooltipSimple>
)}
</div>
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className="model-cost-drawer"
footer={footer}
title={mode === 'edit' ? 'Edit model cost' : 'Add model cost'}
subTitle="Pricing computes gen_ai.estimated_total_cost at ingest."
drawerHeaderProps={{ className: 'model-cost-drawer__title' }}
>
<div className="drawer-section">
<label htmlFor="billing-model-id">Billing model ID</label>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
value={draft.modelName}
disabled={mode === 'edit' || metadataReadOnly}
onChange={(e): void => update({ modelName: e.target.value })}
testId="drawer-model-id-input"
/>
</div>
<div className="drawer-section">
<label htmlFor="provider-select">Provider</label>
<SelectSimple
id="provider-select"
value={draft.provider}
onChange={(value): void => update({ provider: value as string })}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className="full-width"
withPortal={false}
testId="drawer-provider-select"
/>
</div>
<PatternEditor
patterns={draft.patterns}
isReadOnly={metadataReadOnly}
onChange={(patterns): void => update({ patterns })}
/>
<SourceSelector
isOverride={draft.isOverride}
isReadOnly={metadataReadOnly}
disableAuto={mode === 'add'}
onChange={(isOverride): void => update({ isOverride })}
/>
<PricingFields
pricing={draft.pricing}
isReadOnly={pricingReadOnly}
onChange={(patch): void =>
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } })
}
/>
{saveError && (
<div className="drawer-error" role="alert">
{saveError}
</div>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

View File

@@ -1,179 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@signozhq/ui/table';
import { ChevronDown } from '@signozhq/icons';
import cx from 'classnames';
import { startCase } from 'lodash-es';
import type { PricingRule } from './types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from './utils';
const COLUMN_COUNT = 8;
interface ModelCostsTableProps {
rules: PricingRule[];
isLoading: boolean;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: PricingRule) => void;
}
interface RowProps {
rule: PricingRule;
isSelected: boolean;
canManage: boolean;
onEdit: (rule: PricingRule) => void;
}
function ModelCostRow({
rule,
isSelected,
canManage,
onEdit,
}: RowProps): JSX.Element {
const buckets = getExtraBuckets(rule);
return (
<TableRow
className={cx({ 'model-costs-table__row--selected': isSelected })}
data-testid={`model-cost-row-${rule.id}`}
>
<TableCell>
<div className="model-cell">
<div
className="model-cell__name"
data-testid={`model-cell-name-${rule.id}`}
>
{rule.modelName}
</div>
<div
className="model-cell__canonical-id"
data-testid={`model-cell-canonical-id-${rule.id}`}
>
{getCanonicalId(rule)}
</div>
</div>
</TableCell>
<TableCell>{rule.provider}</TableCell>
<TableCell>
<span className="price-cell" data-testid={`price-cell-input-${rule.id}`}>
{formatPricePerMillion(rule.pricing?.input)}
</span>
</TableCell>
<TableCell>
<span className="price-cell" data-testid={`price-cell-output-${rule.id}`}>
{formatPricePerMillion(rule.pricing?.output)}
</span>
</TableCell>
<TableCell>
{buckets.length === 0 ? (
<span className="muted"></span>
) : (
<div className="extra-buckets">
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className="extra-buckets__chip"
>
<span className="extra-buckets__key">{startCase(bucket.key)}</span>
<span className="extra-buckets__price">
{formatPricePerMillion(bucket.pricePerMillion)}
</span>
</Badge>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge
color={rule.isOverride ? 'amber' : 'robin'}
variant="outline"
className="source-badge"
data-testid={`source-badge-${rule.id}`}
>
{getSourceLabel(rule)}
</Badge>
</TableCell>
<TableCell>{getRelativeLastSeen(rule)}</TableCell>
<TableCell>
<Button
variant="ghost"
color="secondary"
size="sm"
suffix={<ChevronDown size={14} />}
testId={`edit-rule-${rule.id}`}
onClick={(): void => onEdit(rule)}
>
{canManage ? 'Edit' : 'View'}
</Button>
</TableCell>
</TableRow>
);
}
function ModelCostsTable({
rules,
isLoading,
selectedRuleId,
canManage,
onEdit,
}: ModelCostsTableProps): JSX.Element {
return (
<Table className="model-costs-table" testId="model-costs-table">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Input / 1M</TableHead>
<TableHead>Output / 1M</TableHead>
<TableHead>Extra buckets</TableHead>
<TableHead>Source</TableHead>
<TableHead>Last seen</TableHead>
<TableHead aria-label="Actions" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && rules.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
Loading pricing rules
</TableCell>
</TableRow>
)}
{!isLoading && rules.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
No model costs yet.
</TableCell>
</TableRow>
)}
{rules.map((rule) => (
<ModelCostRow
key={rule.id}
rule={rule}
isSelected={rule.id === selectedRuleId}
canManage={canManage}
onEdit={onEdit}
/>
))}
</TableBody>
</Table>
);
}
export default ModelCostsTable;

View File

@@ -1,96 +0,0 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { X } from '@signozhq/icons';
interface PatternEditorProps {
patterns: string[];
isReadOnly: boolean;
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
onChange,
}: PatternEditorProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || patterns.includes(next)) {
setPatternInput('');
return;
}
onChange([...patterns, next]);
setPatternInput('');
};
const removePattern = (pattern: string): void => {
onChange(patterns.filter((p) => p !== pattern));
};
return (
<div className="drawer-section">
<span className="field-label">
Model name patterns <span className="muted">(prefix match)</span>
</span>
<div className="pattern-box">
<div className="pattern-chips">
{patterns.map((pattern) => (
<Badge
key={pattern}
color="vanilla"
variant="outline"
className="pattern-chip"
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className="pattern-chip__remove"
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className="pattern-add">
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
addPattern();
}
}}
testId="drawer-pattern-input"
/>
<Button
variant="outlined"
color="secondary"
onClick={addPattern}
testId="drawer-pattern-add-btn"
>
+ Add
</Button>
</div>
)}
</div>
<p className="muted help">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
</p>
</div>
);
}
export default PatternEditor;

View File

@@ -1,78 +0,0 @@
import { Input } from '@signozhq/ui/input';
import { Lock } from '@signozhq/icons';
import ExtraPricingBuckets from './ExtraPricingBuckets';
import { parsePricingAmount } from './utils';
import type { DrawerDraft } from './types';
type Pricing = DrawerDraft['pricing'];
interface PricingFieldsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function PricingFields({
pricing,
isReadOnly,
onChange,
}: PricingFieldsProps): JSX.Element {
return (
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Pricing (per 1M tokens, USD)</h4>
{isReadOnly && (
<span className="managed-label" data-testid="drawer-readonly-label">
<Lock size={12} />
Read-only
</span>
)}
</div>
<div className="pricing-grid">
<div className="pricing-field">
<label htmlFor="input-cost">
Input cost <span className="required">*</span>
</label>
<Input
id="input-cost"
type="number"
min={0}
step={0.01}
value={pricing.input}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ input: parsePricingAmount(e.target.value) ?? 0 })
}
testId="drawer-input-cost"
/>
</div>
<div className="pricing-field">
<label htmlFor="output-cost">
Output cost <span className="required">*</span>
</label>
<Input
id="output-cost"
type="number"
min={0}
step={0.01}
value={pricing.output}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ output: parsePricingAmount(e.target.value) ?? 0 })
}
testId="drawer-output-cost"
/>
</div>
</div>
<ExtraPricingBuckets
pricing={pricing}
isReadOnly={isReadOnly}
onChange={onChange}
/>
</div>
);
}
export default PricingFields;

View File

@@ -1,110 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
interface SourceSelectorProps {
isOverride: boolean;
isReadOnly: boolean;
disableAuto?: boolean;
onChange: (isOverride: boolean) => void;
}
// Auto-populated vs user-override selector, with a confirm step before
// discarding custom values back to defaults.
function SourceSelector({
isOverride,
isReadOnly,
disableAuto = false,
onChange,
}: SourceSelectorProps): JSX.Element {
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !isOverride) {
onChange(true);
}
};
const confirmReset = (): void => {
onChange(false);
setShowResetConfirm(false);
};
return (
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Source</h4>
{isReadOnly && (
<span className="managed-label" data-testid="drawer-managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={isOverride ? 'override' : 'auto'}
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
className="source-radio-group"
>
<RadioGroupItem
value="auto"
containerClassName="source-radio source-radio--auto"
testId="drawer-source-auto"
disabled={disableAuto}
>
<div className="source-radio__title">Auto-populated</div>
<div className="source-radio__desc">
{disableAuto
? 'Available once SigNoz has default pricing for this model.'
: 'Default pricing from SigNoz.'}
</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName="source-radio source-radio--override"
testId="drawer-source-override"
>
<div className="source-radio__title">User override</div>
<div className="source-radio__desc">Custom pricing. Takes precedence.</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div
className="reset-confirm"
role="dialog"
aria-label="Reset to default pricing"
>
<p>
Reset to default pricing? Custom values will be discarded. it might take
24 hours for changes to take effect.
</p>
<div className="reset-confirm__actions">
<Button
variant="outlined"
color="secondary"
onClick={(): void => setShowResetConfirm(false)}
testId="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
variant="solid"
color="primary"
onClick={confirmReset}
testId="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
);
}
export default SourceSelector;

View File

@@ -1,194 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
const ENDPOINT = '*/api/v1/llm_pricing_rules';
const mockRules: LlmpricingruletypesLLMPricingRuleDTO[] = [
{
id: 'rule-gpt4o',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
},
{
id: 'rule-llama',
orgId: 'org-1',
modelName: 'llama-3.1-70b',
provider: 'Self-hosted',
modelPattern: ['llama-3.1'],
isOverride: true,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 0, output: 0 },
},
];
describe('LLMObservabilityModelPricing', () => {
beforeEach(() => {
server.use(
rest.get(ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: mockRules,
limit: 100,
offset: 0,
total: mockRules.length,
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page header and both rules', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
expect(screen.getByText('Configuration')).toBeInTheDocument();
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
});
it('search is server-driven: typing sends the q param to the list request', async () => {
const requestedQ: (string | null)[] = [];
server.use(
rest.get(ENDPOINT, (req, res, ctx) => {
requestedQ.push(req.url.searchParams.get('q'));
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: { items: mockRules, limit: 20, offset: 0, total: mockRules.length },
}),
);
}),
);
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'llama' },
});
// Debounced, so the request fires shortly after typing stops.
await waitFor(() => expect(requestedQ).toContain('llama'));
});
// TODO: drive this through the actual source <SelectSimple> UI (open the
// dropdown + click "User override") instead of seeding the URL. The radix
// Select popover doesn't open under jsdom, so for now we assert the wiring
// via the URL param. Fix once we have a reliable way to interact with the
// Select in tests.
it('source filter is server-driven: the URL source param is sent to the list request', async () => {
const requestedSource: (string | null)[] = [];
server.use(
rest.get(ENDPOINT, (req, res, ctx) => {
requestedSource.push(req.url.searchParams.get('source'));
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: { items: mockRules, limit: 20, offset: 0, total: mockRules.length },
}),
);
}),
);
render(
<LLMObservabilityModelPricing />,
{},
{ initialRoute: '/?source=override' },
);
await screen.findByText('gpt-4o');
await waitFor(() => expect(requestedSource).toContain('override'));
});
it('opens the drawer in Add mode when the Add button is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('add-model-cost-btn'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('');
});
it('opens the drawer in Edit mode with prefilled values when a row Edit is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('edit-rule-rule-gpt4o'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('gpt-4o');
});
it('hides the Add button for non-admin users (write APIs are Admin-only)', async () => {
render(<LLMObservabilityModelPricing />, {}, { role: 'VIEWER' });
await screen.findByText('gpt-4o');
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
// rows still open in read-only "View" mode
expect(screen.getByTestId('edit-rule-rule-gpt4o')).toHaveTextContent('View');
});
it('paginates server-side: selecting page 2 requests the next offset', async () => {
const requestedOffsets: number[] = [];
server.use(
rest.get(ENDPOINT, (req, res, ctx) => {
const offset = Number(req.url.searchParams.get('offset') ?? '0');
requestedOffsets.push(offset);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: [
{ ...mockRules[0], id: `rule-${offset}`, modelName: `model-${offset}` },
],
limit: 20,
offset,
total: 25,
},
}),
);
}),
);
render(<LLMObservabilityModelPricing />);
await screen.findByText('model-0');
fireEvent.click(screen.getByRole('button', { name: '2' }));
await screen.findByText('model-20');
expect(requestedOffsets).toContain(20);
});
});

View File

@@ -1,302 +0,0 @@
import { useState } from 'react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from 'tests/test-utils';
import { EMPTY_DRAFT } from '../constants';
import type { DrawerDraft } from '../types';
import ModelCostDrawer from '../ModelCostDrawer';
interface HarnessProps {
initialDraft?: DrawerDraft;
mode?: 'add' | 'edit';
canManage?: boolean;
onSave?: () => void;
onDelete?: () => void;
}
function Harness({
initialDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1 },
},
mode = 'add',
canManage = true,
onSave = jest.fn(),
onDelete = jest.fn(),
}: HarnessProps): JSX.Element {
const [draft, setDraft] = useState<DrawerDraft>(initialDraft);
return (
<ModelCostDrawer
isOpen
mode={mode}
draft={draft}
setDraft={setDraft}
onClose={jest.fn()}
onSave={onSave}
onDelete={onDelete}
isSaving={false}
isDeleting={false}
saveError={null}
canManage={canManage}
/>
);
}
describe('ModelCostDrawer', () => {
it('adds a pattern chip when the user types and presses Enter', () => {
render(<Harness />);
fireEvent.change(screen.getByTestId('drawer-pattern-input'), {
target: { value: 'gpt-4o-mini' },
});
fireEvent.keyDown(screen.getByTestId('drawer-pattern-input'), {
key: 'Enter',
code: 'Enter',
});
expect(screen.getByText('gpt-4o-mini*')).toBeInTheDocument();
});
it('disables pricing fields when isOverride is false', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: false,
}}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).toBeDisabled();
});
it('enables pricing fields when isOverride is true', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).not.toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).not.toBeDisabled();
});
it('shows only the Add pricing bucket button when no extra buckets are set', () => {
render(<Harness mode="add" />);
expect(screen.getByTestId('drawer-add-bucket-btn')).toBeInTheDocument();
expect(
screen.queryByTestId('drawer-cache-read-cost'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('drawer-cache-write-cost'),
).not.toBeInTheDocument();
});
it('adds a cache bucket from the picker', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<Harness mode="add" />);
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
// Picker offers both cache buckets…
expect(
screen.getByTestId('drawer-add-bucket-cache-read'),
).toBeInTheDocument();
expect(
screen.getByTestId('drawer-add-bucket-cache-write'),
).toBeInTheDocument();
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
// …and picking one reveals its price input.
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
});
it('removes an existing cache bucket', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1, cacheRead: 0.5 },
}}
/>,
);
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
await user.click(screen.getByTestId('drawer-remove-cache-read'));
expect(
screen.queryByTestId('drawer-cache-read-cost'),
).not.toBeInTheDocument();
});
it('hides bucket add/remove controls in read-only mode but shows set buckets', () => {
render(
<Harness
mode="edit"
canManage={false}
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, cacheRead: 0.5 },
}}
/>,
);
expect(screen.getByTestId('drawer-cache-read-cost')).toBeDisabled();
expect(screen.queryByTestId('drawer-add-bucket-btn')).not.toBeInTheDocument();
expect(
screen.queryByTestId('drawer-remove-cache-read'),
).not.toBeInTheDocument();
});
it('disables the Provider select in Edit mode but allows it in Add mode', () => {
const { unmount } = render(<Harness mode="add" />);
expect(screen.getByTestId('drawer-provider-select')).not.toHaveAttribute(
'data-disabled',
);
unmount();
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-provider-select')).toHaveAttribute(
'data-disabled',
);
});
it('keeps metadata editable but locks pricing when source is auto-populated', () => {
render(
<Harness
mode="add"
initialDraft={{ ...EMPTY_DRAFT, modelName: 'gpt-4o', isOverride: false }}
/>,
);
// Metadata stays editable while the rule is auto-populated…
expect(screen.getByTestId('drawer-model-id-input')).not.toBeDisabled();
// …but pricing is read-only until "User override" is chosen.
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
});
it('shows a reset confirmation when switching from Override to Auto', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
await user.click(screen.getByTestId('drawer-source-auto'));
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
});
it('disables the Auto-populated source option in Add mode', () => {
render(<Harness mode="add" />);
expect(screen.getByTestId('drawer-source-auto')).toBeDisabled();
});
it('enables the Auto-populated source option in Edit mode', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-source-auto')).not.toBeDisabled();
});
it('hides the Delete action in Add mode', () => {
render(<Harness mode="add" />);
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
});
it('shows the Delete action in Edit mode', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-delete-btn')).toBeInTheDocument();
});
it('calls onSave when the Save button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(<Harness onSave={onSave} />);
await user.click(screen.getByTestId('drawer-save-btn'));
expect(onSave).toHaveBeenCalledTimes(1);
});
it('is read-only when the user cannot manage pricing (hides Save/Delete)', () => {
render(
<Harness
mode="edit"
canManage={false}
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
expect(screen.getByTestId('drawer-model-id-input')).toBeDisabled();
});
});

View File

@@ -1,149 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EMPTY_DRAFT } from '../constants';
import type { DrawerDraft, PricingRule } from '../types';
import {
buildPricingPayload,
buildRulePayload,
draftFromRule,
validateDraft,
} from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('drawer draft utils', () => {
describe('draftFromRule', () => {
it('maps a rule to a draft with cache values when present', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 3.75,
},
},
});
const draft = draftFromRule(rule);
expect(draft.modelName).toBe('gpt-4o');
expect(draft.pricing.input).toBe(3);
expect(draft.pricing.cacheRead).toBe(0.3);
expect(draft.pricing.cacheWrite).toBe(3.75);
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.additive);
});
it('falls back to defaults when cache is missing', () => {
const draft = draftFromRule(makeRule());
expect(draft.pricing.cacheRead).toBeNull();
expect(draft.pricing.cacheWrite).toBeNull();
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
});
});
describe('buildPricingPayload', () => {
it('omits the cache block when no cache values are set', () => {
const payload = buildPricingPayload(EMPTY_DRAFT);
expect(payload.cache).toBeUndefined();
});
it('includes only the cache values that are > 0', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
pricing: {
...EMPTY_DRAFT.pricing,
cacheRead: 1.5,
cacheWrite: 0,
cacheMode: CacheModeDTO.subtract,
},
};
const payload = buildPricingPayload(draft);
expect(payload.cache?.read).toBe(1.5);
expect(payload.cache?.write).toBeUndefined();
expect(payload.cache?.mode).toBe(CacheModeDTO.subtract);
});
});
describe('buildRulePayload', () => {
it('uses the modelName as a default pattern when no patterns are supplied', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
patterns: [],
provider: 'OpenAI',
};
const payload = buildRulePayload(draft);
expect(payload.modelPattern).toStrictEqual(['gpt-4o']);
expect(payload.unit).toBe(UnitDTO.per_million_tokens);
expect(payload.enabled).toBe(true);
});
it('omits id and sourceId for an Add draft', () => {
const payload = buildRulePayload(EMPTY_DRAFT);
expect(payload.id).toBeUndefined();
expect(payload.sourceId).toBeUndefined();
});
});
describe('validateDraft', () => {
it('requires a model name in Add mode', () => {
const result = validateDraft(EMPTY_DRAFT, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/billing model id/i);
});
it('rejects negative pricing', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: -1 },
};
expect(validateDraft(draft, 'add').ok).toBe(false);
});
it('accepts a valid Add draft', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 2 },
};
expect(validateDraft(draft, 'add').ok).toBe(true);
});
it('rejects zero input/output cost for overrides', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 5 },
};
const result = validateDraft(draft, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/input cost must be greater than 0/i);
});
it('skips pricing validation for auto-populated (non-override) rules', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: false,
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 0 },
};
expect(validateDraft(draft, 'edit').ok).toBe(true);
});
});
});

View File

@@ -1,90 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } from '../types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('utils', () => {
describe('formatPricePerMillion', () => {
it('formats numbers with 2 decimals and dollar prefix', () => {
expect(formatPricePerMillion(15)).toBe('$15.00');
expect(formatPricePerMillion(0.15)).toBe('$0.15');
});
it('returns em-dash for nullish or NaN', () => {
expect(formatPricePerMillion(undefined)).toBe('—');
expect(formatPricePerMillion(Number.NaN)).toBe('—');
});
});
describe('getExtraBuckets', () => {
it('returns an empty array when there is no cache pricing', () => {
expect(getExtraBuckets(makeRule())).toStrictEqual([]);
});
it('returns only buckets with values > 0', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 0,
},
},
});
const buckets = getExtraBuckets(rule);
expect(buckets).toStrictEqual([{ key: 'cache_read', pricePerMillion: 0.3 }]);
});
});
describe('getSourceLabel', () => {
it('returns "Auto" for non-override and "User override" otherwise', () => {
expect(getSourceLabel(makeRule({ isOverride: false }))).toBe('Auto');
expect(getSourceLabel(makeRule({ isOverride: true }))).toBe('User override');
});
});
describe('getCanonicalId', () => {
it('lowercases the provider and joins with the model name', () => {
expect(getCanonicalId(makeRule({ provider: 'OpenAI' }))).toBe(
'openai:gpt-4o',
);
});
});
describe('getRelativeLastSeen', () => {
it('returns em-dash when no timestamp is present', () => {
expect(getRelativeLastSeen(makeRule())).toBe('—');
});
it('formats minutes-old timestamps via dayjs fromNow', () => {
const recent = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(getRelativeLastSeen(makeRule({ updatedAt: recent }))).toMatch(
/minutes? ago/,
);
});
});
});

View File

@@ -1,41 +0,0 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { CacheBucketDef, DrawerDraft } from './types';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
{ value: CacheModeDTO.unknown, label: 'Unknown' },
];
// Optional buckets offered in the "Add pricing bucket" picker. Only the cache
// buckets are persisted by the API today (pricing.cache.read/write).
export const CACHE_BUCKETS: CacheBucketDef[] = [
{ key: 'cacheRead', label: 'cache_read', testId: 'cache-read' },
{ key: 'cacheWrite', label: 'cache_write', testId: 'cache-write' },
];
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: 0,
output: 0,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};

View File

@@ -1,55 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type ListLLMPricingRulesParams,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export type SourceFilter = 'all' | 'auto' | 'override';
// List request params. Extends the generated (offset/limit) params with the
// search/source filters — backend support is pending, but sending them now
// keeps the page backend-driven so no FE change is needed once it lands.
export type ListModelPricingParams = ListLLMPricingRulesParams & {
q?: string;
source?: Exclude<SourceFilter, 'all'>;
};
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export type DrawerMode = 'add' | 'edit';
// Optional pricing buckets the user can add/remove. Keyed by the matching
// DrawerDraft['pricing'] field.
export type CacheBucketKey = 'cacheRead' | 'cacheWrite';
export interface CacheBucketDef {
key: CacheBucketKey;
label: string;
testId: string;
}
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number;
output: number;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}
export interface ValidationResult {
ok: boolean;
message?: string;
}

View File

@@ -1,123 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import { EMPTY_DRAFT } from './constants';
import type { DrawerDraft, DrawerMode, PricingRule } from './types';
import { buildRulePayload, draftFromRule } from './utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: () => Promise<void>;
deleteRule: () => Promise<void>;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [draft, setDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((prefillModelName?: string): void => {
setMode('add');
setDraft({
...EMPTY_DRAFT,
modelName: prefillModelName || '',
patterns: prefillModelName ? [prefillModelName] : [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(async (): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
}, [createOrUpdate, draft, invalidateList, mode]);
const deleteRule = useCallback(async (): Promise<void> => {
if (!draft.id) {
return;
}
setSaveError(null);
try {
await deleteRuleApi({ pathParams: { id: draft.id } });
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success('Model cost deleted');
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
setSaveError(message);
}
}, [deleteRuleApi, draft.id, invalidateList]);
return {
isOpen,
mode,
draft,
setDraft,
openForAdd,
openForEdit,
close,
save,
deleteRule,
isSaving,
isDeleting,
saveError,
selectedRuleId,
};
}

View File

@@ -1,74 +0,0 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import useUrlQuery from 'hooks/useUrlQuery';
import type { SourceFilter } from './types';
const SEARCH_KEY = 'q';
const SOURCE_KEY = 'source';
const PAGE_KEY = 'page';
const DEFAULT_SOURCE: SourceFilter = 'all';
export interface ModelPricingFilters {
search: string;
source: SourceFilter;
page: number;
setSearch: (value: string) => void;
setSource: (value: SourceFilter) => void;
setPage: (value: number) => void;
}
const isSourceFilter = (value: string | null): value is SourceFilter =>
value === 'all' || value === 'auto' || value === 'override';
// Keeps the model-cost list filters (search / source / page) in the URL so the
// view is shareable and reload-safe. These map straight onto the list request
// params (q, source, offset), making the table backend-driven once the API
// honours them.
export function useModelPricingFilters(): ModelPricingFilters {
const history = useHistory();
const urlQuery = useUrlQuery();
const search = urlQuery.get(SEARCH_KEY) ?? '';
const sourceParam = urlQuery.get(SOURCE_KEY);
const source = isSourceFilter(sourceParam) ? sourceParam : DEFAULT_SOURCE;
const page = Math.max(1, Number(urlQuery.get(PAGE_KEY)) || 1);
const setParam = useCallback(
(key: string, value: string | null, resetPage: boolean): void => {
const next = new URLSearchParams(urlQuery.toString());
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
// Filter changes invalidate the current page offset.
if (resetPage) {
next.delete(PAGE_KEY);
}
history.replace({ search: next.toString() });
},
[history, urlQuery],
);
const setSearch = useCallback(
(value: string): void => setParam(SEARCH_KEY, value.trim(), true),
[setParam],
);
const setSource = useCallback(
(value: SourceFilter): void =>
// 'all' is the default, so keep it out of the URL.
setParam(SOURCE_KEY, value === DEFAULT_SOURCE ? null : value, true),
[setParam],
);
const setPage = useCallback(
(value: number): void =>
setParam(PAGE_KEY, value <= 1 ? null : String(value), false),
[setParam],
);
return { search, source, page, setSearch, setSource, setPage };
}

View File

@@ -1,162 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type {
DrawerDraft,
DrawerMode,
ExtraBucket,
PricingRule,
ValidationResult,
} from './types';
// Idempotent — relativeTime is also extended globally in utils/timeUtils.
dayjs.extend(relativeTime);
const lc = (value: string): string => value.toLowerCase();
const hasCacheValue = (value: number | null): boolean =>
typeof value === 'number' && value > 0;
// ─── Input helpers ───────────────────────────────────────────────────────────
// Parses a price input's raw string. Empty → null (used by optional buckets),
// otherwise a finite number (NaN coerced to 0).
export const parsePricingAmount = (raw: string): number | null => {
if (raw.trim() === '') {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null || Number.isNaN(value)) {
return '—';
}
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
export const getRelativeLastSeen = (rule: PricingRule): string => {
const ts = rule.updatedAt || rule.syncedAt || rule.createdAt;
const parsed = ts ? dayjs(ts) : null;
return parsed?.isValid() ? parsed.fromNow() : '—';
};
export const getCanonicalId = (rule: PricingRule): string => {
const provider = rule.provider?.trim() || 'unknown';
return `${lc(provider)}:${rule.modelName}`;
};
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider || 'OpenAI',
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const pricing: LlmpricingruletypesLLMRulePricingDTO = {
input: draft.pricing.input,
output: draft.pricing.output,
};
if (
hasCacheValue(draft.pricing.cacheRead) ||
hasCacheValue(draft.pricing.cacheWrite)
) {
const cache: LlmpricingruletypesLLMPricingCacheCostsDTO = {
mode: draft.pricing.cacheMode,
};
if (hasCacheValue(draft.pricing.cacheRead)) {
cache.read = draft.pricing.cacheRead as number;
}
if (hasCacheValue(draft.pricing.cacheWrite)) {
cache.write = draft.pricing.cacheWrite as number;
}
pricing.cache = cache;
}
return pricing;
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern:
draft.patterns.length > 0 ? draft.patterns : [draft.modelName.trim()],
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export const validateDraft = (
draft: DrawerDraft,
mode: DrawerMode,
): ValidationResult => {
if (mode === 'add' && !draft.modelName.trim()) {
return { ok: false, message: 'Billing model ID is required.' };
}
if (!draft.provider.trim()) {
return { ok: false, message: 'Provider is required.' };
}
// Pricing is only user-entered for overrides; auto-populated rules are
// managed by SigNoz (and may legitimately be 0 for self-hosted models).
if (draft.isOverride) {
if (!(draft.pricing.input > 0)) {
return { ok: false, message: 'Input cost must be greater than 0.' };
}
if (!(draft.pricing.output > 0)) {
return { ok: false, message: 'Output cost must be greater than 0.' };
}
if (
(draft.pricing.cacheRead !== null && draft.pricing.cacheRead < 0) ||
(draft.pricing.cacheWrite !== null && draft.pricing.cacheWrite < 0)
) {
return { ok: false, message: 'Cache costs must be non-negative.' };
}
}
return { ok: true };
};

View File

@@ -39,6 +39,7 @@
.right-header {
display: flex;
gap: 16px;
align-items: center;
}
}

View File

@@ -15,6 +15,7 @@ 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';
@@ -820,6 +821,11 @@ function NewWidget({
</Flex>
</div>
<div className="right-header">
<HeaderRightSection
enableAnnouncements={false}
enableShare={false}
enableFeedback={false}
/>
{showSwitchToViewModeButton && (
<Button
color="primary"

View File

@@ -206,7 +206,6 @@ export const routesToSkip = [
ROUTES.METER,
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -68,10 +68,6 @@
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
.new-view-btn {
margin-left: 8px;
}
}
.second-row {

View File

@@ -0,0 +1,48 @@
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);
});
});

View File

@@ -0,0 +1,23 @@
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],
);
}

View File

@@ -1,7 +0,0 @@
import LLMObservabilityModelPricing from 'container/LLMObservabilityModelPricing/LLMObservabilityModelPricing';
function LLMObservabilityModelPricingPage(): JSX.Element {
return <LLMObservabilityModelPricing />;
}
export default LLMObservabilityModelPricingPage;

View File

@@ -0,0 +1,18 @@
import { Redirect, useParams } from 'react-router-dom';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
// Legacy /trace-old/:id now redirects to the current /trace/:id view,
// preserving the query string and hash.
export default function TraceDetailOldRedirect(): JSX.Element {
const { id } = useParams<TraceDetailV2URLProps>();
return (
<Redirect
to={{
pathname: `/trace/${id}`,
search: window.location.search,
hash: window.location.hash,
}}
/>
);
}

View File

@@ -1,25 +0,0 @@
import { Redirect, useParams } from 'react-router-dom';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import TraceDetailsV3 from '../TraceDetailsV3';
export default function TraceDetailV3Page(): JSX.Element {
const { id } = useParams<TraceDetailV2URLProps>();
const preferOld =
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true';
if (preferOld) {
return (
<Redirect
to={{
pathname: `/trace-old/${id}`,
search: window.location.search,
}}
/>
);
}
return <TraceDetailsV3 />;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import {
@@ -8,11 +8,9 @@ import {
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Skeleton } from 'antd';
import setLocalStorageKey from 'api/browser/localstorage/set';
import cx from 'classnames';
import FieldsSelector from 'components/FieldsSelector';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
@@ -21,7 +19,6 @@ import {
ArrowLeft,
CalendarClock,
ChartPie,
CornerUpLeft,
Server,
Timer,
} from '@signozhq/icons';
@@ -93,18 +90,6 @@ function TraceDetailsHeader({
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
const pageLoadedAtRef = useRef(Date.now());
const handleSwitchToOldView = useCallback((): void => {
logTraceEvent(TraceDetailEvents.ViewSwitched, {
[TraceDetailEventKeys.From]: 'v3',
[TraceDetailEventKeys.To]: 'v2',
[TraceDetailEventKeys.DwellMs]: Date.now() - pageLoadedAtRef.current,
});
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
history.replace(oldUrl);
}, [traceID, logTraceEvent]);
const handleToggleAnalytics = useCallback((): void => {
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
@@ -172,20 +157,6 @@ function TraceDetailsHeader({
{!isFilterExpanded && (
<TooltipProvider>
<div className={styles.headerActions}>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Switch to legacy trace view"
onClick={handleSwitchToOldView}
>
<CornerUpLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Switch to legacy trace view</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger asChild>
<Button

View File

@@ -26,13 +26,6 @@ jest.mock('react-router-dom', () => ({
useParams: (): { id: string } => ({ id: 'trace-123' }),
}));
const mockSetLocalStorageKey = jest.fn();
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void =>
mockSetLocalStorageKey(key, value),
}));
jest.mock(
'../../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters',
() => ({
@@ -97,15 +90,11 @@ describe('TraceDetailsHeader back button', () => {
describe('TraceDetailsHeader action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
mockSetLocalStorageKey.mockClear();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
expect(
screen.queryByRole('button', { name: /switch to legacy trace view/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
@@ -114,12 +103,9 @@ describe('TraceDetailsHeader action cluster', () => {
).not.toBeInTheDocument();
});
it('renders Legacy View, Analytics, and Settings action buttons once data is loaded', () => {
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^analytics$/i }),
).toBeInTheDocument();
@@ -128,23 +114,6 @@ describe('TraceDetailsHeader action cluster', () => {
).toBeInTheDocument();
});
it('routes to the legacy trace view and persists the preference on click', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
fireEvent.click(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
);
expect(mockSetLocalStorageKey).toHaveBeenCalledWith(
'TRACE_DETAILS_PREFER_OLD_VIEW',
'true',
);
expect(mockReplace).toHaveBeenCalledTimes(1);
expect(mockReplace).toHaveBeenCalledWith(
expect.stringContaining('/trace-old/trace-123'),
);
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);

View File

@@ -22,7 +22,6 @@ export enum TraceDetailEventKeys {
SpanPanelVariant = 'spanPanelVariant',
ColorByField = 'colorByField',
PreviewFieldsCount = 'previewFieldsCount',
EntryPreferOldView = 'entryPreferOldView',
// View switched
From = 'from',
To = 'to',

View File

@@ -179,8 +179,6 @@ function TraceDetailsV3(): JSX.Element {
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
[TraceDetailEventKeys.EntryPreferOldView]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true',
});
}, [
traceId,

View File

@@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { ResourceProvider } from 'hooks/useResourceAttribute';
@@ -302,7 +301,7 @@ export function AllTheProviders({
<ErrorModalProvider>
<TimezoneProvider>
<PreferenceContextProvider>
<TooltipProvider>{queryBuilderContent}</TooltipProvider>
{queryBuilderContent}
</PreferenceContextProvider>
</TimezoneProvider>
</ErrorModalProvider>

View File

@@ -20,8 +20,7 @@ export type ComponentTypes =
| 'add_panel'
| 'page_pipelines'
| 'edit_locked_dashboard'
| 'add_panel_locked_dashboard'
| 'manage_llm_pricing';
| 'add_panel_locked_dashboard';
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
@@ -43,7 +42,6 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
page_pipelines: ['ADMIN', 'EDITOR'],
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
manage_llm_pricing: ['ADMIN', 'EDITOR', 'AUTHOR'],
};
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
@@ -138,5 +136,4 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -0,0 +1,72 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import { ENVIRONMENT } from 'constants/env';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getSigNozInstanceUrl } from './signozInstanceUrl';
jest.mock('api/browser/localstorage/get');
jest.mock('constants/env', () => ({
ENVIRONMENT: { baseURL: '' },
}));
const mockedGet = getLocalStorageApi as jest.MockedFunction<
typeof getLocalStorageApi
>;
function setOrigin(origin: string): void {
Object.defineProperty(window, 'location', {
value: { origin },
writable: true,
configurable: true,
});
}
describe('getSigNozInstanceUrl', () => {
beforeEach(() => {
mockedGet.mockReset();
ENVIRONMENT.baseURL = '';
setOrigin('http://localhost');
});
it('returns the localStorage override when present', () => {
mockedGet.mockReturnValue('https://override.example.com');
ENVIRONMENT.baseURL = 'https://build.example.com';
setOrigin('https://browser.example.com');
expect(getSigNozInstanceUrl()).toBe('https://override.example.com');
expect(mockedGet).toHaveBeenCalledWith(
LOCALSTORAGE.ACTIVE_SIGNOZ_INSTANCE_URL,
);
});
it('ignores a blank/whitespace-only override and falls through', () => {
mockedGet.mockReturnValue(' ');
ENVIRONMENT.baseURL = 'https://build.example.com';
expect(getSigNozInstanceUrl()).toBe('https://build.example.com');
});
it('returns the build-time baseURL when no override exists (cloud)', () => {
mockedGet.mockReturnValue(null);
ENVIRONMENT.baseURL = 'https://build.example.com';
setOrigin('https://browser.example.com');
expect(getSigNozInstanceUrl()).toBe('https://build.example.com');
});
it('falls back to window.location.origin when baseURL is empty (self-hosted)', () => {
mockedGet.mockReturnValue(null);
ENVIRONMENT.baseURL = '';
setOrigin('https://self-hosted.example.com');
expect(getSigNozInstanceUrl()).toBe('https://self-hosted.example.com');
});
it('returns an empty string when nothing is resolvable', () => {
mockedGet.mockReturnValue(null);
ENVIRONMENT.baseURL = '';
setOrigin('');
expect(getSigNozInstanceUrl()).toBe('');
});
});

View File

@@ -2,7 +2,37 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ENVIRONMENT } from 'constants/env';
import { getBaseUrl } from 'utils/basePath';
/**
* Resolves the SigNoz instance URL sent to the AI Assistant backend via the
* `X-SigNoz-URL` header. Resolution order:
*
* 1. `ACTIVE_SIGNOZ_INSTANCE_URL` localStorage override (cloud instance switcher).
* 2. `ENVIRONMENT.baseURL` — the build-time absolute endpoint
* (`VITE_FRONTEND_API_ENDPOINT`). Non-empty on SigNoz Cloud builds.
* 3. `getBaseUrl()` (origin + base path) — fallback for self-hosted builds.
*
* Decision (self-hosted fallback to the browser's current URL):
* Self-hosted builds ship with `VITE_FRONTEND_API_ENDPOINT` unset because the
* frontend talks to its backend over relative paths, so `ENVIRONMENT.baseURL`
* is an empty string. That previously caused `X-SigNoz-URL` to be sent empty,
* which the AI backend rejects (`missing_signoz_url` / `invalid_signoz_url`)
* rather than treating as "omitted". We deliberately fall back to the URL the
* user is actually accessing the instance from so the header is always
* populated. We use `getBaseUrl()` rather than raw `window.location.origin`
* because a self-hosted instance may be served under a base path (e.g.
* `https://host/signoz`); the backend needs that full base to reach the API.
*
* Known tradeoff: this reflects the *browser's* view of the instance. For
* deployments where the instance is not reachable from the cloud AI backend at
* that URL (air-gapped, internal-only DNS, private IPs, behind a reverse proxy
* on a different external host), it will be unreachable and the AI backend may
* fail to query the instance. Those deployments must set an explicit,
* externally reachable URL via the localStorage override. A first-class admin
* config for the public instance URL is the proper long-term fix; this fallback
* is the pragmatic default until that exists.
*/
export function getSigNozInstanceUrl(): string {
const fromStorage = getLocalStorageApi(
LOCALSTORAGE.ACTIVE_SIGNOZ_INSTANCE_URL,
@@ -12,7 +42,11 @@ export function getSigNozInstanceUrl(): string {
return fromStorage;
}
return ENVIRONMENT.baseURL;
if (ENVIRONMENT.baseURL) {
return ENVIRONMENT.baseURL;
}
return getBaseUrl();
}
export function setSigNozInstanceUrl(url: string | null | undefined): void {

View File

@@ -0,0 +1,186 @@
/**
* Stylelint rule: local/class-name-pattern
*
* Enforces camelCase class names in CSS modules.
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case is converted but
* using camelCase directly avoids confusion.
*
* BAD:
* .my-class { } // converted to myClass, but confusing
* .my_class { } // converted to myClass, but confusing
* .MyClass { } // PascalCase not conventional
*
* GOOD:
* .myClass { }
* .alertHistory { }
* .statsCard { }
*/
import stylelint from 'stylelint';
const ruleName = 'local/class-name-pattern';
const messages = stylelint.utils.ruleMessages(ruleName, {
kebabCase: (className) =>
`Class "${className}" is not on camelCase. Use instead: "${toCamelCase(className)}".`,
snakeCase: (className) =>
`Class "${className}" is not on camelCase. Use instead: "${toCamelCase(className)}".`,
pascalCase: (className) =>
`Class "${className}" is not on camelCase. Use instead: "${className.charAt(0).toLowerCase() + className.slice(1)}".`,
});
function toCamelCase(str) {
return str
.split(/[-_]/)
.map((part, i) =>
i === 0
? part.toLowerCase()
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
)
.join('');
}
function isKebabCase(str) {
return str.includes('-');
}
function isSnakeCase(str) {
return str.includes('_');
}
function isPascalCase(str) {
return /^[A-Z][a-zA-Z0-9]*$/.test(str);
}
const CLASS_PATTERN = /\.([a-zA-Z_][a-zA-Z0-9_-]*)/g;
const DEFAULT_THIRD_PARTY_PREFIXES = [
'ant-', // Ant Design
'rc-', // rc-components (Ant Design internals)
'recharts-', // Recharts
'uplot-', // uPlot
'u-', // uPlot legacy
'leaflet-', // Leaflet
'monaco-', // Monaco editor
'react-resizable', // react-resizable
'cm-', // CodeMirror
];
// Bare `:global { ... }` block (no parens) makes all descendants global.
// `:global(.foo)` is a per-selector escape — handled separately by globalRanges().
function hasBareGlobalAncestor(node) {
let current = node.parent;
while (current) {
const selector = current.selector;
if (selector && /:global(?!\s*\()/.test(selector)) {
return true;
}
current = current.parent;
}
return false;
}
// Return list of [start, end) index ranges inside `selector` that fall within a
// balanced `:global(...)` argument list. Class matches inside these ranges are
// third-party and should be skipped.
function globalRanges(selector) {
const ranges = [];
const re = /:global\s*\(/g;
let match;
while ((match = re.exec(selector)) !== null) {
const argStart = match.index + match[0].length;
let depth = 1;
let i = argStart;
while (i < selector.length && depth > 0) {
const ch = selector[i];
if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
}
if (depth > 0) {
i++;
}
}
ranges.push([argStart, i]);
re.lastIndex = i;
}
return ranges;
}
function indexInRanges(index, ranges) {
for (const [start, end] of ranges) {
if (index >= start && index < end) {
return true;
}
}
return false;
}
const rule = (primaryOption, secondaryOptions) => {
return (root, result) => {
if (!primaryOption) {
return;
}
const userPrefixes =
(secondaryOptions && secondaryOptions.ignoreThirdPartyPrefixes) || [];
const allPrefixes = [...DEFAULT_THIRD_PARTY_PREFIXES, ...userPrefixes];
root.walkRules((ruleNode) => {
const selector = ruleNode.selector;
// Bare `:global { }` block makes all descendants global — skip entirely.
if (hasBareGlobalAncestor(ruleNode)) {
return;
}
const ranges = globalRanges(selector);
let match;
CLASS_PATTERN.lastIndex = 0;
while ((match = CLASS_PATTERN.exec(selector)) !== null) {
const className = match[1];
// Skip classes inside `:global(...)` ranges of this selector.
if (indexInRanges(match.index, ranges)) {
continue;
}
// Skip third-party library classes
if (allPrefixes.some((prefix) => className.startsWith(prefix))) {
continue;
}
if (isKebabCase(className)) {
stylelint.utils.report({
message: messages.kebabCase(className),
node: ruleNode,
result,
ruleName,
});
} else if (isSnakeCase(className)) {
stylelint.utils.report({
message: messages.snakeCase(className),
node: ruleNode,
result,
ruleName,
});
} else if (isPascalCase(className)) {
stylelint.utils.report({
message: messages.pascalCase(className),
node: ruleNode,
result,
ruleName,
});
}
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -0,0 +1,164 @@
/**
* Stylelint rule: local/no-bare-element-selectors
*
* Prevents bare element selectors at root level in CSS modules.
* Bare elements affect ALL instances of that element within component scope,
* often unintentionally.
*
* BAD:
* div { }
* span { padding: 4px; }
* p { margin: 0; }
*
* GOOD:
* .container { }
* .title { }
*
* ALLOWED (nested under class):
* .container {
* p { margin: 0; } // Scoped to .container
* }
*/
import stylelint from 'stylelint';
const ruleName = 'local/no-bare-element-selectors';
const messages = stylelint.utils.ruleMessages(ruleName, {
unexpected: (element) =>
`Bare element selector "${element}" at root level affects all instances. Use class selector or nest under a class.`,
unexpectedInCompound: (element, full) =>
`Bare element "${element}" in unscoped selector "${full}" at root level matches every "<${element}>" in the module. Anchor the selector with a class (e.g., ".container ${element}") or use :global() if intentional.`,
});
const ELEMENT_PATTERN = /^[a-z][a-z0-9]*$/i;
const EXCLUDED_ELEMENTS = new Set(['html', 'body', 'root']);
function isPartBareElement(part) {
const trimmed = part.trim();
if (!trimmed) {
return false;
}
// Strip pseudo suffixes (`:hover`, `::before`) — element part is what's before
const elementOnly = trimmed.split(':')[0];
if (!elementOnly) {
return false;
}
// Skip class/id/attribute parts
if (/[.#[]/.test(elementOnly)) {
return false;
}
return (
ELEMENT_PATTERN.test(elementOnly) &&
!EXCLUDED_ELEMENTS.has(elementOnly.toLowerCase())
);
}
function isBareElement(selector) {
const trimmed = selector.trim();
// Skip combined selectors
if (/[,\s>+~]/.test(trimmed)) {
return false;
}
// Skip pseudo-selectors
if (trimmed.includes(':')) {
return false;
}
// Skip class/id/attribute selectors
if (/[.#[]/.test(trimmed)) {
return false;
}
return (
ELEMENT_PATTERN.test(trimmed) && !EXCLUDED_ELEMENTS.has(trimmed.toLowerCase())
);
}
// Split a compound selector on combinators (`>`, `+`, `~`, descendant space)
// while keeping each part intact. Returns an array of selector parts.
function splitOnCombinators(selector) {
return selector
.split(/\s*[>+~]\s*|\s+/)
.map((p) => p.trim())
.filter(Boolean);
}
function isInsideGlobal(selector) {
return /:global\s*\(/.test(selector);
}
const KEYFRAME_ATRULES = new Set([
'keyframes',
'-webkit-keyframes',
'-moz-keyframes',
'-o-keyframes',
]);
// Rule's effective parent is root if all ancestors above it are atrules
// (e.g. `@media`, `@supports`). A bare `div` inside `@media { }` at top level
// still matches every `<div>` in the module. Exclude `@keyframes` — its
// `from`/`to`/`0%` children are not element selectors.
function isEffectivelyTopLevel(node) {
let parent = node.parent;
while (parent && parent.type === 'atrule') {
if (KEYFRAME_ATRULES.has(parent.name.toLowerCase())) {
return false;
}
parent = parent.parent;
}
return Boolean(parent) && parent.type === 'root';
}
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkRules((ruleNode) => {
if (!isEffectivelyTopLevel(ruleNode)) {
return;
}
const selectors = ruleNode.selector.split(',').map((s) => s.trim());
for (const selector of selectors) {
if (isInsideGlobal(selector)) {
continue;
}
if (isBareElement(selector)) {
stylelint.utils.report({
message: messages.unexpected(selector),
node: ruleNode,
result,
ruleName,
});
continue;
}
// Check combined selectors part-by-part. Only flag when the compound
// has NO class/id anchor — `.container > div` is already scoped, but
// `div + span` at root affects every matching descendant in the module.
if (/[\s>+~]/.test(selector) && !/[.#]/.test(selector)) {
const parts = splitOnCombinators(selector);
for (const part of parts) {
if (isPartBareElement(part)) {
stylelint.utils.report({
message: messages.unexpectedInCompound(part.split(':')[0], selector),
node: ruleNode,
result,
ruleName,
});
}
}
}
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -0,0 +1,97 @@
/**
* Stylelint rule: local/no-deep-nesting
*
* Prevents deep nesting in CSS modules (max 3 levels).
* Deep nesting creates specificity wars and hard-to-override styles.
*
* BAD:
* .container { .wrapper { .inner { .content { } } } } // 4 levels
*
* GOOD:
* .container { }
* .containerWrapper { }
* .containerContent { }
*
* Allowed nesting (pseudo-classes/elements):
* .button { &:hover { } &::before { } }
*/
import stylelint from 'stylelint';
const ruleName = 'local/no-deep-nesting';
const DEFAULT_MAX_DEPTH = 3;
const messages = stylelint.utils.ruleMessages(ruleName, {
tooDeep: (depth, max) =>
`Nesting depth ${depth} exceeds maximum of ${max}. Flatten selectors for CSS modules.`,
});
function isPseudoSelector(selector) {
return /^&?:/.test(selector.trim());
}
function isParentReference(selector) {
return /^&[.#[]/.test(selector.trim());
}
function countNestingDepth(rule, depth = 0) {
const selector = rule.selector || '';
// Don't count pseudo-selectors or parent references toward depth
if (isPseudoSelector(selector) || isParentReference(selector)) {
// Still check children
let maxChildDepth = depth;
rule.walkRules?.((child) => {
const childDepth = countNestingDepth(child, depth);
maxChildDepth = Math.max(maxChildDepth, childDepth);
});
return maxChildDepth;
}
const currentDepth = depth + 1;
let maxDepth = currentDepth;
rule.walkRules?.((child) => {
const childDepth = countNestingDepth(child, currentDepth);
maxDepth = Math.max(maxDepth, childDepth);
});
return maxDepth;
}
const rule = (primaryOption, secondaryOptions) => {
return (root, result) => {
if (!primaryOption) {
return;
}
const maxDepth =
secondaryOptions && Number.isInteger(secondaryOptions.maxDepth)
? secondaryOptions.maxDepth
: DEFAULT_MAX_DEPTH;
root.walkRules((ruleNode) => {
// Only check top-level rules
if (ruleNode.parent.type !== 'root' && ruleNode.parent.type !== 'atrule') {
return;
}
const depth = countNestingDepth(ruleNode);
if (depth > maxDepth) {
stylelint.utils.report({
message: messages.tooDeep(depth, maxDepth),
node: ruleNode,
result,
ruleName,
});
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -0,0 +1,55 @@
/**
* Stylelint rule: local/no-id-selectors
*
* Prevents ID selectors in CSS modules.
* IDs create high specificity, can't be reused, and defeat CSS modules scoping purpose.
*
* BAD:
* #myComponent { }
* .container #header { }
*
* GOOD:
* .myComponent { }
* .containerHeader { }
*/
import stylelint from 'stylelint';
const ruleName = 'local/no-id-selectors';
const messages = stylelint.utils.ruleMessages(ruleName, {
unexpected: (selector) =>
`ID selector "${selector}" not allowed in CSS modules. Use class selector instead.`,
});
const ID_PATTERN = /#[a-zA-Z_][a-zA-Z0-9_-]*/g;
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkRules((ruleNode) => {
const selector = ruleNode.selector;
const matches = selector.match(ID_PATTERN);
if (matches) {
for (const match of matches) {
stylelint.utils.report({
message: messages.unexpected(match),
node: ruleNode,
result,
ruleName,
});
}
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -0,0 +1,168 @@
/**
* Stylelint rule: local/prefer-css-variables
*
* Warns on hardcoded colors in CSS modules.
* Use CSS variables for consistent theming.
*
* BAD:
* color: #ff0000;
* background: rgb(255, 0, 0);
* border: 1px solid blue;
*
* GOOD:
* color: var(--l1-foreground);
* background: var(--primary-background);
* border: 1px solid var(--l2-border);
*
* ALLOWED:
* transparent, inherit, currentColor, none
* Colors inside var() fallbacks
*/
import stylelint from 'stylelint';
const ruleName = 'local/prefer-css-variables';
const messages = stylelint.utils.ruleMessages(ruleName, {
hardcodedColor: (value, property) =>
`Hardcoded color "${value}" in "${property}". Use a semantic CSS variable instead (e.g., var(--l1-foreground), var(--primary-background)). See docs/css-modules-guide.md.`,
});
const COLOR_PROPERTIES = new Set([
'color',
'background',
'background-color',
'background-image',
'border',
'border-color',
'border-top',
'border-right',
'border-bottom',
'border-left',
'border-top-color',
'border-right-color',
'border-bottom-color',
'border-left-color',
'border-image',
'border-image-source',
'outline',
'outline-color',
'box-shadow',
'text-shadow',
'fill',
'stroke',
'caret-color',
'text-decoration-color',
'column-rule-color',
'mask',
'mask-image',
]);
const ALLOWED_VALUES = new Set([
'transparent',
'inherit',
'initial',
'unset',
'currentcolor',
'none',
'auto',
]);
const HEX_PATTERN = /#[0-9a-fA-F]{3,8}\b/;
const RGB_PATTERN = /rgba?\s*\([^)]+\)/i;
const HSL_PATTERN = /hsla?\s*\([^)]+\)/i;
const NAMED_COLOR_PATTERN =
/\b(red|blue|green|yellow|orange|purple|pink|black|white|gray|grey|cyan|magenta|brown|navy|teal|olive|maroon|lime|aqua|fuchsia|silver)\b/i;
// Strip balanced `fn(...)` sections (e.g. `var(...)`, `url(...)`) from value.
// Handles nested parens by counting depth.
function stripBalancedFn(value, fnName) {
const needle = `${fnName}(`;
let out = '';
let i = 0;
while (i < value.length) {
if (value.startsWith(needle, i)) {
let depth = 1;
i += needle.length;
while (i < value.length && depth > 0) {
const ch = value[i];
if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
}
i++;
}
} else {
out += value[i];
i++;
}
}
return out;
}
function containsHardcodedColor(value) {
// Strip url(...) first — paths/fragments may contain hex-like or color-named substrings
let scanned = value.includes('url(') ? stripBalancedFn(value, 'url') : value;
// Strip var(...) — color tokens inside var fallbacks are allowed
if (scanned.includes('var(')) {
scanned = stripBalancedFn(scanned, 'var');
if (!scanned.trim()) {
return null;
}
}
const lower = scanned.toLowerCase();
if (ALLOWED_VALUES.has(lower)) {
return null;
}
if (HEX_PATTERN.test(scanned)) {
return scanned.match(HEX_PATTERN)[0];
}
if (RGB_PATTERN.test(scanned)) {
return scanned.match(RGB_PATTERN)[0];
}
if (HSL_PATTERN.test(scanned)) {
return scanned.match(HSL_PATTERN)[0];
}
if (NAMED_COLOR_PATTERN.test(scanned)) {
return scanned.match(NAMED_COLOR_PATTERN)[0];
}
return null;
}
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkDecls((decl) => {
const prop = decl.prop.toLowerCase();
if (!COLOR_PROPERTIES.has(prop)) {
return;
}
const hardcodedColor = containsHardcodedColor(decl.value);
if (hardcodedColor) {
stylelint.utils.report({
message: messages.hardcodedColor(hardcodedColor, decl.prop),
node: decl,
result,
ruleName,
});
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -82,6 +82,11 @@ export default defineConfig(({ mode }): UserConfig => {
];
if (env.VITE_SENTRY_AUTH_TOKEN) {
if (!env.VITE_SENTRY_ORG || !env.VITE_SENTRY_PROJECT_ID) {
throw new Error(
'VITE_SENTRY_ORG and VITE_SENTRY_PROJECT_ID must be defined when VITE_SENTRY_AUTH_TOKEN is present.',
);
}
// Refuse to upload sourcemaps without an explicit version.
if (!env.VITE_VERSION) {
throw new Error(

View File

@@ -68,6 +68,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CloneV2), handler.OpenAPIDef{
ID: "CloneDashboardV2",
Tags: []string{"dashboard"},
Summary: "Clone dashboard (v2)",
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.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},

View File

@@ -22,7 +22,7 @@ func newConfig() factory.Config {
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.11",
Version: "v0.0.12",
},
}
}

View File

@@ -0,0 +1 @@
<svg id="ba7e3c83-4ea6-40d5-820f-746ccedb8156" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="e74ddf23-fe74-4f89-9a49-c7f2e22be284" x1="3.707" y1="5.123" x2="3.707" y2="2.061" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0078d4"/><stop offset="1" stop-color="#5ea0ef"/></linearGradient><linearGradient id="bcce3b94-6b83-44c5-9bf7-a89d666f3d1e" x1="12.741" y1="10.558" x2="12.741" y2="5.161" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0078d4"/><stop offset="1" stop-color="#5ea0ef"/></linearGradient><linearGradient id="f9b0803f-41e5-4165-9230-e220427ec475" x1="3.707" y1="13.723" x2="3.707" y2="10.378" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0078d4"/><stop offset="1" stop-color="#5ea0ef"/></linearGradient></defs><g><g><rect x="7.923" y="1.922" width="0.98" height="6.183" transform="translate(0.013 10.048) rotate(-61.725)" fill="#003067"/><path d="M7.081,4.2V1.5H.33v2.7h0v0c0,.716,1.511,1.3,3.376,1.3s3.376-.58,3.376-1.3Z" fill="url(#e74ddf23-fe74-4f89-9a49-c7f2e22be284)"/><ellipse cx="3.707" cy="1.546" rx="3.376" ry="1.296" fill="#83b9f9"/><path d="M16.115,8.616v-2.7H9.365v2.7h0v0c0,.716,1.511,1.3,3.376,1.3s3.376-.58,3.376-1.3Z" fill="url(#bcce3b94-6b83-44c5-9bf7-a89d666f3d1e)"/><ellipse cx="12.741" cy="5.96" rx="3.376" ry="1.296" fill="#83b9f9"/><rect x="3.22" y="4.168" width="0.98" height="7.962" fill="#003067"/><circle cx="3.75" cy="3.337" r="1.157" fill="#c3f1ff"/><path d="M7.081,12.824v-2.7H.33v2.7h0v0c0,.716,1.511,1.3,3.376,1.3s3.376-.58,3.376-1.3Z" fill="url(#f9b0803f-41e5-4165-9230-e220427ec475)"/><ellipse cx="3.707" cy="10.167" rx="3.376" ry="1.296" fill="#83b9f9"/><rect x="3.708" y="9.164" width="9.741" height="0.98" transform="translate(-3.376 4.767) rotate(-26.14)" fill="#003067"/><circle cx="3.701" cy="11.939" r="1.157" fill="#c3f1ff"/><circle cx="12.743" cy="7.548" r="1.157" fill="#c3f1ff"/></g><g><path d="M17.166,15.5h0a.255.255,0,0,0-.1-.185A17,17,0,0,0,15.563,14a3.563,3.563,0,0,0-2.638-.706,5.515,5.515,0,0,0-3.339,2.066.224.224,0,0,0-.058.14h0v0h0a.26.26,0,0,0,.1.186,17.319,17.319,0,0,0,1.507,1.315,3.568,3.568,0,0,0,2.637.706,5.517,5.517,0,0,0,3.339-2.066.229.229,0,0,0,.059-.141h0Z" fill="#003067"/><circle cx="13.381" cy="15.501" r="1.846" fill="#c3f1ff"/><path d="M13.389,14.478a.875.875,0,0,0-.144.029.537.537,0,0,1,.1.292.559.559,0,0,1-.559.558.548.548,0,0,1-.368-.145.985.985,0,0,0-.062.3,1.038,1.038,0,1,0,1.038-1.037Z" fill="#003067"/><path d="M17.529,13.472A6.582,6.582,0,0,0,15.6,12.241l.437-.776a.458.458,0,0,0,.044-.34.441.441,0,0,0-.806-.106l-.535.952a5.762,5.762,0,0,0-.914-.143v-.975a.442.442,0,0,0-.883,0v.974a5.879,5.879,0,0,0-.934.144l-.536-.952a.442.442,0,0,0-.6-.158.45.45,0,0,0-.159.6q.219.387.437.776a6.582,6.582,0,0,0-1.931,1.231c-.422.382.2,1,.624.624a5.183,5.183,0,0,1,7.067,0C17.326,14.476,17.951,13.854,17.529,13.472Z" fill="#003067"/></g></g><rect x="-4.934" y="-3.691" width="29.331" height="34.261" fill="none" stroke="#b31b1b" stroke-miterlimit="10"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,7 @@
### Monitor Azure Managed Instance for Apache Cassandra with SigNoz
Collect key Azure Managed Instance for Apache Cassandra metrics and view them with an out of the box dashboard.
This integration collects platform metrics for the `Microsoft.DocumentDB/cassandraClusters` resource type.
Note: This integration is for Azure Managed Instance for Apache Cassandra. Azure Cosmos DB for Apache Cassandra (the API offering) exposes a different set of metrics and is not covered here.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><radialGradient id="a" cx="-105.006" cy="-10.409" r="5.954" gradientTransform="matrix(1.036 0 0 1.027 117.739 19.644)" gradientUnits="userSpaceOnUse"><stop offset=".183" stop-color="#5ea0ef"/><stop offset="1" stop-color="#0078d4"/></radialGradient><clipPath id="b"><path d="M14.969 7.53a6.137 6.137 0 11-7.395-4.543 6.137 6.137 0 017.395 4.543z" fill="none"/></clipPath></defs><path d="M2.954 5.266a.175.175 0 01-.176-.176A2.012 2.012 0 00.769 3.081a.176.176 0 01-.176-.175.176.176 0 01.176-.176A2.012 2.012 0 002.778.72a.175.175 0 01.176-.176.175.175 0 01.176.176 2.012 2.012 0 002.009 2.009.175.175 0 01.176.176.175.175 0 01-.176.176A2.011 2.011 0 003.13 5.09a.177.177 0 01-.176.176zM15.611 17.456a.141.141 0 01-.141-.141 1.609 1.609 0 00-1.607-1.607.141.141 0 01-.141-.14.141.141 0 01.141-.141 1.608 1.608 0 001.607-1.607.141.141 0 01.141-.141.141.141 0 01.141.141 1.608 1.608 0 001.607 1.607.141.141 0 110 .282 1.609 1.609 0 00-1.607 1.607.141.141 0 01-.141.14z" fill="#50e6ff"/><path d="M14.969 7.53a6.137 6.137 0 11-7.395-4.543 6.137 6.137 0 017.395 4.543z" fill="url(#a)"/><g clip-path="url(#b)" fill="#f2f2f2"><path d="M5.709 13.115a1.638 1.638 0 10.005-3.275 1.307 1.307 0 00.007-.14A1.651 1.651 0 004.06 8.064H2.832a6.251 6.251 0 001.595 5.051zM15.045 7.815c0-.015 0-.03-.007-.044a5.978 5.978 0 00-1.406-2.88 1.825 1.825 0 00-.289-.09 1.806 1.806 0 00-2.3 1.663 2 2 0 00-.2-.013 1.737 1.737 0 00-.581 3.374 1.451 1.451 0 00.541.1h2.03a13.453 13.453 0 002.212-2.11z"/></g><path d="M17.191 3.832c-.629-1.047-2.1-1.455-4.155-1.149a14.606 14.606 0 00-2.082.452 6.456 6.456 0 011.528.767c.241-.053.483-.116.715-.151a7.49 7.49 0 011.103-.089 2.188 2.188 0 011.959.725c.383.638.06 1.729-.886 3a16.723 16.723 0 01-4.749 4.051A16.758 16.758 0 014.8 13.7c-1.564.234-2.682 0-3.065-.636s-.06-1.73.886-2.995c.117-.157.146-.234.279-.392a6.252 6.252 0 01.026-1.63 11.552 11.552 0 00-1.17 1.372C.517 11.076.181 12.566.809 13.613a3.165 3.165 0 002.9 1.249 8.434 8.434 0 001.251-.1 17.855 17.855 0 006.219-2.4A17.808 17.808 0 0016.24 8.03c1.243-1.661 1.579-3.15.951-4.198z" fill="#50e6ff"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,529 @@
{
"id": "cosmosdb",
"title": "Cosmos DB",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_totalrequests_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of requests made against the account."
},
{
"name": "azure_totalrequestunits_total",
"unit": "Count",
"type": "Sum",
"description": "Request Units (RUs) consumed."
},
{
"name": "azure_totalrequestunits_average",
"unit": "Count",
"type": "Gauge",
"description": "Request Units (RUs) consumed."
},
{
"name": "azure_totalrequestunits_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Request Units (RUs) consumed."
},
{
"name": "azure_normalizedruconsumption_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Maximum RU consumption percentage per minute."
},
{
"name": "azure_normalizedruconsumption_average",
"unit": "Percent",
"type": "Gauge",
"description": "Maximum RU consumption percentage per minute."
},
{
"name": "azure_metadatarequests_count",
"unit": "Count",
"type": "Gauge",
"description": "Count of metadata requests (enumerating databases/collections and their configurations)."
},
{
"name": "azure_datausage_total",
"unit": "Bytes",
"type": "Sum",
"description": "Total data usage, reported at 5-minute granularity."
},
{
"name": "azure_datausage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Total data usage, reported at 5-minute granularity."
},
{
"name": "azure_datausage_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Total data usage, reported at 5-minute granularity."
},
{
"name": "azure_datausage_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Total data usage, reported at 5-minute granularity."
},
{
"name": "azure_indexusage_total",
"unit": "Bytes",
"type": "Sum",
"description": "Total index usage, reported at 5-minute granularity."
},
{
"name": "azure_indexusage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Total index usage, reported at 5-minute granularity."
},
{
"name": "azure_indexusage_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Total index usage, reported at 5-minute granularity."
},
{
"name": "azure_indexusage_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Total index usage, reported at 5-minute granularity."
},
{
"name": "azure_documentcountv2_total",
"unit": "Count",
"type": "Sum",
"description": "Total document count."
},
{
"name": "azure_documentcountv2_average",
"unit": "Count",
"type": "Gauge",
"description": "Total document count."
},
{
"name": "azure_documentquota_total",
"unit": "Bytes",
"type": "Sum",
"description": "Total storage quota, reported at 5-minute granularity."
},
{
"name": "azure_documentquota_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Total storage quota, reported at 5-minute granularity."
},
{
"name": "azure_provisionedthroughput_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Provisioned throughput (RU/s)."
},
{
"name": "azure_autoscalemaxthroughput_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Autoscale maximum throughput (RU/s)."
},
{
"name": "azure_autoscaledru_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Autoscaled RU consumption per region and per partition."
},
{
"name": "azure_serversidelatencydirect_average",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "Server-side latency in Direct connection mode."
},
{
"name": "azure_serversidelatencydirect_minimum",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "Server-side latency in Direct connection mode."
},
{
"name": "azure_serversidelatencydirect_maximum",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "Server-side latency in Direct connection mode."
},
{
"name": "azure_serversidelatencydirect_total",
"unit": "MilliSeconds",
"type": "Sum",
"description": "Server-side latency in Direct connection mode."
},
{
"name": "azure_serversidelatencygateway_average",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "Server-side latency in Gateway connection mode."
},
{
"name": "azure_serversidelatencygateway_minimum",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "Server-side latency in Gateway connection mode."
},
{
"name": "azure_serversidelatencygateway_maximum",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "Server-side latency in Gateway connection mode."
},
{
"name": "azure_serversidelatencygateway_total",
"unit": "MilliSeconds",
"type": "Sum",
"description": "Server-side latency in Gateway connection mode."
},
{
"name": "azure_replicationlatency_minimum",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "P99 replication latency across source and target regions for a geo-enabled account."
},
{
"name": "azure_replicationlatency_maximum",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "P99 replication latency across source and target regions for a geo-enabled account."
},
{
"name": "azure_replicationlatency_average",
"unit": "MilliSeconds",
"type": "Gauge",
"description": "P99 replication latency across source and target regions for a geo-enabled account."
},
{
"name": "azure_serviceavailability_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Account requests availability at one-hour, day, or month granularity."
},
{
"name": "azure_serviceavailability_average",
"unit": "Percent",
"type": "Gauge",
"description": "Account requests availability at one-hour, day, or month granularity."
},
{
"name": "azure_serviceavailability_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Account requests availability at one-hour, day, or month granularity."
},
{
"name": "azure_physicalpartitioncount_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Physical partition count."
},
{
"name": "azure_physicalpartitionsizeinfo_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Physical partition size in bytes."
},
{
"name": "azure_physicalpartitionsizeinfo_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Physical partition size in bytes."
},
{
"name": "azure_physicalpartitionthroughputinfo_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Physical partition throughput (RU/s)."
},
{
"name": "azure_cassandrarequests_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of Cassandra API requests made."
},
{
"name": "azure_cassandrarequestcharges_total",
"unit": "Count",
"type": "Sum",
"description": "Request Units consumed for Cassandra API requests."
},
{
"name": "azure_cassandrarequestcharges_average",
"unit": "Count",
"type": "Gauge",
"description": "Request Units consumed for Cassandra API requests."
},
{
"name": "azure_cassandrarequestcharges_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Request Units consumed for Cassandra API requests."
},
{
"name": "azure_cassandrarequestcharges_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Request Units consumed for Cassandra API requests."
},
{
"name": "azure_cassandraconnectionclosures_average",
"unit": "Count",
"type": "Gauge",
"description": "Number of Cassandra connections that were closed, reported at 1-minute granularity."
},
{
"name": "azure_cassandraconnectionclosures_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Number of Cassandra connections that were closed, reported at 1-minute granularity."
},
{
"name": "azure_cassandraconnectionclosures_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Number of Cassandra connections that were closed, reported at 1-minute granularity."
},
{
"name": "azure_cassandraconnectionclosures_total",
"unit": "Count",
"type": "Sum",
"description": "Number of Cassandra connections that were closed, reported at 1-minute granularity."
},
{
"name": "azure_gremlinrequests_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of Gremlin API requests made."
},
{
"name": "azure_gremlinrequestcharges_total",
"unit": "Count",
"type": "Sum",
"description": "Request Units consumed for Gremlin API requests."
},
{
"name": "azure_gremlinrequestcharges_average",
"unit": "Count",
"type": "Gauge",
"description": "Request Units consumed for Gremlin API requests."
},
{
"name": "azure_gremlinrequestcharges_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Request Units consumed for Gremlin API requests."
},
{
"name": "azure_gremlinrequestcharges_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Request Units consumed for Gremlin API requests."
},
{
"name": "azure_mongorequests_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of Mongo API requests made."
},
{
"name": "azure_mongorequestcharge_total",
"unit": "Count",
"type": "Sum",
"description": "Request Units consumed for Mongo API requests."
},
{
"name": "azure_mongorequestcharge_average",
"unit": "Count",
"type": "Gauge",
"description": "Request Units consumed for Mongo API requests."
},
{
"name": "azure_mongorequestcharge_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Request Units consumed for Mongo API requests."
},
{
"name": "azure_dedicatedgatewayaveragecpuusage_average",
"unit": "Percent",
"type": "Gauge",
"description": "Average CPU usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewaymaximumcpuusage_average",
"unit": "Percent",
"type": "Gauge",
"description": "Average maximum CPU usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewaymaximumcpuusage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Average maximum CPU usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewaycpuusage_average",
"unit": "Percent",
"type": "Gauge",
"description": "CPU usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewaycpuusage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "CPU usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewaycpuusage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "CPU usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewayaveragememoryusage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Average memory usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewaymemoryusage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Memory usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewaymemoryusage_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Memory usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewaymemoryusage_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Memory usage across dedicated gateway instances."
},
{
"name": "azure_dedicatedgatewayrequests_count",
"unit": "Count",
"type": "Gauge",
"description": "Requests at the dedicated gateway."
},
{
"name": "azure_integratedcacheevictedentriessize_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Size of the entries evicted from the integrated cache."
},
{
"name": "azure_integratedcacheitemexpirationcount_average",
"unit": "Count",
"type": "Gauge",
"description": "Number of items evicted from the integrated cache due to TTL expiration."
},
{
"name": "azure_integratedcacheitemhitrate_average",
"unit": "Percent",
"type": "Gauge",
"description": "Point reads that used the integrated cache divided by point reads routed through the dedicated gateway with eventual consistency."
},
{
"name": "azure_integratedcachequeryexpirationcount_average",
"unit": "Count",
"type": "Gauge",
"description": "Number of queries evicted from the integrated cache due to TTL expiration."
},
{
"name": "azure_integratedcachequeryhitrate_average",
"unit": "Percent",
"type": "Gauge",
"description": "Queries that used the integrated cache divided by queries routed through the dedicated gateway with eventual consistency."
},
{
"name": "azure_materializedviewcatchupgapinminutes_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Maximum time difference (minutes) between data in the source container and data propagated to the materialized view."
},
{
"name": "azure_materializedviewsbuilderaveragecpuusage_average",
"unit": "Percent",
"type": "Gauge",
"description": "Average CPU usage across materialized view builder instances."
},
{
"name": "azure_materializedviewsbuildermaximumcpuusage_average",
"unit": "Percent",
"type": "Gauge",
"description": "Average maximum CPU usage across materialized view builder instances."
},
{
"name": "azure_materializedviewsbuildermaximumcpuusage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Average maximum CPU usage across materialized view builder instances."
},
{
"name": "azure_materializedviewsbuilderaveragememoryusage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Average memory usage across materialized view builder instances."
},
{
"name": "azure_globalsecondaryindexcatchupgapinminutes_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Maximum time difference (minutes) between data in the source container and data propagated to the global secondary index."
},
{
"name": "azure_globalsecondaryindexpropagationlatencyinseconds_average",
"unit": "Count",
"type": "Gauge",
"description": "Average time difference (seconds) between data in the source container and data propagated to the global secondary index."
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.DocumentDB",
"resourceType": "databaseAccounts",
"metrics": {},
"logs": {
"categoryGroups": [
"allLogs"
]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Azure Cosmos DB Overview",
"description": "Overview of Azure Cosmos DB metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,7 @@
### Monitor Azure Cosmos DB with SigNoz
Collect key Azure Cosmos DB metrics and view them with an out of the box dashboard.
This integration collects platform metrics for the `Microsoft.DocumentDB/databaseAccounts` resource type — the Request Unit (RU) based Azure Cosmos DB account. A single account resource type is used across all Cosmos DB APIs (NoSQL, Cassandra, Gremlin, Table, and MongoDB for RU), so per-API request metrics are only reported for the API your account uses.
Note: This integration is for the RU-based Cosmos DB account (`Microsoft.DocumentDB/databaseAccounts`).

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 61 81" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round"><use xlink:href="#A" x=".5" y=".5"/><symbol id="A" overflow="visible"><g stroke="none"><path d="M0 10.88v58.08c0 6.08 13.333 10.88 30 10.88V10.88H0z" fill="#3999c6"/><path d="M29.524 80H30c16.508 0 30-4.96 30-10.88V10.88H29.524V80z" fill="#59b4d9"/><path d="M60 10.88c0 6.08-13.333 10.88-30 10.88S0 16.96 0 10.88C0 4.96 13.492 0 30 0s30 4.96 30 10.88"/><path d="M53.81 10.24c0 4-10.635 7.2-23.81 7.2s-23.809-3.2-23.809-7.2 10.635-7.2 23.81-7.2 23.81 3.2 23.81 7.2" fill="#7fba00"/><path d="M48.889 14.72c3.175-1.28 4.921-2.72 4.921-4.48 0-4-10.635-7.2-23.81-7.2s-23.809 3.2-23.809 7.2c0 1.6 1.905 3.2 4.921 4.48 4.286-1.76 11.111-2.88 18.889-2.88 7.619 0 14.444 1.12 18.889 2.88" fill="#b8d432"/><path d="M21.429 66.88c-2.381 0-4.286-.32-5.555-.8-1.429-.48-2.381-1.28-3.016-2.24-.635-1.12-.952-2.72-.952-4.8v-3.52c0-2.24-.952-3.52-2.857-3.52v-4.32c1.905 0 2.857-1.12 2.857-3.52v-3.2c0-2.24.317-4 .952-4.96.635-1.12 1.587-1.76 2.857-2.4 1.429-.48 3.175-.8 5.556-.8v4.32c-1.111 0-1.746.16-2.381.64-.476.48-.794 1.28-.794 2.4v2.72c0 1.76-.159 3.2-.635 4.32s-1.27 1.92-2.381 2.4h0c1.111.48 1.905 1.28 2.381 2.56.476 1.12.794 2.72.794 4.48v2.4c0 1.12.159 1.92.635 2.4s1.27.8 2.381.8v4.64zM48.413 52c-1.905 0-2.857 1.12-2.857 3.52v3.2c0 2.24-.318 4-.952 4.96-.635 1.12-1.587 1.76-2.857 2.4-1.428.48-3.175.8-5.556.8v-4.32c1.111 0 1.905-.16 2.381-.64s.794-1.28.794-2.4V56.8c0-1.76.159-3.2.635-4.32a4.39 4.39 0 0 1 2.381-2.4h0c-1.111-.48-1.905-1.44-2.381-2.56s-.794-2.72-.794-4.48v-2.4c0-1.12-.159-1.92-.635-2.4s-1.27-.8-2.381-.8v-4.32c2.381 0 4.286.32 5.556.8 1.428.48 2.381 1.28 3.016 2.24.635 1.12.953 2.72.953 4.8v3.52c0 2.24.952 3.52 2.857 3.52v4z"/></g></symbol></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,247 @@
{
"id": "mongodb",
"title": "MongoDB (DocumentDB)",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_cpupercent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Percent CPU utilization on the node."
},
{
"name": "azure_cpupercent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Percent CPU utilization on the node."
},
{
"name": "azure_cpupercent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Percent CPU utilization on the node."
},
{
"name": "azure_memorypercent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Percent memory utilization on the node."
},
{
"name": "azure_memorypercent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Percent memory utilization on the node."
},
{
"name": "azure_memorypercent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Percent memory utilization on the node."
},
{
"name": "azure_committedmemorypercent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of the commit memory limit allocated by applications on the node."
},
{
"name": "azure_committedmemorypercent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of the commit memory limit allocated by applications on the node."
},
{
"name": "azure_committedmemorypercent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of the commit memory limit allocated by applications on the node."
},
{
"name": "azure_storagepercent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Percent of available storage used on the node."
},
{
"name": "azure_storagepercent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Percent of available storage used on the node."
},
{
"name": "azure_storagepercent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Percent of available storage used on the node."
},
{
"name": "azure_storageused_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Quantity of available storage used on the node."
},
{
"name": "azure_storageused_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Quantity of available storage used on the node."
},
{
"name": "azure_storageused_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Quantity of available storage used on the node."
},
{
"name": "azure_autoscaleutilizationpercent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Percent autoscale utilization."
},
{
"name": "azure_autoscaleutilizationpercent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Percent autoscale utilization."
},
{
"name": "azure_autoscaleutilizationpercent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Percent autoscale utilization."
},
{
"name": "azure_iops_average",
"unit": "Count",
"type": "Gauge",
"description": "Disk IO operations per second on the node."
},
{
"name": "azure_iops_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Disk IO operations per second on the node."
},
{
"name": "azure_iops_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Disk IO operations per second on the node."
},
{
"name": "azure_networkbytesegress_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Network bytes sent on the node."
},
{
"name": "azure_networkbytesegress_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Network bytes sent on the node."
},
{
"name": "azure_networkbytesegress_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Network bytes sent on the node."
},
{
"name": "azure_networkbytesegress_total",
"unit": "Bytes",
"type": "Sum",
"description": "Network bytes sent on the node."
},
{
"name": "azure_networkbytesingress_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Network bytes received on the node."
},
{
"name": "azure_networkbytesingress_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Network bytes received on the node."
},
{
"name": "azure_networkbytesingress_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Network bytes received on the node."
},
{
"name": "azure_networkbytesingress_total",
"unit": "Bytes",
"type": "Sum",
"description": "Network bytes received on the node."
},
{
"name": "azure_mongorequestdurationms_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": "End-to-end duration in milliseconds of client MongoDB requests handled by the cluster."
},
{
"name": "azure_mongorequestdurationms_maximum",
"unit": "Milliseconds",
"type": "Gauge",
"description": "End-to-end duration in milliseconds of client MongoDB requests handled by the cluster."
},
{
"name": "azure_mongorequestdurationms_minimum",
"unit": "Milliseconds",
"type": "Gauge",
"description": "End-to-end duration in milliseconds of client MongoDB requests handled by the cluster."
},
{
"name": "azure_mongorequestdurationms_total",
"unit": "Milliseconds",
"type": "Sum",
"description": "End-to-end duration in milliseconds of client MongoDB requests handled by the cluster."
},
{
"name": "azure_mongorequestdurationms_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of client MongoDB requests handled by the cluster."
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.DocumentDB",
"resourceType": "mongoClusters",
"metrics": {},
"logs": {
"categoryGroups": [
"allLogs"
]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Azure MongoDB (DocumentDB) Overview",
"description": "Overview of Azure MongoDB (DocumentDB) metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,5 @@
### Monitor Azure MongoDB with SigNoz
Collect key Azure MongoDB (DocumentDB) cluster metrics and view them with an out of the box dashboard.
This integration collects platform metrics for the `Microsoft.DocumentDB/mongoClusters` resource type — the vCore-based Azure DocumentDB offering with MongoDB compatibility.

View File

@@ -0,0 +1 @@
<svg id="e05c9575-4c38-4bcd-90eb-276caf26e3d0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="e291aba6-8038-4db1-a04d-c7be74f5a3e6" x1="2.59" y1="10.16" x2="15.41" y2="10.16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#005ba1"/><stop offset="0.07" stop-color="#0060a9"/><stop offset="0.36" stop-color="#0071c8"/><stop offset="0.52" stop-color="#0078d4"/><stop offset="0.64" stop-color="#0074cd"/><stop offset="0.82" stop-color="#006abb"/><stop offset="1" stop-color="#005ba1"/></linearGradient></defs><title>Icon-databases-122</title><path d="M9,5.14c-3.54,0-6.41-1-6.41-2.32V15.18c0,1.27,2.82,2.3,6.32,2.32H9c3.54,0,6.41-1,6.41-2.32V2.82C15.41,4.11,12.54,5.14,9,5.14Z" fill="url(#e291aba6-8038-4db1-a04d-c7be74f5a3e6)"/><path d="M15.41,2.82c0,1.29-2.87,2.32-6.41,2.32s-6.41-1-6.41-2.32S5.46.5,9,.5s6.41,1,6.41,2.32" fill="#e8e8e8"/><path d="M13.92,2.63c0,.82-2.21,1.48-4.92,1.48S4.08,3.45,4.08,2.63,6.29,1.16,9,1.16s4.92.66,4.92,1.47" fill="#50e6ff"/><path d="M9,3a11.55,11.55,0,0,0-3.89.57A11.42,11.42,0,0,0,9,4.11a11.15,11.15,0,0,0,3.89-.58A11.84,11.84,0,0,0,9,3Z" fill="#198ab3"/><path d="M12.64,9v1.63h-1a.39.39,0,0,1-.29-.14V9H10v1.78a.92.92,0,0,0,1,.89h1.49l.26-.13s-.11.41-.26.43H10.11v1h2.66A1.21,1.21,0,0,0,14,11.7V9Z" fill="#f2f2f2"/><path d="M9.53,9s0,0,0,0V8.51a.7.7,0,0,0-.48-.77,1.74,1.74,0,0,0-.5-.08.94.94,0,0,0-.91.58l-.78,1.9-1-1.9A.93.93,0,0,0,5,7.66a1.44,1.44,0,0,0-.51.09c-.35.11-.43.34-.43.73v3.31H5.23V9.56l.63,1.57a1.08,1.08,0,0,0,1,.66c.44,0,.62-.26.8-.66l.67-1.51v2.15H9.51V9h0Z" fill="#f2f2f2"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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