Compare commits

...

18 Commits

Author SHA1 Message Date
Vinícius Lourenço
0d6c27ec5f test(compositeQuery): add qsAlias tests 2026-06-16 21:27:44 -03:00
Vinícius Lourenço
9d919e166b feat(compositeQuery): add qsAlias adapter 2026-06-16 21:27:33 -03:00
Vinícius Lourenço
8c86885090 feat(compositeQuery): add baseline objects 2026-06-16 19:56:25 -03:00
Vinícius Lourenço
e7be5ee17d chore(query-builder-operators): add noop operator
This is used to help reduce the amount of diff on future baseline objects
2026-06-16 18:29:44 -03:00
Vinícius Lourenço
49c11f51ac test(useSafeNavigate): move helpers to add testing 2026-06-16 18:28:55 -03:00
Vinícius Lourenço
0c35a8f6e5 feat(compositeQuery): drop query param in favor of serializer functions 2026-06-16 18:26:39 -03:00
Vinícius Lourenço
2c076a3d50 feat(compositeQuery): add base structure for serializer with json as default 2026-06-16 18:25:03 -03:00
Vinícius Lourenço
086040799c feat(compositeQuery): add contract for serialize/deserialize query 2026-06-16 17:11:03 -03: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
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
177 changed files with 39465 additions and 1269 deletions

View File

@@ -1357,6 +1357,14 @@ components:
- appservice
- containerapp
- aks
- sqldatabase
- sqldatabasemi
- mysqlflexibleserver
- postgresqlflexibleserver
- mongodb
- cosmosdb
- cassandradb
- redis
type: string
CloudintegrationtypesServiceMetadata:
properties:

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",
@@ -94,6 +94,7 @@
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
"posthog-js": "1.298.0",
"qs": "6.15.2",
"rc-select": "14.10.0",
"react": "18.2.0",
"react-addons-update": "15.6.3",
@@ -168,6 +169,7 @@
"@types/lodash-es": "^4.17.4",
"@types/node": "^16.10.3",
"@types/papaparse": "5.3.7",
"@types/qs": "6.15.1",
"@types/react": "18.0.26",
"@types/react-addons-update": "0.14.21",
"@types/react-beautiful-dnd": "13.1.8",
@@ -192,9 +194,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,
},
};

715
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'
),
);

View File

@@ -47,7 +47,7 @@ import {
SomethingWentWrong,
StatusPage,
SupportPage,
TraceDetail,
TraceDetailOldRedirect,
TraceDetailV3,
TraceFilter,
TracesExplorer,
@@ -139,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',
},

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

@@ -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 = {
/**

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

@@ -6,6 +6,10 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
@@ -124,15 +128,13 @@ export function useNavigateToExplorer(): (
});
}
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
applySerializedParams(serialize(preparedQuery), urlParams);
const basePath =
dataSource === DataSource.TRACES
? ROUTES.TRACES_EXPLORER
: ROUTES.LOGS_EXPLORER;
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},

View File

@@ -32,6 +32,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
@@ -252,7 +253,7 @@ function LogDetailInner({
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
...serializeToParams(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,

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

@@ -18,7 +18,6 @@ export enum QueryParams {
q = 'q',
activeLogId = 'activeLogId',
timeRange = 'timeRange',
compositeQuery = 'compositeQuery',
panelTypes = 'panelTypes',
pageSize = 'pageSize',
viewMode = 'viewMode',

View File

@@ -6,6 +6,10 @@ import {
import { SelectOption } from 'types/common/select';
export const metricAggregateOperatorOptions: SelectOption<string, string>[] = [
{
value: MetricAggregateOperator.NOOP,
label: 'No aggregation',
},
{
value: MetricAggregateOperator.COUNT,
label: 'Count',

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

@@ -0,0 +1,172 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
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([]);
});
it('decodes the serialized composite query into metadata.query', () => {
const query = { builder: { queryData: [] } } as unknown as Query;
const search = `?${serialize(query).toString()}`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata?.query).toStrictEqual(query);
});
it('omits metadata.query when no serialized query is in the URL', () => {
// Detection no longer gates on the `compositeQuery` key — it routes
// through `deserialize`/the adapter list — so non-query params (time
// range, etc.) must not be mistaken for a query.
const search = `?${QueryParams.startTime}=1700000000000&${QueryParams.endTime}=1700003600000`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata).not.toHaveProperty('query');
});
});

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

@@ -24,7 +24,7 @@ import {
undoExecution,
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { serialize } from 'lib/compositeQuery/serializer';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -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:
*
@@ -399,8 +363,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → history.push', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const params = serialize(normalized);
deps.history.push(`${base}?${params.toString()}`);
}
/** Picks the right rollback API call for a given action kind. */
@@ -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,286 @@
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 { deserialize } from 'lib/compositeQuery/serializer';
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);
const params = new URLSearchParams(new URL(url, 'http://x').search);
expect(deserialize(params)).not.toBeNull();
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,118 @@
import { getAllViews } from 'api/saveView/getAllViews';
import { getViewById } from 'api/saveView/getViewById';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
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();
applySerializedParams(serialize(query), params);
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,8 @@
import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import { AlertListTabs } from 'pages/AlertList/types';
import { matchPath } from 'react-router-dom';
/**
@@ -99,6 +101,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 +134,7 @@ export function getAutoContexts(
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit' },
metadata: { page: 'alert_edit', ruleId },
},
];
}
@@ -125,6 +151,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 +166,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 +283,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 +300,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
@@ -291,15 +340,9 @@ function collectSharedMetadata(
out.timeRange = { start: startTime, end: endTime };
}
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
if (compositeQueryRaw) {
try {
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
} catch {
// Malformed JSON in the URL — drop silently rather than throw
// inside a context-collection helper.
}
const decodedQuery = deserialize(params);
if (decodedQuery) {
out.query = decodedQuery;
}
// Saved view selectors (logs / traces explorer) and dashboard variables.

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

@@ -2,8 +2,8 @@ import { memo } from 'react';
import { Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
@@ -28,9 +28,7 @@ function PanelTypeSelectionModal(): JSX.Element {
const queryParams = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
};
history.push(

View File

@@ -62,6 +62,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import useErrorNotification from 'hooks/useErrorNotification';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { cloneDeep, isEqual, omit } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
@@ -174,7 +176,7 @@ function ExplorerOptions({
const handleConditionalQueryModification = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(defaultQuery: Query | null): string => {
(defaultQuery: Query | null): Record<string, string> => {
const queryToUse = defaultQuery || query;
if (!queryToUse) {
throw new Error('No query provided');
@@ -184,7 +186,7 @@ function ExplorerOptions({
StringOperators.NOOP &&
sourcepage !== DataSource.LOGS
) {
return JSON.stringify(queryToUse);
return serializeToParams(queryToUse);
}
// Convert NOOP to COUNT for alerts and strip orderBy for logs
@@ -208,14 +210,7 @@ function ExplorerOptions({
);
}
try {
return JSON.stringify(modifiedQuery);
} catch (err) {
throw new Error(
'Failed to stringify modified query: ' +
(err instanceof Error ? err.message : String(err)),
);
}
return serializeToParams(modifiedQuery);
},
[panelType, query, sourcepage],
);
@@ -238,13 +233,9 @@ function ExplorerOptions({
});
}
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
const serializedParams = handleConditionalQueryModification(defaultQuery);
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
history.push(`${ROUTES.ALERTS_NEW}?${createQueryParams(serializedParams)}`);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleConditionalQueryModification, history],

View File

@@ -34,6 +34,7 @@ import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty, isEqual } from 'lodash-es';
@@ -384,7 +385,7 @@ function FormAlertRules({
const onCancelHandler = useCallback(
(e?: React.MouseEvent) => {
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
@@ -610,7 +611,7 @@ function FormAlertRules({
`${ruleId}`,
]);
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);

View File

@@ -23,6 +23,10 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
clearSerializedParams,
serializeToParams,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import {
@@ -212,9 +216,7 @@ function WidgetGraphComponent({
[QueryParams.graphType]: clonedWidget?.panelTypes,
[QueryParams.widgetId]: uuid,
...(clonedWidget?.query && {
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(clonedWidget.query),
),
...serializeToParams(clonedWidget.query),
}),
};
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
@@ -255,7 +257,7 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
clearSerializedParams(existingSearchParams);
existingSearchParams.delete(QueryParams.graphType);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {

View File

@@ -29,6 +29,10 @@ import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { unparse } from 'papaparse';
@@ -86,10 +90,7 @@ function WidgetHeader({
const widgetId = widget.id;
urlQuery.set(QueryParams.widgetId, widgetId);
urlQuery.set(QueryParams.graphType, widget.panelTypes);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(widget.query)),
);
applySerializedParams(serialize(widget.query), urlQuery);
const generatedUrl = buildAbsolutePath({
relativePath: 'new',
urlQueryString: urlQuery.toString(),

View File

@@ -7,6 +7,10 @@ import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
@@ -134,10 +138,7 @@ export default function AlertRules({
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -28,6 +28,10 @@ import {
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
@@ -410,7 +414,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
@@ -435,7 +439,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}

View File

@@ -53,6 +53,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { serialize } from 'lib/compositeQuery/serializer';
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
import {
ArrowUpRight,
@@ -77,6 +78,7 @@ import {
UpdateLimitProps,
} from 'types/api/ingestionKeys/limits/types';
import { PaginationProps } from 'types/api/ingestionKeys/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
@@ -896,8 +898,6 @@ function MultiIngestionSettings(): JSX.Element {
},
};
const stringifiedQuery = JSON.stringify(query);
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
thresholds[0].thresholdValue = thresholdValue;
thresholds[0].unit = thresholdUnit;
@@ -907,17 +907,12 @@ function MultiIngestionSettings(): JSX.Element {
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
QueryParams.thresholds
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
const params = serialize(query as Query);
params.set(QueryParams.thresholds, JSON.stringify(thresholds));
params.set(QueryParams.ruleName, ruleName);
params.set(QueryParams.yAxisUnit, yAxisUnit);
history.push(URL);
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [

View File

@@ -1,5 +1,6 @@
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
@@ -132,17 +133,19 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(1000);
expect(thresholds[0].unit).toBe('{count}');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('{count}');
expect(compositeQuery.builder.queryData).toBeDefined();
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('{count}');
expect(compositeQuery?.builder.queryData).toBeDefined();
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
"signoz.workspace.key.id='k1'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe(
'signoz.meter.metric.datapoint.count',
);
@@ -213,18 +216,18 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(400);
expect(thresholds[0].unit).toBe('GiBy');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('bytes');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('bytes');
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
"signoz.workspace.key.id='k2'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');

View File

@@ -6,6 +6,10 @@ import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
@@ -31,10 +35,7 @@ export function useAlertRulesHandlers(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -14,6 +14,10 @@ import { FontSize } from 'container/OptionsMenu/types';
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -111,10 +115,7 @@ function ContextLogRenderer({
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), urlQuery);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');

View File

@@ -247,16 +247,12 @@ function Application(): JSX.Element {
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -8,6 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
@@ -60,16 +64,18 @@ export function generateExplorerPath(
urlParams: URLSearchParams,
servicename: string | undefined,
selectedTraceTags: string,
JSONCompositeQuery: string,
apmToTraceQuery: Query,
queryString: string[],
): string {
const basePath = isViewLogsClicked
? ROUTES.LOGS_EXPLORER
: ROUTES.TRACES_EXPLORER;
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}&${queryString.join('&')}`;
applySerializedParams(serialize(apmToTraceQuery), urlParams);
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
'&',
)}`;
}
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
@@ -105,16 +111,12 @@ export function onViewTracePopupClick({
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -1,5 +1,6 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
@@ -29,13 +30,11 @@ export const navigateToTrace = ({
);
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
const newTraceExplorerPath = `${
ROUTES.TRACES_EXPLORER
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
apmToTraceQuery,
).toString()}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');

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';
@@ -32,6 +33,7 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
@@ -790,9 +792,7 @@ function NewWidget({
const queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType,
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
...serializeToParams(currentQuery),
[QueryParams.variables]: variables,
};
@@ -820,6 +820,11 @@ function NewWidget({
</Flex>
</div>
<div className="right-header">
<HeaderRightSection
enableAnnouncements={false}
enableShare={false}
enableFeedback={false}
/>
{showSwitchToViewModeButton && (
<Button
color="primary"

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -49,7 +50,7 @@ const useBaseDrilldownNavigate = ({
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
@@ -94,7 +95,7 @@ export function buildDrilldownUrl(
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),

View File

@@ -19,6 +19,7 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Compass } from '@signozhq/icons';
import { ILog } from 'types/api/logs/log';
@@ -139,7 +140,7 @@ function SpanLogs({
[QueryParams.activeLogId]: `"${log.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
...serializeToParams(updatedQuery),
};
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;

View File

@@ -15,6 +15,10 @@ import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { BarChart, Compass, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
@@ -155,7 +159,7 @@ function SpanRelatedSignals({
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), searchParams);
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());

View File

@@ -3,6 +3,7 @@ import getUserPreference from 'api/v1/user/preferences/name/get';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { deserialize } from 'lib/compositeQuery/serializer';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
@@ -545,14 +546,13 @@ describe('SpanDetailsDrawer', () => {
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000'); // traceEndTime + 5 minutes
// Verify composite query includes both trace_id and span_id filters
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
const { filter } = compositeQuery.builder.queryData[0];
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
const filter = compositeQuery?.builder.queryData[0]?.filter;
// Check that the filter expression contains trace_id
// Note: Current behavior uses only trace_id filter for navigation
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
expect(filter?.expression).toContain("trace_id = 'test-trace-id'");
// Verify mockSafeNavigate was NOT called
expect(mockSafeNavigate).not.toHaveBeenCalled();
@@ -595,16 +595,16 @@ describe('SpanDetailsDrawer', () => {
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
// Verify composite query includes only trace_id filter (no span_id for context logs)
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
const { filter } = compositeQuery.builder.queryData[0];
// Verify composite query filters by trace_id and the context log's own span_id
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
const filter = compositeQuery?.builder.queryData[0]?.filter;
// Check that the filter expression contains trace_id but not span_id for context logs
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
// Context logs should not have span_id filter
expect(filter.expression).not.toContain('span_id');
// Check that the filter expression contains trace_id
expect(filter?.expression).toContain("trace_id = 'test-trace-id'");
// Context logs use their own span id, not the currently selected span id
expect(filter?.expression).toContain("span_id = 'different-span-id'");
expect(filter?.expression).not.toContain('test-span-id');
// Verify mockSafeNavigate was NOT called
expect(mockSafeNavigate).not.toHaveBeenCalled();

View File

@@ -35,6 +35,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import {
applySerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
@@ -278,7 +283,7 @@ function DateTimeSelection({
return `Refreshed ${secondsDiff} sec ago`;
}, [maxTime, minTime, selectedTime]);
const getUpdatedCompositeQuery = useCallback((): string => {
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
let updatedCompositeQuery = cloneDeep(currentQuery);
updatedCompositeQuery.id = uuid();
// Remove the filters
@@ -299,7 +304,7 @@ function DateTimeSelection({
})),
},
};
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
return serialize(updatedCompositeQuery);
}, [currentQuery]);
const onSelectHandler = useCallback(
@@ -334,9 +339,9 @@ function DateTimeSelection({
// Remove Hidden Filters from URL query parameters on time change
urlQuery.delete(QueryParams.activeLogId);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@@ -424,9 +429,9 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;

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,170 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const BASE = 'http://localhost';
const urlFrom = (pathname: string, params?: URLSearchParams): URL => {
const search = params?.toString();
const query = search ? `?${search}` : '';
return new URL(`${pathname}${query}`, BASE);
};
/** Build params containing the serialized `compositeQuery` plus any extras. */
const withQuery = (
query: Query,
extra: Record<string, string> = {},
): URLSearchParams => {
const params = serialize(query);
Object.entries(extra).forEach(([key, value]) => params.set(key, value));
return params;
};
describe('areUrlsEffectivelySame', () => {
it('returns false when pathnames differ', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/traces'))).toBe(
false,
);
});
it('returns true for two identical param-less URLs', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/logs'))).toBe(true);
});
it('returns true when only the compositeQuery is present and identical', () => {
const params = withQuery(initialQueriesMap.logs);
expect(
areUrlsEffectivelySame(
urlFrom('/logs', params),
urlFrom('/logs', new URLSearchParams(params.toString())),
),
).toBe(true);
});
// Regression: a matching compositeQuery must NOT mask differences in other
// params. Previously every param was compared via the decoded query, so any
// two URLs sharing a compositeQuery were judged identical.
it('returns false when compositeQuery matches but another param differs', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '2000' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('returns false when compositeQuery matches but a param exists on only one URL', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('ignores the volatile id when comparing compositeQuery', () => {
const url1 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-1' }),
);
const url2 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-2' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(true);
});
it('returns false when compositeQuery is semantically different', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/metrics', withQuery(initialQueriesMap.metrics));
// Force same pathname so only the query differs.
expect(
areUrlsEffectivelySame(
url1,
urlFrom('/logs', new URLSearchParams(url2.search)),
),
).toBe(false);
});
it('returns false when compositeQuery exists on only one URL', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/logs');
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('compares non-compositeQuery params directly when no compositeQuery is present', () => {
const same1 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
const same2 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
expect(areUrlsEffectivelySame(same1, same2)).toBe(true);
const diff = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '3' }),
);
expect(areUrlsEffectivelySame(same1, diff)).toBe(false);
});
it('falls back to raw comparison when compositeQuery cannot be decoded', () => {
const corrupt1 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
const corrupt2 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt2)).toBe(true);
const corrupt3 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bother' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt3)).toBe(false);
});
});
describe('isDefaultNavigation', () => {
it('returns false for different pathnames', () => {
expect(isDefaultNavigation(urlFrom('/logs'), urlFrom('/traces'))).toBe(false);
});
it('returns true when a clean URL gains params', () => {
expect(
isDefaultNavigation(
urlFrom('/logs'),
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
),
).toBe(true);
});
it('returns true when the target introduces a new param key', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '1', endTime: '2' })),
),
).toBe(true);
});
it('returns false when the target has no new param keys', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '9' })),
),
).toBe(false);
});
});

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

@@ -5,7 +5,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
@@ -13,6 +12,7 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize } from 'lib/compositeQuery/serializer';
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { AppState } from 'store/reducers';
@@ -58,9 +58,14 @@ export const useActiveLog = (): UseActiveLog => {
const [activeLog, setActiveLog] = useState<ILog | null>(null);
// Close drawer/clear active log when query in URL changes
// Close drawer/clear active log when query in URL changes. Track the decoded
// query (not a single raw param) so it stays correct across serializer tiers
// that explode the query into many keys.
const urlQuery = useUrlQuery();
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
const compositeQuery = useMemo(() => {
const decoded = deserialize(urlQuery);
return decoded ? JSON.stringify(decoded) : '';
}, [urlQuery]);
const prevQueryRef = useRef<string | null>(null);
useEffect(() => {
if (

View File

@@ -2,9 +2,10 @@ import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import useCreateAlerts from '../useCreateAlerts';
@@ -79,14 +80,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
},
}) as unknown as Widgets;
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const getCompositeQueryFromLastOpen = (): Query => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
const composite = deserialize(query);
if (!composite) {
throw new Error('compositeQuery not found in URL');
}
return JSON.parse(decodeURIComponent(raw));
return composite;
};
describe('useCreateAlerts', () => {

View File

@@ -0,0 +1,26 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
let mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => mockUrlQuery,
}));
describe('useGetCompositeQueryParam', () => {
it('decodes a legacy compositeQuery param', () => {
mockUrlQuery = new URLSearchParams({
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
});
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null when the param is absent', () => {
mockUrlQuery = new URLSearchParams();
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current).toBeNull();
});
});

View File

@@ -14,6 +14,10 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
@@ -86,10 +90,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
applySerializedParams(serialize(updatedQuery), params);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,72 +1,10 @@
import { useMemo } from 'react';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { deserialize } from 'lib/compositeQuery/serializer';
import { useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
try {
if (!compositeQuery) {
return null;
}
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
parsedCompositeQuery = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
parsedCompositeQuery.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;
});
}
} catch (e) {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
return useMemo(() => deserialize(urlQuery), [urlQuery]);
};

View File

@@ -1,6 +1,9 @@
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
@@ -18,77 +21,6 @@ interface UseSafeNavigateProps {
preventSameUrlNavigation?: boolean;
}
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
const allParams = new Set([...params1.keys(), ...params2.keys()]);
return [...allParams].every((param) => {
if (param === 'compositeQuery') {
try {
const query1 = params1.get('compositeQuery');
const query2 = params2.get('compositeQuery');
if (!query1 || !query2) {
return false;
}
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
delete filtered1.id;
delete filtered2.id;
return isEqual(filtered1, filtered2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;
}
}
return params1.get(param) === params2.get(param);
});
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,

View File

@@ -0,0 +1,103 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { isEqual } from 'lodash-es';
/**
* Compare the (optional) `compositeQuery` param of two URLSearchParams
* semantically. Its serialized form is not byte-stable — the volatile `id` and
* the adapter choice both vary — so we decode and deep-compare, ignoring `id`.
*
* compositeQuery is not guaranteed to be present: absent on both sides counts
* as equal, present on only one side counts as different. When either side is
* present but can't be decoded, we fall back to comparing the raw values.
*/
const compositeQueriesEqual = (
params1: URLSearchParams,
params2: URLSearchParams,
): boolean => {
const raw1 = params1.get(COMPOSITE_QUERY_KEY);
const raw2 = params2.get(COMPOSITE_QUERY_KEY);
if (!raw1 && !raw2) {
return true;
}
if (!raw1 || !raw2) {
return false;
}
try {
const decoded1 = deserialize(params1);
const decoded2 = deserialize(params2);
if (decoded1 && decoded2) {
// Ignore the volatile `id` when comparing queries.
const { id: _id1, ...rest1 } = decoded1;
const { id: _id2, ...rest2 } = decoded2;
return isEqual(rest1, rest2);
}
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
}
// One or both could not be decoded — compare the raw encoded values.
return raw1 === raw2;
};
export const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
// The compositeQuery is compared semantically (it round-trips through a
// non-stable serialized form); every other param is compared by raw value.
if (!compositeQueriesEqual(params1, params2)) {
return false;
}
const otherKeys = new Set(
[...params1.keys(), ...params2.keys()].filter(
(key) => key !== COMPOSITE_QUERY_KEY,
),
);
return [...otherKeys].every((key) => params1.get(key) === params2.get(key));
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
export const isDefaultNavigation = (
currentUrl: URL,
targetUrl: URL,
): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};

View File

@@ -0,0 +1,269 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`baseline immutability snapshots LOGS_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots LOGS_BASELINE_V1_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots METRICS_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots TRACES_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;

View File

@@ -0,0 +1,121 @@
/**
* ╔════════════════════════════════════════════════════════════════════════════╗
* ║ ⚠️ CRITICAL WARNING ⚠️ ║
* ╠════════════════════════════════════════════════════════════════════════════╣
* ║ These baselines are FROZEN FOREVER. They must NEVER be modified. ║
* ║ ║
* ║ WHY: Every URL ever emitted by the compositeQuery serializer encodes a ║
* ║ diff against these exact baselines. Changing a single byte here silently ║
* ║ BREAKS ALL EXISTING URLs — dashboards, saved views, shared links, etc. ║
* ║ ║
* ║ If these snapshot tests fail: ║
* ║ 1. DO NOT update the snapshots ║
* ║ 2. REVERT your changes to baseline.ts immediately ║
* ║ 3. If you need a new schema, create a NEW versioned baseline: ║
* ║ - METRICS_BASELINE_V2, LOGS_BASELINE_V2, TRACES_BASELINE_V2 ║
* ║ - Create a new adapter (e.g., V2~) that uses the new baselines ║
* ║ - Keep the old baselines untouched for backwards compatibility ║
* ╚════════════════════════════════════════════════════════════════════════════╝
*/
import getBaselineByTag, { pickBaseline } from '../baseline';
import { METRICS_BASELINE_V1 } from 'lib/compositeQuery/baseline.metrics';
import { LOGS_BASELINE_V1 } from 'lib/compositeQuery/baseline.logs';
import { TRACES_BASELINE_V1 } from 'lib/compositeQuery/baseline.traces';
describe('baseline immutability snapshots', () => {
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('METRICS_BASELINE_V1 must never change', () => {
expect(METRICS_BASELINE_V1).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('LOGS_BASELINE_V1 must never change', () => {
expect(LOGS_BASELINE_V1).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('TRACES_BASELINE_V1 must never change', () => {
expect(TRACES_BASELINE_V1).toMatchSnapshot();
});
});
describe('pickBaseline', () => {
it('returns metrics baseline for metrics dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'metrics' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
it('returns logs baseline for logs dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'logs' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(LOGS_BASELINE_V1);
expect(result.tag).toBe('l');
});
it('returns traces baseline for traces dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'traces' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(TRACES_BASELINE_V1);
expect(result.tag).toBe('t');
});
it('defaults to metrics baseline for unknown dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'unknown' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
it('defaults to metrics baseline when queryData is empty', () => {
const query = {
builder: { queryData: [] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
});
describe('getBaselineByTag', () => {
it('returns LOGS_BASELINE_V1 for tag "l"', () => {
expect(getBaselineByTag('l')).toBe(LOGS_BASELINE_V1);
});
it('returns TRACES_BASELINE_V1 for tag "t"', () => {
expect(getBaselineByTag('t')).toBe(TRACES_BASELINE_V1);
});
it('returns METRICS_BASELINE_V1 for tag "m"', () => {
expect(getBaselineByTag('m')).toBe(METRICS_BASELINE_V1);
});
});

View File

@@ -0,0 +1,51 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import {
clearSerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
describe('composite query serializer', () => {
it('round-trips through serialize/deserialize', () => {
const query = initialQueriesMap.logs;
const decoded = deserialize(serialize(query));
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null on corrupt input instead of throwing', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
expect(deserialize(params)).toBeNull();
});
it('returns null for empty/missing value', () => {
const params = new URLSearchParams();
expect(deserialize(params)).toBeNull();
});
it('preserves id field through roundtrip', () => {
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
const serialized = serialize(query);
const decoded = deserialize(serialized);
expect(decoded?.id).toBe('test-query-uuid-123');
});
it('clearSerializedParams purges every serialized key, leaving others intact', () => {
const params = serialize(initialQueriesMap.logs);
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(deserialize(params)).toBeNull();
expect(params.get('panelTypes')).toBe('list');
});
it('clearSerializedParams drops a corrupt legacy key via fallback', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(params.get('panelTypes')).toBe('list');
});
});

View File

@@ -0,0 +1,63 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import {
CompositeQueryAdapter,
COMPOSITE_QUERY_KEY,
} from 'lib/compositeQuery/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
function migrateLegacyFormat(parsed: Query): Query {
if (!parsed?.builder?.queryData) {
return parsed;
}
const next = parsed;
next.builder.queryData = parsed.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
if (Array.isArray(query.having)) {
convertedQuery.having = convertHavingToExpression(query.having);
}
if (!query.aggregations && query.aggregateOperator) {
convertedQuery.aggregations = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
}
return convertedQuery;
});
return next;
}
export const jsonAdapter: CompositeQueryAdapter = {
name: 'json(legacy)',
encode: (query) => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encodeURIComponent(JSON.stringify(query)));
return params;
},
matches: () => true,
decode: (params) => {
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
const parsed: Query = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
return migrateLegacyFormat(parsed);
},
};

View File

@@ -0,0 +1,74 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { jsonAdapter } from './index';
const roundTrip = (query: Query): Query =>
jsonAdapter.decode(jsonAdapter.encode(query));
describe('jsonAdapter', () => {
describe('round-trip', () => {
it.each(['metrics', 'logs', 'traces'] as const)(
'round-trips %s baseline preserving dataSource',
(source) => {
const query = initialQueriesMap[source];
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].dataSource).toBe(source);
},
);
});
describe('legacy format compatibility', () => {
it('encodes to legacy format (encodeURIComponent + JSON)', () => {
const query = initialQueriesMap.logs;
const params = jsonAdapter.encode(query);
const encoded = params.get(COMPOSITE_QUERY_KEY) ?? '';
expect(encoded).toBe(encodeURIComponent(JSON.stringify(query)));
expect(encoded.startsWith('%7B')).toBe(true);
});
});
describe('tag matching', () => {
it('matches any value (catch-all fallback)', () => {
const params1 = new URLSearchParams();
params1.set(COMPOSITE_QUERY_KEY, '%7B%22queryType%22%3A%22builder%22%7D');
expect(jsonAdapter.matches(params1)).toBe(true);
const params2 = new URLSearchParams();
params2.set(COMPOSITE_QUERY_KEY, 'z1~abc');
expect(jsonAdapter.matches(params2)).toBe(true);
});
});
describe('migration', () => {
it('migrates old format (filters -> filter.expression)', () => {
const legacy = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { op: 'AND', items: [] },
aggregateOperator: 'count',
aggregateAttribute: { key: '', dataType: '', type: '' },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
id: 'x',
unit: '',
};
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encodeURIComponent(JSON.stringify(legacy)));
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filter).toBeDefined();
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
});
});
});

View File

@@ -0,0 +1,81 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter encoding format field aliasing emits the short alias instead of the full field name: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=sum&query0.source="`;
exports[`qsAliasAdapter encoding format prefix substitution rewrites builder.queryData.0 to the query0 prefix: url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability is independent of source key order: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability is stable after spread / reconstruct: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability re-encoding after a decode is byte-identical: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter encoding format stability re-encoding after a decode is byte-identical: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;

View File

@@ -0,0 +1,225 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAlias leaf codec decodeLeaf falls back to raw text on a malformed tagged token (never throws): decoded-fallback 1`] = `
{
"fallback": "_not json",
}
`;
exports[`qsAlias leaf codec decodeLeaf parses tagged empty containers: decoded-containers 1`] = `
{
"array": [],
"object": {},
}
`;
exports[`qsAlias leaf codec decodeLeaf parses tagged scalars back to their type: decoded-scalars 1`] = `
{
"false": false,
"negative": -4.5,
"null": null,
"number": 123,
"true": true,
}
`;
exports[`qsAlias leaf codec decodeLeaf returns untagged tokens as plain strings: decoded-strings 1`] = `
{
"123": "123",
"empty": "",
"null": "null",
"traces": "traces",
"true": "true",
}
`;
exports[`qsAlias leaf codec decodeLeaf unescapes a doubled-tag string: decoded-escaped 1`] = `
{
"__": "_",
"___name__": "__name__",
"__x": "_x",
}
`;
exports[`qsAlias leaf codec encodeLeaf emits strings verbatim: encoded-strings 1`] = `
{
"empty": "",
"service.name": "service.name",
"traces": "traces",
}
`;
exports[`qsAlias leaf codec encodeLeaf escapes a string that begins with the tag char by doubling it: encoded-escaped 1`] = `
{
"_": "__",
"__name__": "___name__",
"_x": "__x",
}
`;
exports[`qsAlias leaf codec encodeLeaf normalizes undefined to null: encoded-undefined 1`] = `
{
"undefined": "_null",
}
`;
exports[`qsAlias leaf codec encodeLeaf type-tags empty containers: encoded-containers 1`] = `
{
"array": "_[]",
"object": "_{}",
}
`;
exports[`qsAlias leaf codec encodeLeaf type-tags non-string scalars with a leading underscore: encoded-scalars 1`] = `
{
"false": "_false",
"negative": "_-4.5",
"null": "_null",
"number": "_123",
"true": "_true",
}
`;
exports[`qsAlias leaf codec round-trip "" survives encode → decode: roundtrip-"" 1`] = `
{
"decoded": "",
"encoded": "",
"input": "",
}
`;
exports[`qsAlias leaf codec round-trip "_" survives encode → decode: roundtrip-"_" 1`] = `
{
"decoded": "_",
"encoded": "__",
"input": "_",
}
`;
exports[`qsAlias leaf codec round-trip "_leading" survives encode → decode: roundtrip-"_leading" 1`] = `
{
"decoded": "_leading",
"encoded": "__leading",
"input": "_leading",
}
`;
exports[`qsAlias leaf codec round-trip "123" survives encode → decode: roundtrip-"123" 1`] = `
{
"decoded": "123",
"encoded": "123",
"input": "123",
}
`;
exports[`qsAlias leaf codec round-trip "a=b&c#d%e+f.g" survives encode → decode: roundtrip-"a=b&c#d%e+f.g" 1`] = `
{
"decoded": "a=b&c#d%e+f.g",
"encoded": "a=b&c#d%e+f.g",
"input": "a=b&c#d%e+f.g",
}
`;
exports[`qsAlias leaf codec round-trip "false" survives encode → decode: roundtrip-"false" 1`] = `
{
"decoded": "false",
"encoded": "false",
"input": "false",
}
`;
exports[`qsAlias leaf codec round-trip "null" survives encode → decode: roundtrip-"null" 1`] = `
{
"decoded": "null",
"encoded": "null",
"input": "null",
}
`;
exports[`qsAlias leaf codec round-trip "service.name" survives encode → decode: roundtrip-"service.name" 1`] = `
{
"decoded": "service.name",
"encoded": "service.name",
"input": "service.name",
}
`;
exports[`qsAlias leaf codec round-trip "traces" survives encode → decode: roundtrip-"traces" 1`] = `
{
"decoded": "traces",
"encoded": "traces",
"input": "traces",
}
`;
exports[`qsAlias leaf codec round-trip "true" survives encode → decode: roundtrip-"true" 1`] = `
{
"decoded": "true",
"encoded": "true",
"input": "true",
}
`;
exports[`qsAlias leaf codec round-trip [] survives encode → decode: roundtrip-[] 1`] = `
{
"decoded": [],
"encoded": "_[]",
"input": [],
}
`;
exports[`qsAlias leaf codec round-trip {} survives encode → decode: roundtrip-{} 1`] = `
{
"decoded": {},
"encoded": "_{}",
"input": {},
}
`;
exports[`qsAlias leaf codec round-trip -4.5 survives encode → decode: roundtrip--4.5 1`] = `
{
"decoded": -4.5,
"encoded": "_-4.5",
"input": -4.5,
}
`;
exports[`qsAlias leaf codec round-trip 0 survives encode → decode: roundtrip-0 1`] = `
{
"decoded": 0,
"encoded": "_0",
"input": 0,
}
`;
exports[`qsAlias leaf codec round-trip 123 survives encode → decode: roundtrip-123 1`] = `
{
"decoded": 123,
"encoded": "_123",
"input": 123,
}
`;
exports[`qsAlias leaf codec round-trip false survives encode → decode: roundtrip-false 1`] = `
{
"decoded": false,
"encoded": "_false",
"input": false,
}
`;
exports[`qsAlias leaf codec round-trip null survives encode → decode: roundtrip-null 1`] = `
{
"decoded": null,
"encoded": "_null",
"input": null,
}
`;
exports[`qsAlias leaf codec round-trip true survives encode → decode: roundtrip-true 1`] = `
{
"decoded": true,
"encoded": "_true",
"input": true,
}
`;

View File

@@ -0,0 +1,388 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAlias maps FIELD_ALIASES integrity FIELD_REVERSE is the exact inverse of FIELD_ALIASES: all-reverse 1`] = `
{
"aggAttr": "aggregateAttribute",
"aggOp": "aggregateOperator",
"ds": "dataSource",
"dt": "dataType",
"ic": "isColumn",
"ij": "isJSON",
"mn": "metricName",
"qn": "queryName",
"qt": "queryType",
"spaceAgg": "spaceAggregation",
"stepIn": "stepInterval",
"timeAgg": "timeAggregation",
"tp": "temporality",
}
`;
exports[`qsAlias maps FIELD_ALIASES integrity alias values are unique (no two fields share an alias): all-aliases 1`] = `
{
"aggregateAttribute": "aggAttr",
"aggregateOperator": "aggOp",
"dataSource": "ds",
"dataType": "dt",
"isColumn": "ic",
"isJSON": "ij",
"metricName": "mn",
"queryName": "qn",
"queryType": "qt",
"spaceAggregation": "spaceAgg",
"stepInterval": "stepIn",
"temporality": "tp",
"timeAggregation": "timeAgg",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips aggregateAttribute ⇄ aggAttr via aliasField / expandField: alias-aggregateAttribute 1`] = `
{
"alias": "aggAttr",
"aliased": "aggAttr",
"expanded": "aggregateAttribute",
"field": "aggregateAttribute",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips aggregateOperator ⇄ aggOp via aliasField / expandField: alias-aggregateOperator 1`] = `
{
"alias": "aggOp",
"aliased": "aggOp",
"expanded": "aggregateOperator",
"field": "aggregateOperator",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips dataSource ⇄ ds via aliasField / expandField: alias-dataSource 1`] = `
{
"alias": "ds",
"aliased": "ds",
"expanded": "dataSource",
"field": "dataSource",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips dataType ⇄ dt via aliasField / expandField: alias-dataType 1`] = `
{
"alias": "dt",
"aliased": "dt",
"expanded": "dataType",
"field": "dataType",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips isColumn ⇄ ic via aliasField / expandField: alias-isColumn 1`] = `
{
"alias": "ic",
"aliased": "ic",
"expanded": "isColumn",
"field": "isColumn",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips isJSON ⇄ ij via aliasField / expandField: alias-isJSON 1`] = `
{
"alias": "ij",
"aliased": "ij",
"expanded": "isJSON",
"field": "isJSON",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips metricName ⇄ mn via aliasField / expandField: alias-metricName 1`] = `
{
"alias": "mn",
"aliased": "mn",
"expanded": "metricName",
"field": "metricName",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips queryName ⇄ qn via aliasField / expandField: alias-queryName 1`] = `
{
"alias": "qn",
"aliased": "qn",
"expanded": "queryName",
"field": "queryName",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips queryType ⇄ qt via aliasField / expandField: alias-queryType 1`] = `
{
"alias": "qt",
"aliased": "qt",
"expanded": "queryType",
"field": "queryType",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips spaceAggregation ⇄ spaceAgg via aliasField / expandField: alias-spaceAggregation 1`] = `
{
"alias": "spaceAgg",
"aliased": "spaceAgg",
"expanded": "spaceAggregation",
"field": "spaceAggregation",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips stepInterval ⇄ stepIn via aliasField / expandField: alias-stepInterval 1`] = `
{
"alias": "stepIn",
"aliased": "stepIn",
"expanded": "stepInterval",
"field": "stepInterval",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips temporality ⇄ tp via aliasField / expandField: alias-temporality 1`] = `
{
"alias": "tp",
"aliased": "tp",
"expanded": "temporality",
"field": "temporality",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips timeAggregation ⇄ timeAgg via aliasField / expandField: alias-timeAggregation 1`] = `
{
"alias": "timeAgg",
"aliased": "timeAgg",
"expanded": "timeAggregation",
"field": "timeAggregation",
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips chsql ⇄ [["clickhouse_sql"]] via transformPath / expandPath: prefix-chsql 1`] = `
{
"expanded": [
"clickhouse_sql",
0,
"someField",
],
"match": [
"clickhouse_sql",
],
"prefix": "chsql",
"transformed": [
"chsql0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips formula ⇄ [["builder", "queryFormulas"]] via transformPath / expandPath: prefix-formula 1`] = `
{
"expanded": [
"builder",
"queryFormulas",
0,
"someField",
],
"match": [
"builder",
"queryFormulas",
],
"prefix": "formula",
"transformed": [
"formula0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips handles multi-digit indices: multi-digit 1`] = `
{
"expanded": [
"builder",
"queryData",
12,
"x",
],
"prefix": "query",
"transformed": [
"query12",
"x",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips promql ⇄ [["promql"]] via transformPath / expandPath: prefix-promql 1`] = `
{
"expanded": [
"promql",
0,
"someField",
],
"match": [
"promql",
],
"prefix": "promql",
"transformed": [
"promql0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips query ⇄ [["builder", "queryData"]] via transformPath / expandPath: prefix-query 1`] = `
{
"expanded": [
"builder",
"queryData",
0,
"someField",
],
"match": [
"builder",
"queryData",
],
"prefix": "query",
"transformed": [
"query0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips traceOp ⇄ [["builder", "queryTraceOperator"]] via transformPath / expandPath: prefix-traceOp 1`] = `
{
"expanded": [
"builder",
"queryTraceOperator",
0,
"someField",
],
"match": [
"builder",
"queryTraceOperator",
],
"prefix": "traceOp",
"transformed": [
"traceOp0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_REVERSE consistency mirrors PREFIX_PATTERNS one-to-one: all-prefix-reverse 1`] = `
{
"chsql": [
"clickhouse_sql",
],
"formula": [
"builder",
"queryFormulas",
],
"promql": [
"promql",
],
"query": [
"builder",
"queryData",
],
"traceOp": [
"builder",
"queryTraceOperator",
],
}
`;
exports[`qsAlias maps alias / expand passthrough leaves numeric path segments untouched: numeric-passthrough 1`] = `
{
"seven": 7,
"zero": 0,
}
`;
exports[`qsAlias maps alias / expand passthrough leaves numeric-string segments untouched in expandField: numeric-string-passthrough 1`] = `
{
"fortyTwo": "42",
"zero": "0",
}
`;
exports[`qsAlias maps alias / expand passthrough leaves unknown field names untouched: unknown-passthrough 1`] = `
{
"aliasUnknown": "unknownField",
"expandUnknown": "zz",
}
`;
exports[`qsAlias maps isOwnedKey matches chsql prefix with index: owned-chsql 1`] = `
{
"chsql0": true,
"chsql0.field": true,
"chsql12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches delete-prefixed keys: delete-prefixed 1`] = `
{
"-formula0": true,
"-query0.field": true,
}
`;
exports[`qsAlias maps isOwnedKey matches formula prefix with index: owned-formula 1`] = `
{
"formula0": true,
"formula0.field": true,
"formula12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches promql prefix with index: owned-promql 1`] = `
{
"promql0": true,
"promql0.field": true,
"promql12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches query prefix with index: owned-query 1`] = `
{
"query0": true,
"query0.field": true,
"query12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches the tag key: tag-key 1`] = `
{
"_t": true,
}
`;
exports[`qsAlias maps isOwnedKey matches top-level query keys: top-level-keys 1`] = `
{
"id": true,
"qt": true,
"queryType": true,
"unit": true,
}
`;
exports[`qsAlias maps isOwnedKey matches traceOp prefix with index: owned-traceOp 1`] = `
{
"traceOp0": true,
"traceOp0.field": true,
"traceOp12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey rejects foreign params: foreign-params 1`] = `
{
"compositeQuery": false,
"endTime": false,
"panelTypes": false,
"startTime": false,
}
`;
exports[`qsAlias maps isOwnedKey rejects prefix without index: prefix-without-index 1`] = `
{
"formula": false,
"query": false,
}
`;

View File

@@ -0,0 +1,795 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter round-trip decoded query keeps exactly the source top-level keys: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip decoded query keeps exactly the source top-level keys: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip is lodash isEqual to the source (ignoring volatile id): decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip is lodash isEqual to the source (ignoring volatile id): url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios clickhouse query survives encode → decode: clickhouse query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "SELECT count() FROM signoz_logs",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "clickhouse_sql",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios clickhouse query survives encode → decode: clickhouse query-url 1`] = `"_t=QAm&chsql0.query=SELECT+count%28%29+FROM+signoz_logs&id=test-stable-id&qt=clickhouse_sql&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios custom id survives encode → decode: custom id-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios custom id survives encode → decode: custom id-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios enum-like legend preserved survives encode → decode: enum-like legend preserved-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "sum",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios enum-like legend preserved survives encode → decode: enum-like legend preserved-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.legend=sum&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios logs baseline survives encode → decode: logs baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios logs baseline survives encode → decode: logs baseline-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios metrics baseline survives encode → decode: metrics baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios metrics baseline survives encode → decode: metrics baseline-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios modified builder query survives encode → decode: modified builder query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "p95",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": true,
"expression": "A",
"filter": {
"expression": "severity_text = 'ERROR'",
},
"filters": {
"items": [
{
"id": "item-1",
"key": {
"dataType": "string",
"isColumn": false,
"isJSON": false,
"key": "severity_text",
"type": "tag",
},
"op": "=",
"value": "ERROR",
},
],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "error rate",
"limit": null,
"orderBy": [
{
"columnName": "timestamp",
"order": "desc",
},
],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": 60,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios modified builder query survives encode → decode: modified builder query-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=p95&query0.disabled=_true&query0.filter.expression=severity_text+%3D+%27ERROR%27&query0.filters.items.0.id=item-1&query0.filters.items.0.key.dt=string&query0.filters.items.0.key.ic=_false&query0.filters.items.0.key.ij=_false&query0.filters.items.0.key.key=severity_text&query0.filters.items.0.key.type=tag&query0.filters.items.0.op=%3D&query0.filters.items.0.value=ERROR&query0.legend=error+rate&query0.orderBy.0.columnName=timestamp&query0.orderBy.0.order=desc&query0.source=&query0.stepIn=_60"`;
exports[`qsAliasAdapter round-trip scenarios promql query survives encode → decode: promql query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "rate(http_requests_total[5m])",
},
],
"queryType": "promql",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios promql query survives encode → decode: promql query-url 1`] = `"_t=QAm&id=test-stable-id&promql0.query=rate%28http_requests_total%5B5m%5D%29&qt=promql&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios traces baseline survives encode → decode: traces baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios traces baseline survives encode → decode: traces baseline-url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios wire delimiters in values survives encode → decode: wire delimiters in values-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "!weird = "x_y*z"",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "_a*b_*c",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios wire delimiters in values survives encode → decode: wire delimiters in values-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.filter.expression=%21weird+%3D+%22x_y*z%22&query0.legend=__a*b_*c&query0.source="`;

View File

@@ -0,0 +1,277 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter tagging encode tags by dataSource logs → QAl: url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging encode tags by dataSource metrics → QAm: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging encode tags by dataSource traces → QAt: url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAl decodes to the logs baseline: decoded-QAl 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAm decodes to the metrics baseline: decoded-QAm 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAt decodes to the traces baseline: decoded-QAt 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline round-trips the baseline with no extra params: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline round-trips the baseline with no extra params: url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;

View File

@@ -0,0 +1,213 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const makeFilterItem = (value: string): any => ({
key: {
key: 'severity_text',
dataType: 'string',
type: 'tag',
isColumn: false,
isJSON: false,
},
id: `item-${value}`,
op: '=',
value,
});
describe('qsAliasAdapter edge cases', () => {
describe('baseline field deletion', () => {
it('emits a delete token and decode drops the field', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (query.builder.queryData[0] as any).aggregateOperator;
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('-query0.aggOp');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect('aggregateOperator' in decoded.builder.queryData[0]).toBe(false);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('array growth', () => {
it('round-trips multiple added filter items element-wise', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].filters = {
op: 'AND',
items: [makeFilterItem('a'), makeFilterItem('b')],
};
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.filters.items.0.');
expect(wire).toContain('query0.filters.items.1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('null and empty containers', () => {
it('round-trips a null leaf', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).legend = null;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips an empty-object leaf', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].filter =
{} as Query['builder']['queryData'][0]['filter'];
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips an empty-array leaf', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].groupBy = [];
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('undefined values', () => {
it('does not break decode when fields are undefined', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).aggregateOperator = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).source = undefined;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
expect(() => roundTrip(query)).not.toThrow();
const decoded = roundTrip(query);
expect(decoded).not.toBeNull();
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
/**
* The wire type-tags non-strings (`_123`, `_true`, `_null`) and emits strings
* verbatim, while qs percent-encodes values. Every scalar therefore
* round-trips losslessly — including strings that look like numbers/booleans
* or contain query-string delimiters.
*/
describe('tricky scalar values (lossless)', () => {
it('keeps a numeric-looking string as a string', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '123';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('keeps "true" / "false" / "null" string values as strings', () => {
['true', 'false', 'null'].forEach((literal) => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = literal;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot(`url-${literal}`);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot(`decoded-${literal}`);
});
});
it('preserves a value containing the ampersand delimiter', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = 'x&y';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('preserves assorted wire-special characters', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = 'a=b&c#d%e+f.g';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('preserves a string that begins with the type-tag char', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '_underscored';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('scalar type fidelity', () => {
it('keeps number and look-alike string distinct', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].stepInterval = 300;
query.builder.queryData[0].legend = '300';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].stepInterval).toBe(300);
expect(decoded.builder.queryData[0].legend).toBe('300');
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('keeps boolean and look-alike string distinct', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].disabled = true;
query.builder.queryData[0].legend = 'true';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].disabled).toBe(true);
expect(decoded.builder.queryData[0].legend).toBe('true');
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,89 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
describe('qsAliasAdapter encoding format', () => {
describe('prefix substitution', () => {
it('rewrites builder.queryData.0 to the query0 prefix', () => {
const query = clone(initialQueriesMap.traces);
query.builder.queryData[0].aggregateOperator = 'count';
const encoded = qsAliasAdapter.encode(query);
const keys = Array.from(encoded.keys());
expect(keys.some((k) => k.startsWith('query0.'))).toBe(true);
expect(keys.some((k) => k.includes('queryData'))).toBe(false);
expect(keys.some((k) => k.includes('builder'))).toBe(false);
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
});
describe('field aliasing', () => {
it('emits the short alias instead of the full field name', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData[0].aggregateOperator = 'sum';
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.aggOp=');
expect(wire).not.toContain('aggregateOperator');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
describe('stability', () => {
it('re-encoding after a decode is byte-identical', () => {
const encoded1 = qsAliasAdapter.encode(initialQueriesMap.metrics);
const encoded2 = qsAliasAdapter.encode(qsAliasAdapter.decode(encoded1));
expect(encoded2.toString()).toBe(encoded1.toString());
expect(normalizeUrl(encoded1.toString())).toMatchSnapshot('url');
expect(normalizeId(qsAliasAdapter.decode(encoded1))).toMatchSnapshot(
'decoded',
);
});
it('is independent of source key order', () => {
const query1 = initialQueriesMap.metrics;
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
const reordered = {
unit: query2.unit,
id: query2.id,
queryType: query2.queryType,
clickhouse_sql: query2.clickhouse_sql,
promql: query2.promql,
builder: query2.builder,
} as Query;
const wire1 = qsAliasAdapter.encode(query1).toString();
const wire2 = qsAliasAdapter.encode(reordered).toString();
expect(wire2).toBe(wire1);
expect(normalizeUrl(wire1)).toMatchSnapshot('url');
});
it('is stable after spread / reconstruct', () => {
const query = { ...initialQueriesMap.metrics };
const transformed = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({ ...item })),
},
};
const wire = qsAliasAdapter.encode(transformed).toString();
expect(wire).toBe(qsAliasAdapter.encode(query).toString());
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
});

View File

@@ -0,0 +1,154 @@
import { Json } from '../diff/predicates';
import { decodeLeaf, encodeLeaf } from '../leaf';
describe('qsAlias leaf codec', () => {
describe('encodeLeaf', () => {
it('emits strings verbatim', () => {
expect(encodeLeaf('traces')).toBe('traces');
expect(encodeLeaf('service.name')).toBe('service.name');
expect(encodeLeaf('')).toBe('');
expect({
traces: encodeLeaf('traces'),
'service.name': encodeLeaf('service.name'),
empty: encodeLeaf(''),
}).toMatchSnapshot('encoded-strings');
});
it('type-tags non-string scalars with a leading underscore', () => {
expect(encodeLeaf(123)).toBe('_123');
expect(encodeLeaf(-4.5)).toBe('_-4.5');
expect(encodeLeaf(true)).toBe('_true');
expect(encodeLeaf(false)).toBe('_false');
expect(encodeLeaf(null)).toBe('_null');
expect({
number: encodeLeaf(123),
negative: encodeLeaf(-4.5),
true: encodeLeaf(true),
false: encodeLeaf(false),
null: encodeLeaf(null),
}).toMatchSnapshot('encoded-scalars');
});
it('type-tags empty containers', () => {
expect(encodeLeaf([])).toBe('_[]');
expect(encodeLeaf({})).toBe('_{}');
expect({
array: encodeLeaf([]),
object: encodeLeaf({}),
}).toMatchSnapshot('encoded-containers');
});
it('normalizes undefined to null', () => {
expect(encodeLeaf(undefined)).toBe('_null');
expect({ undefined: encodeLeaf(undefined) }).toMatchSnapshot(
'encoded-undefined',
);
});
it('escapes a string that begins with the tag char by doubling it', () => {
expect(encodeLeaf('_x')).toBe('__x');
expect(encodeLeaf('_')).toBe('__');
expect(encodeLeaf('__name__')).toBe('___name__');
expect({
_x: encodeLeaf('_x'),
_: encodeLeaf('_'),
__name__: encodeLeaf('__name__'),
}).toMatchSnapshot('encoded-escaped');
});
});
describe('decodeLeaf', () => {
it('returns untagged tokens as plain strings', () => {
expect(decodeLeaf('traces')).toBe('traces');
expect(decodeLeaf('123')).toBe('123');
expect(decodeLeaf('true')).toBe('true');
expect(decodeLeaf('null')).toBe('null');
expect(decodeLeaf('')).toBe('');
expect({
traces: decodeLeaf('traces'),
'123': decodeLeaf('123'),
true: decodeLeaf('true'),
null: decodeLeaf('null'),
empty: decodeLeaf(''),
}).toMatchSnapshot('decoded-strings');
});
it('parses tagged scalars back to their type', () => {
expect(decodeLeaf('_123')).toBe(123);
expect(decodeLeaf('_-4.5')).toBe(-4.5);
expect(decodeLeaf('_true')).toBe(true);
expect(decodeLeaf('_false')).toBe(false);
expect(decodeLeaf('_null')).toBeNull();
expect({
number: decodeLeaf('_123'),
negative: decodeLeaf('_-4.5'),
true: decodeLeaf('_true'),
false: decodeLeaf('_false'),
null: decodeLeaf('_null'),
}).toMatchSnapshot('decoded-scalars');
});
it('parses tagged empty containers', () => {
expect(decodeLeaf('_[]')).toStrictEqual([]);
expect(decodeLeaf('_{}')).toStrictEqual({});
expect({
array: decodeLeaf('_[]'),
object: decodeLeaf('_{}'),
}).toMatchSnapshot('decoded-containers');
});
it('unescapes a doubled-tag string', () => {
expect(decodeLeaf('__x')).toBe('_x');
expect(decodeLeaf('__')).toBe('_');
expect(decodeLeaf('___name__')).toBe('__name__');
expect({
__x: decodeLeaf('__x'),
__: decodeLeaf('__'),
___name__: decodeLeaf('___name__'),
}).toMatchSnapshot('decoded-escaped');
});
it('falls back to raw text on a malformed tagged token (never throws)', () => {
expect(() => decodeLeaf('_not json')).not.toThrow();
expect(decodeLeaf('_not json')).toBe('_not json');
expect({ fallback: decodeLeaf('_not json') }).toMatchSnapshot(
'decoded-fallback',
);
});
});
describe('round-trip', () => {
const cases: Json[] = [
'traces',
'',
'123',
'true',
'false',
'null',
'_leading',
'_',
'a=b&c#d%e+f.g',
'service.name',
0,
123,
-4.5,
true,
false,
null,
[],
{},
];
it.each(cases.map((value) => [JSON.stringify(value), value] as const))(
'%s survives encode → decode',
(label, value) => {
const encoded = encodeLeaf(value);
const decoded = decodeLeaf(encoded);
expect(decoded).toStrictEqual(value);
expect({ input: value, encoded, decoded }).toMatchSnapshot(
`roundtrip-${label}`,
);
},
);
});
});

View File

@@ -0,0 +1,179 @@
import { aliasField, expandField, expandPath, transformPath } from '../codec';
import {
FIELD_ALIASES,
FIELD_REVERSE,
isOwnedKey,
PREFIX_PATTERNS,
PREFIX_REVERSE,
} from '../maps';
describe('qsAlias maps', () => {
describe('FIELD_ALIASES — every key round-trips', () => {
it.each(Object.entries(FIELD_ALIASES))(
'%s ⇄ %s via aliasField / expandField',
(field, alias) => {
expect(aliasField(field)).toBe(alias);
expect(expandField(alias)).toBe(field);
expect({
field,
alias,
aliased: aliasField(field),
expanded: expandField(alias),
}).toMatchSnapshot(`alias-${field}`);
},
);
});
describe('FIELD_ALIASES integrity', () => {
it('alias values are unique (no two fields share an alias)', () => {
const values = Object.values(FIELD_ALIASES);
expect(new Set(values).size).toBe(values.length);
expect(FIELD_ALIASES).toMatchSnapshot('all-aliases');
});
it('no alias contains "." (would corrupt path splitting)', () => {
Object.values(FIELD_ALIASES).forEach((alias) => {
expect(alias).not.toContain('.');
});
});
it('FIELD_REVERSE is the exact inverse of FIELD_ALIASES', () => {
expect(FIELD_REVERSE).toStrictEqual(
Object.fromEntries(
Object.entries(FIELD_ALIASES).map(([key, value]) => [value, key]),
),
);
expect(FIELD_REVERSE).toMatchSnapshot('all-reverse');
});
});
describe('PREFIX_PATTERNS — every prefix round-trips', () => {
it.each(PREFIX_PATTERNS)(
'$prefix ⇄ [$match] via transformPath / expandPath',
({ match, prefix }) => {
const fullPath = [...match, 0, 'someField'];
const transformed = transformPath(fullPath);
const expanded = expandPath(`${prefix}0.someField`);
expect(transformed).toStrictEqual([`${prefix}0`, 'someField']);
expect(expanded).toStrictEqual([...match, 0, 'someField']);
expect({ prefix, match, transformed, expanded }).toMatchSnapshot(
`prefix-${prefix}`,
);
},
);
it('handles multi-digit indices', () => {
const { match, prefix } = PREFIX_PATTERNS[0];
const transformed = transformPath([...match, 12, 'x']);
const expanded = expandPath(`${prefix}12.x`);
expect(transformed).toStrictEqual([`${prefix}12`, 'x']);
expect(expanded).toStrictEqual([...match, 12, 'x']);
expect({ prefix, transformed, expanded }).toMatchSnapshot('multi-digit');
});
});
describe('PREFIX_REVERSE consistency', () => {
it('mirrors PREFIX_PATTERNS one-to-one', () => {
PREFIX_PATTERNS.forEach(({ match, prefix }) => {
expect(PREFIX_REVERSE[prefix]).toStrictEqual(match);
});
expect(Object.keys(PREFIX_REVERSE).sort()).toStrictEqual(
PREFIX_PATTERNS.map((pattern) => pattern.prefix).sort(),
);
expect(PREFIX_REVERSE).toMatchSnapshot('all-prefix-reverse');
});
});
describe('alias / expand passthrough', () => {
it('leaves numeric path segments untouched', () => {
expect(aliasField(0)).toBe(0);
expect(aliasField(7)).toBe(7);
expect({ zero: aliasField(0), seven: aliasField(7) }).toMatchSnapshot(
'numeric-passthrough',
);
});
it('leaves unknown field names untouched', () => {
expect(aliasField('unknownField')).toBe('unknownField');
expect(expandField('zz')).toBe('zz');
expect({
aliasUnknown: aliasField('unknownField'),
expandUnknown: expandField('zz'),
}).toMatchSnapshot('unknown-passthrough');
});
it('leaves numeric-string segments untouched in expandField', () => {
expect(expandField('0')).toBe('0');
expect(expandField('42')).toBe('42');
expect({
zero: expandField('0'),
fortyTwo: expandField('42'),
}).toMatchSnapshot('numeric-string-passthrough');
});
});
describe('isOwnedKey', () => {
it('matches the tag key', () => {
expect(isOwnedKey('_t')).toBe(true);
expect({ _t: isOwnedKey('_t') }).toMatchSnapshot('tag-key');
});
it.each(PREFIX_PATTERNS.map((p) => p.prefix))(
'matches %s prefix with index',
(prefix) => {
expect(isOwnedKey(`${prefix}0`)).toBe(true);
expect(isOwnedKey(`${prefix}0.field`)).toBe(true);
expect(isOwnedKey(`${prefix}12.nested.path`)).toBe(true);
expect({
[`${prefix}0`]: isOwnedKey(`${prefix}0`),
[`${prefix}0.field`]: isOwnedKey(`${prefix}0.field`),
[`${prefix}12.nested.path`]: isOwnedKey(`${prefix}12.nested.path`),
}).toMatchSnapshot(`owned-${prefix}`);
},
);
it('matches delete-prefixed keys', () => {
expect(isOwnedKey('-query0.field')).toBe(true);
expect(isOwnedKey('-formula0')).toBe(true);
expect({
'-query0.field': isOwnedKey('-query0.field'),
'-formula0': isOwnedKey('-formula0'),
}).toMatchSnapshot('delete-prefixed');
});
it('matches top-level query keys', () => {
expect(isOwnedKey('id')).toBe(true);
expect(isOwnedKey('queryType')).toBe(true);
expect(isOwnedKey('qt')).toBe(true);
expect(isOwnedKey('unit')).toBe(true);
expect({
id: isOwnedKey('id'),
queryType: isOwnedKey('queryType'),
qt: isOwnedKey('qt'),
unit: isOwnedKey('unit'),
}).toMatchSnapshot('top-level-keys');
});
it('rejects foreign params', () => {
expect(isOwnedKey('panelTypes')).toBe(false);
expect(isOwnedKey('startTime')).toBe(false);
expect(isOwnedKey('endTime')).toBe(false);
expect(isOwnedKey('compositeQuery')).toBe(false);
expect({
panelTypes: isOwnedKey('panelTypes'),
startTime: isOwnedKey('startTime'),
endTime: isOwnedKey('endTime'),
compositeQuery: isOwnedKey('compositeQuery'),
}).toMatchSnapshot('foreign-params');
});
it('rejects prefix without index', () => {
expect(isOwnedKey('query')).toBe(false);
expect(isOwnedKey('formula')).toBe(false);
expect({
query: isOwnedKey('query'),
formula: isOwnedKey('formula'),
}).toMatchSnapshot('prefix-without-index');
});
});
});

View File

@@ -0,0 +1,364 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
} from 'constants/queryBuilder';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
const makeSecondBuilderQuery = (name: string): IBuilderQuery => ({
...clone(initialQueryBuilderFormValuesMap.metrics),
queryName: name,
aggregateOperator: 'avg',
legend: `${name} legend`,
});
const makeFormula = (name: string, expression: string): IBuilderFormula => ({
queryName: name,
expression,
disabled: false,
legend: `${name} result`,
});
describe('qsAliasAdapter multi-queryData', () => {
describe('multiple builder queries', () => {
it('round-trips two queryData entries (A + B)', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three queryData entries (A + B + C)', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryData.push(makeSecondBuilderQuery('C'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('formula queries', () => {
it('round-trips single formula F1 = A/B', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips multiple formulas F1 + F2', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryData.push(makeSecondBuilderQuery('C'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
query.builder.queryFormulas.push(makeFormula('F2', 'A*100/C'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips formula with complex expression', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', '(A - B) / B * 100'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('multiple clickhouse queries', () => {
it('round-trips two clickhouse_sql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query =
'SELECT count() FROM logs WHERE severity > 0';
query.clickhouse_sql.push({
name: 'B',
legend: 'total',
disabled: false,
query: 'SELECT count() FROM logs',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three clickhouse_sql entries with mixed disabled states', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query = 'SELECT 1';
query.clickhouse_sql.push({
name: 'B',
legend: 'second',
disabled: true,
query: 'SELECT 2',
});
query.clickhouse_sql.push({
name: 'C',
legend: '',
disabled: false,
query: 'SELECT 3',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('multiple promql queries', () => {
it('round-trips two promql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'rate(http_requests_total[5m])';
query.promql.push({
name: 'B',
legend: 'errors',
disabled: false,
query: 'rate(http_errors_total[5m])',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three promql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'metric_a';
query.promql.push({
name: 'B',
legend: 'b-legend',
disabled: false,
query: 'metric_b',
});
query.promql.push({
name: 'C',
legend: '',
disabled: true,
query: 'metric_c',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('mixed data sources within builder', () => {
it('round-trips logs queryData with formulas', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData.push({
...clone(initialQueryBuilderFormValuesMap.logs),
queryName: 'B',
aggregateOperator: 'count_distinct',
});
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips traces queryData with formulas', () => {
const query = clone(initialQueriesMap.traces);
query.builder.queryData.push({
...clone(initialQueryBuilderFormValuesMap.traces),
queryName: 'B',
aggregateOperator: 'p99',
});
query.builder.queryFormulas.push(makeFormula('F1', 'B - A'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('wire format verification', () => {
it('encodes multiple queryData with indexed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.');
expect(wire).toContain('query1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes formulas with formula-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(query.builder.queryFormulas).toHaveLength(1);
expect(query.builder.queryFormulas[0].queryName).toBe('F1');
expect(wire).toContain('formula0.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes clickhouse with chsql-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql.push({
name: 'B',
legend: '',
disabled: false,
query: 'SELECT 1',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('chsql1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes promql with promql-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql.push({
name: 'B',
legend: '',
disabled: false,
query: 'metric_b',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('promql1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
describe('template diffing optimization', () => {
it('added queryData only emits changed fields vs baseline[0]', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push({
...clone(query.builder.queryData[0]),
queryName: 'B',
aggregateOperator: 'avg',
legend: 'B query',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const params = new URLSearchParams(wire);
const query1Params = Array.from(params.keys()).filter((k) =>
k.startsWith('query1.'),
);
// Should have ~4-5 params (qn, aggOp, legend, source), not ~25
expect(query1Params.length).toBeLessThan(10);
// Should NOT have unchanged fields
expect(wire).not.toContain('query1.filters.op');
expect(wire).not.toContain('query1.groupBy');
expect(wire).not.toContain('query1.having');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('decoder correctly reconstructs from template-diffed wire', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push({
...clone(query.builder.queryData[0]),
queryName: 'B',
aggregateOperator: 'avg',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
// Wire should be compact
expect(wire).not.toContain('query1.filters.op');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('works for queryFormulas with template inheritance', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryFormulas.push(makeFormula('F1', 'A'));
query.builder.queryFormulas.push({
...makeFormula('F2', 'B'),
disabled: true,
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const params = new URLSearchParams(wire);
const f1Params = Array.from(params.keys()).filter((k) =>
k.startsWith('formula0.'),
);
const f2Params = Array.from(params.keys()).filter((k) =>
k.startsWith('formula1.'),
);
// F2 should be smaller or equal (diffs against F1)
expect(f2Params.length).toBeLessThanOrEqual(f1Params.length);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,52 @@
import { isEqual } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { roundTripScenarios } from '../../testing/scenarios';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
describe('qsAliasAdapter round-trip', () => {
describe('scenarios', () => {
it.each(roundTripScenarios)(
'$name survives encode → decode',
({ query, name }) => {
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot(`${name}-url`);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot(`${name}-decoded`);
},
);
});
it('decoded query keeps exactly the source top-level keys', () => {
const wire = qsAliasAdapter.encode(initialQueriesMap.metrics).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(initialQueriesMap.metrics);
expect(Object.keys(decoded).sort()).toStrictEqual(
Object.keys(initialQueriesMap.metrics).sort(),
);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('is lodash isEqual to the source (ignoring volatile id)', () => {
const wire = qsAliasAdapter.encode(initialQueriesMap.metrics).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(initialQueriesMap.metrics);
const { id: _sourceId, ...source } = initialQueriesMap.metrics;
const { id: _decodedId, ...result } = decoded;
expect(isEqual(source, result)).toBe(true);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});

View File

@@ -0,0 +1,92 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { decodeQsAlias, encodeQsAlias, qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const tagOf = (params: URLSearchParams): string => params.get('_t') ?? '';
describe('qsAliasAdapter tagging', () => {
describe('encode tags by dataSource', () => {
it('metrics → QAm', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.metrics);
expect(tagOf(encoded)).toBe('QAm');
expect(encodeQsAlias(initialQueriesMap.metrics).tag).toBe('QAm');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
it('logs → QAl', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.logs);
expect(tagOf(encoded)).toBe('QAl');
expect(encodeQsAlias(initialQueriesMap.logs).tag).toBe('QAl');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
it('traces → QAt', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.traces);
expect(tagOf(encoded)).toBe('QAt');
expect(encodeQsAlias(initialQueriesMap.traces).tag).toBe('QAt');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
});
describe('matches', () => {
it('matches its own QAm/QAl/QAt tags', () => {
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.metrics)),
).toBe(true);
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.logs)),
).toBe(true);
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.traces)),
).toBe(true);
});
it('rejects another serializer tag', () => {
const params = new URLSearchParams();
params.set('_t', 'FVm~');
expect(qsAliasAdapter.matches(params)).toBe(false);
});
it('rejects the legacy compositeQuery param', () => {
const params = new URLSearchParams();
params.set('compositeQuery', '{"queryType":"builder"}');
expect(qsAliasAdapter.matches(params)).toBe(false);
});
it('rejects empty params', () => {
expect(qsAliasAdapter.matches(new URLSearchParams())).toBe(false);
});
});
describe('tag-only decode returns the baseline', () => {
it.each([
['QAm', 'metrics'],
['QAl', 'logs'],
['QAt', 'traces'],
] as const)('%s decodes to the %s baseline', (tag, dataSource) => {
const params = new URLSearchParams();
params.set('_t', tag);
const decoded = decodeQsAlias(params);
expect(decoded.queryType).toBe('builder');
expect(decoded.builder.queryData[0].dataSource).toBe(dataSource);
expect(normalizeId(decoded)).toMatchSnapshot(`decoded-${tag}`);
});
it('round-trips the baseline with no extra params', () => {
const { params, tag } = encodeQsAlias(initialQueriesMap.logs);
expect(tag).toBe('QAl');
expect(normalizeUrl(params.toString())).toMatchSnapshot('url');
const decoded = decodeQsAlias(params);
expect(decoded).toStrictEqual(initialQueriesMap.logs);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,302 @@
/**
* qsAlias codec: content-aware URL serialization with prefix substitution
* and field aliasing for readable, compact URLs.
*
* Wire format: multiple query params with aliased paths
* _t=QAm&query0.ds=traces&query0.aa.key=http.status_code&query0.fl.it.0.key.key=service.name
*
* Prefix substitution: builder.queryData.0 → query0
* Field aliasing: aggregateAttribute → aa, filters → fl, etc.
*/
import set from 'lodash-es/set';
import qs from 'qs';
import getBaselineByTag, {
BaselineTag,
pickBaseline,
} from 'lib/compositeQuery/baseline';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { computeDiff, DiffCode } from './diff/diff';
import { isLeaf, Json, PathSeg } from './diff/predicates';
import { decodeLeaf, encodeLeaf } from './leaf';
import {
FIELD_ALIASES,
FIELD_REVERSE,
isOwnedKey,
PREFIX_PATTERNS,
PREFIX_REVERSE,
} from './maps';
const TAG_KEY = '_t';
const DEL_PREFIX = '-';
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
function matchesPrefix(path: PathSeg[], match: string[]): boolean {
for (let i = 0; i < match.length; i++) {
if (path[i] !== match[i]) {
return false;
}
}
return true;
}
// Path/alias helpers below are exported for direct unit testing; the adapter's
// public surface (index.ts) still exposes only encode/decode.
export function aliasField(seg: PathSeg): PathSeg {
if (typeof seg === 'number') {
return seg;
}
return FIELD_ALIASES[seg] ?? seg;
}
export function expandField(seg: string): string {
if (isIndex(seg)) {
return seg;
}
return FIELD_REVERSE[seg] ?? seg;
}
export function transformPath(path: PathSeg[]): PathSeg[] {
for (const { match, prefix } of PREFIX_PATTERNS) {
if (path.length > match.length && matchesPrefix(path, match)) {
const idx = path[match.length];
if (typeof idx === 'number') {
const rest = path.slice(match.length + 1).map(aliasField);
return [`${prefix}${idx}`, ...rest];
}
}
}
return path.map(aliasField);
}
export function expandPath(pathStr: string): PathSeg[] {
const segs = pathStr.split('.');
const first = segs[0];
for (const [prefixName, originalPath] of Object.entries(PREFIX_REVERSE)) {
const match = first.match(new RegExp(`^${prefixName}(\\d+)$`));
if (match) {
const idx = parseInt(match[1], 10);
const rest = segs.slice(1).map(expandField);
return [...originalPath, idx, ...rest];
}
}
return segs.map((s) => (isIndex(s) ? parseInt(s, 10) : expandField(s)));
}
function flattenValue(
target: Record<string, string>,
prefix: string,
value: Json,
): void {
if (value === null || typeof value !== 'object') {
target[prefix] = encodeLeaf(value);
return;
}
if (Array.isArray(value)) {
if (value.length === 0) {
target[prefix] = encodeLeaf(value);
return;
}
for (let i = 0; i < value.length; i++) {
flattenValue(target, `${prefix}.${i}`, value[i]);
}
return;
}
const obj = value as Record<string, Json>;
if (Object.keys(obj).length === 0) {
target[prefix] = encodeLeaf(value);
return;
}
for (const [k, v] of Object.entries(obj)) {
flattenValue(target, `${prefix}.${aliasField(k)}`, v);
}
}
function diffToFlatObject(
baseline: Query,
query: Query,
): Record<string, string> {
const ops = computeDiff(baseline, query);
const obj: Record<string, string> = {};
for (const [code, path, value] of ops) {
const key = transformPath(path).join('.');
if (code === DiffCode.Delete) {
obj[`${DEL_PREFIX}${key}`] = '';
} else if (typeof value === 'object' && value !== null) {
flattenValue(obj, key, value);
} else {
obj[key] = encodeLeaf(value);
}
}
return obj;
}
function leafMap(obj: Json): Record<string, Json> {
const out: Record<string, Json> = {};
const walk = (node: Json, segs: PathSeg[]): void => {
if (isLeaf(node)) {
out[segs.join('.')] = node;
return;
}
if (Array.isArray(node)) {
node.forEach((value, index) => walk(value, [...segs, index]));
return;
}
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
walk(value, [...segs, key]),
);
};
walk(obj, []);
return out;
}
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
const root: Record<string, Json> = {};
Object.entries(map).forEach(([path, value]) => {
const segs = path.split('.').map((s) => (isIndex(s) ? parseInt(s, 10) : s));
set(root, segs, value);
});
return root;
}
/**
* Clone baseline[0] paths to a higher index for template-based array diffing.
* When encoder emits `query1.aggOp=avg`, decoder needs `builder.queryData.1.*`
* to exist first (cloned from index 0) before applying the patch.
*/
function ensureArrayIndexFromTemplate(
baseMap: Record<string, Json>,
arrayPrefix: string,
targetIndex: number,
): void {
const sourcePrefix = `${arrayPrefix}.0.`;
const targetPrefix = `${arrayPrefix}.${targetIndex}.`;
// Skip if target already has entries (already cloned or from baseline)
const hasTarget = Object.keys(baseMap).some((k) => k.startsWith(targetPrefix));
if (hasTarget) {
return;
}
// Clone all index-0 paths to target index
for (const [path, value] of Object.entries(baseMap)) {
if (path.startsWith(sourcePrefix)) {
const suffix = path.slice(sourcePrefix.length);
baseMap[`${targetPrefix}${suffix}`] = value;
}
}
}
export function encode(query: Query): { params: URLSearchParams; tag: string } {
const { baseline, tag } = pickBaseline(query);
const obj = diffToFlatObject(baseline, query);
// `encodeValuesOnly` percent-encodes values (so `&`, `=`, `%`, … survive)
// while leaving the readable dotted keys untouched.
const queryString = qs.stringify(
{ [TAG_KEY]: `QA${tag}`, ...obj },
{
encodeValuesOnly: true,
sort: (a, b) => a.localeCompare(b),
},
);
return { params: new URLSearchParams(queryString), tag: `QA${tag}` };
}
/**
* When a nested path like `a.b.0.c` is set, any ancestor empty-container entry
* (`a.b` = `[]`) must be removed or `rebuildFromLeaves` order may clobber it.
*/
function deleteAncestorEmptyContainers(
map: Record<string, Json>,
fullPath: string,
): void {
const segs = fullPath.split('.');
for (let i = 1; i < segs.length; i += 1) {
const ancestor = segs.slice(0, i).join('.');
const value = map[ancestor];
if (
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' &&
value !== null &&
Object.keys(value).length === 0)
) {
delete map[ancestor];
}
}
}
/**
* Check if expanded path refers to an array element beyond index 0.
* Returns [arrayPrefix, index] if so, null otherwise.
*/
function detectArrayGrowth(expandedPath: PathSeg[]): [string, number] | null {
for (const { match } of PREFIX_PATTERNS) {
if (expandedPath.length > match.length) {
const matchesPattern = match.every((seg, i) => expandedPath[i] === seg);
if (matchesPattern) {
const idx = expandedPath[match.length];
if (typeof idx === 'number' && idx > 0) {
return [match.join('.'), idx];
}
}
}
}
return null;
}
export function decode(params: URLSearchParams): Query {
const parsed = qs.parse(params.toString()) as Record<string, unknown>;
const tagValue = (parsed[TAG_KEY] as string) ?? '';
const baselineTag = tagValue.slice(2) as BaselineTag;
const baseline = getBaselineByTag(baselineTag);
const baseMap = leafMap(baseline);
const clonedIndices = new Set<string>();
for (const [key, value] of Object.entries(parsed)) {
if (key === TAG_KEY) {
continue;
}
// Skip foreign params (e.g. panelTypes, startTime) that qs.parse included.
if (!isOwnedKey(key)) {
continue;
}
if (key.startsWith(DEL_PREFIX)) {
const expandedPath = expandPath(key.slice(1));
const shortPath = expandedPath.join('.');
for (const basePath of Object.keys(baseMap)) {
if (basePath === shortPath || basePath.startsWith(`${shortPath}.`)) {
delete baseMap[basePath];
}
}
continue;
}
const expandedPath = expandPath(key);
// For paths like builder.queryData.1.*, clone from index 0 first
const growth = detectArrayGrowth(expandedPath);
if (growth) {
const [arrayPrefix, idx] = growth;
const cacheKey = `${arrayPrefix}.${idx}`;
if (!clonedIndices.has(cacheKey)) {
ensureArrayIndexFromTemplate(baseMap, arrayPrefix, idx);
clonedIndices.add(cacheKey);
}
}
const fullPath = expandedPath.join('.');
deleteAncestorEmptyContainers(baseMap, fullPath);
baseMap[fullPath] = typeof value === 'string' ? decodeLeaf(value) : value;
}
return rebuildFromLeaves(baseMap) as unknown as Query;
}

View File

@@ -0,0 +1,342 @@
import {
computeDiff,
DiffCode,
DiffOp,
diffArrays,
diffNodes,
diffObjects,
} from '../diff';
const noop = (): void => undefined;
const paths = (ops: DiffOp[]): string[] =>
ops.map(([, path]) => path.join('.'));
describe('qsAlias/diff', () => {
describe('DiffCode', () => {
it('has stable wire-significant numeric codes', () => {
// These leak onto the URL via the codec, so they must not drift.
expect(DiffCode.Set).toBe(1);
expect(DiffCode.Delete).toBe(2);
});
});
describe('computeDiff on leaves', () => {
it('returns no ops when scalars are equal', () => {
expect(computeDiff('a', 'a')).toStrictEqual([]);
expect(computeDiff(1, 1)).toStrictEqual([]);
expect(computeDiff(true, true)).toStrictEqual([]);
expect(computeDiff(null, null)).toStrictEqual([]);
});
it('emits a single Set rooted at [] when scalars differ', () => {
expect(computeDiff(1, 2)).toStrictEqual([[DiffCode.Set, [], 2]]);
expect(computeDiff('a', 'b')).toStrictEqual([[DiffCode.Set, [], 'b']]);
expect(computeDiff(true, false)).toStrictEqual([[DiffCode.Set, [], false]]);
});
it('distinguishes null, false, 0 and empty string', () => {
expect(computeDiff(null, false)).toStrictEqual([[DiffCode.Set, [], false]]);
expect(computeDiff(0, '')).toStrictEqual([[DiffCode.Set, [], '']]);
expect(computeDiff(0, null)).toStrictEqual([[DiffCode.Set, [], null]]);
});
});
describe('computeDiff on objects', () => {
it('returns no ops for deep-equal objects', () => {
expect(
computeDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }),
).toStrictEqual([]);
});
it('emits Set for an added key', () => {
expect(computeDiff({ a: 1 }, { a: 1, b: 2 })).toStrictEqual([
[DiffCode.Set, ['b'], 2],
]);
});
it('emits Delete (undefined value) for a removed key', () => {
expect(computeDiff({ a: 1, b: 2 }, { a: 1 })).toStrictEqual([
[DiffCode.Delete, ['b'], undefined],
]);
});
it('emits Set at the nested path for a changed deep value', () => {
expect(
computeDiff({ a: { b: { c: 1 } } }, { a: { b: { c: 9 } } }),
).toStrictEqual([[DiffCode.Set, ['a', 'b', 'c'], 9]]);
});
it('produces deterministic op order following base-then-query keys', () => {
const base = { ds: 'logs', ag: [{ mn: 'x', ao: 'noop' }], gb: [] };
const query = {
ds: 'traces',
ag: [{ mn: 'x', ao: 'sum' }, { mn: 'y' }],
gb: [],
};
// Generic arrays use wholesale SET for added elements.
// Template diffing only applies to known query builder arrays.
expect(computeDiff(base, query)).toStrictEqual([
[DiffCode.Set, ['ds'], 'traces'],
[DiffCode.Set, ['ag', 0, 'ao'], 'sum'],
[DiffCode.Set, ['ag', 1], { mn: 'y' }],
]);
});
});
describe('diffArrays', () => {
it('defaults the path to [] and diffs element-wise', () => {
expect(diffArrays([1, 2], [1, 9])).toStrictEqual([[DiffCode.Set, [1], 9]]);
});
it('Sets appended elements at their new index', () => {
expect(diffArrays([1], [1, 2, 3])).toStrictEqual([
[DiffCode.Set, [1], 2],
[DiffCode.Set, [2], 3],
]);
});
it('Deletes trailing elements removed from the query', () => {
expect(diffArrays([1, 2, 3], [1])).toStrictEqual([
[DiffCode.Delete, [1], undefined],
[DiffCode.Delete, [2], undefined],
]);
});
it('prefixes the supplied path onto every op', () => {
expect(diffArrays([1], [2], ['items'])).toStrictEqual([
[DiffCode.Set, ['items', 0], 2],
]);
});
});
describe('template diffing for query builder arrays', () => {
const baseQuery = { qn: 'A', aggOp: 'count', ds: 'metrics' };
it('uses template for builder.queryData path', () => {
const base = [baseQuery];
const query = [baseQuery, { qn: 'B', aggOp: 'avg', ds: 'metrics' }];
const ops = diffArrays(base, query, ['builder', 'queryData']);
// Should diff query[1] against query[0], not wholesale SET
expect(ops).toStrictEqual([
[DiffCode.Set, ['builder', 'queryData', 1, 'qn'], 'B'],
[DiffCode.Set, ['builder', 'queryData', 1, 'aggOp'], 'avg'],
]);
});
it('uses template for builder.queryFormulas path', () => {
const baseFormula = { qn: 'F1', expression: 'A', disabled: false };
const base = [baseFormula];
const query = [
baseFormula,
{ qn: 'F2', expression: 'A+B', disabled: false },
];
const ops = diffArrays(base, query, ['builder', 'queryFormulas']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['builder', 'queryFormulas', 1, 'qn'], 'F2'],
[DiffCode.Set, ['builder', 'queryFormulas', 1, 'expression'], 'A+B'],
]);
});
it('uses template for promql path', () => {
const baseProm = { name: 'A', query: 'up', legend: '', disabled: false };
const base = [baseProm];
const query = [
baseProm,
{ name: 'B', query: 'down', legend: '', disabled: false },
];
const ops = diffArrays(base, query, ['promql']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['promql', 1, 'name'], 'B'],
[DiffCode.Set, ['promql', 1, 'query'], 'down'],
]);
});
it('uses template for clickhouse_sql path', () => {
const baseCh = { name: 'A', query: 'SELECT 1', legend: '', disabled: false };
const base = [baseCh];
const query = [
baseCh,
{ name: 'B', query: 'SELECT 2', legend: '', disabled: false },
];
const ops = diffArrays(base, query, ['clickhouse_sql']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['clickhouse_sql', 1, 'name'], 'B'],
[DiffCode.Set, ['clickhouse_sql', 1, 'query'], 'SELECT 2'],
]);
});
it('does NOT use template for unknown paths', () => {
const base = [{ a: 1 }];
const query = [{ a: 1 }, { a: 2 }];
const ops = diffArrays(base, query, ['unknown', 'path']);
// Should emit wholesale SET, not field-level diff
expect(ops).toStrictEqual([
[DiffCode.Set, ['unknown', 'path', 1], { a: 2 }],
]);
});
it('emits DELETE for fields removed vs template', () => {
const base = [{ qn: 'A', aggOp: 'count', extra: 'field' }];
const query = [base[0], { qn: 'B', aggOp: 'avg' }]; // no 'extra'
const ops = diffArrays(base, query, ['builder', 'queryData']);
expect(ops).toContainEqual([
DiffCode.Delete,
['builder', 'queryData', 1, 'extra'],
undefined,
]);
});
});
describe('diffObjects', () => {
it('defaults the path to [] and diffs by own keys', () => {
expect(diffObjects({ a: 1 }, { a: 2 })).toStrictEqual([
[DiffCode.Set, ['a'], 2],
]);
});
it('prefixes the supplied path onto every op', () => {
expect(diffObjects({ a: 1 }, { a: 2 }, ['root'])).toStrictEqual([
[DiffCode.Set, ['root', 'a'], 2],
]);
});
});
describe('diffNodes shape transitions', () => {
it('replaces a leaf with a container wholesale', () => {
expect(diffNodes('a', { b: 1 })).toStrictEqual([
[DiffCode.Set, [], { b: 1 }],
]);
});
it('replaces a container with a leaf wholesale', () => {
expect(diffNodes({ b: 1 }, 'a')).toStrictEqual([[DiffCode.Set, [], 'a']]);
});
it('walks empty-to-non-empty array element-wise (for prefix substitution)', () => {
expect(diffNodes([], [1])).toStrictEqual([[DiffCode.Set, [0], 1]]);
});
it('emits SET [] when clearing a non-empty array (preserves empty array)', () => {
expect(diffNodes([1], [])).toStrictEqual([[DiffCode.Set, [], []]]);
expect(diffNodes([1, 2, 3], [])).toStrictEqual([[DiffCode.Set, [], []]]);
});
it('diffs array-vs-object key-wise (indices become string keys)', () => {
expect(diffNodes([1, 2], { 0: 'a' })).toStrictEqual([
[DiffCode.Set, ['0'], 'a'],
[DiffCode.Delete, ['1'], undefined],
]);
});
});
describe('undefined data', () => {
it('does not diff undefined against undefined', () => {
expect(computeDiff(undefined, undefined)).toStrictEqual([]);
expect(computeDiff({ a: undefined }, { a: undefined })).toStrictEqual([]);
});
it('Sets a real value over a baseline undefined', () => {
expect(computeDiff({ a: undefined }, { a: 1 })).toStrictEqual([
[DiffCode.Set, ['a'], 1],
]);
});
it('Sets undefined over a baseline value', () => {
expect(computeDiff({ a: 1 }, { a: undefined })).toStrictEqual([
[DiffCode.Set, ['a'], undefined],
]);
});
it('never throws when either whole input is undefined', () => {
expect(() => computeDiff(undefined, { a: 1 })).not.toThrow();
expect(() => computeDiff({ a: 1 }, undefined)).not.toThrow();
expect(computeDiff(undefined, { a: 1 })).toStrictEqual([
[DiffCode.Set, [], { a: 1 }],
]);
});
});
describe('unsupported / non-JSON values', () => {
it('treats functions as leaves and never throws', () => {
expect(() => computeDiff({ fn: noop }, { fn: noop })).not.toThrow();
// Two functions both serialize to `undefined`, so they look equal.
expect(computeDiff({ fn: noop }, { fn: noop })).toStrictEqual([]);
});
it('Sets a function over a scalar (treated as a differing leaf)', () => {
const ops = computeDiff({ a: 1 }, { a: noop });
expect(ops).toHaveLength(1);
expect(ops[0][0]).toBe(DiffCode.Set);
expect(ops[0][1]).toStrictEqual(['a']);
});
it('does not throw on NaN / Infinity leaves', () => {
expect(() => computeDiff({ a: NaN }, { a: Infinity })).not.toThrow();
// Both stringify to "null", so the diff cannot tell them apart.
expect(computeDiff({ a: NaN }, { a: Infinity })).toStrictEqual([]);
});
});
describe('prototype-pollution hardening', () => {
afterEach(() => {
// Guard against the test itself leaking pollution into later suites.
delete (Object.prototype as Record<string, unknown>).polluted;
});
it('skips a JSON-injected own __proto__ key (emits no op for it)', () => {
const malicious = JSON.parse(
'{"safe":2,"__proto__":{"polluted":true}}',
) as Record<string, unknown>;
// Base must be a non-empty object so both sides reach diffObjects;
// an empty `{}` is a leaf and would collapse to a wholesale Set.
const ops = computeDiff({ safe: 1 }, malicious);
expect(paths(ops)).toStrictEqual(['safe']);
expect(paths(ops)).not.toContain('__proto__');
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it('skips own constructor and prototype keys', () => {
const ops = diffObjects({}, {
constructor: 'x',
prototype: 'y',
safe: 1,
} as Record<string, unknown>);
expect(paths(ops)).toStrictEqual(['safe']);
});
it('emits no Delete op when the baseline carries a forbidden key', () => {
const ops = diffObjects({ constructor: 'x' } as Record<string, unknown>, {});
expect(ops).toStrictEqual([]);
});
it('skips a nested __proto__ key reached via recursion', () => {
const malicious = JSON.parse(
'{"a":{"keep":1,"__proto__":{"polluted":true}}}',
) as Record<string, unknown>;
const ops = computeDiff({ a: { keep: 1 } }, malicious);
expect(ops).toStrictEqual([]);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
describe('op-list invariants', () => {
it('produces a unique path per op (order-independent list)', () => {
const base = { a: 1, b: [1, 2, 3], c: { d: 4 } };
const query = { a: 9, b: [1], c: { d: 4, e: 5 }, f: 6 };
const list = paths(computeDiff(base, query));
expect(new Set(list).size).toBe(list.length);
});
});
});

View File

@@ -0,0 +1,89 @@
import { isContainer, isEmptyContainer, isLeaf } from '../predicates';
const noop = (): void => undefined;
describe('qsAlias/diff predicates', () => {
describe('isContainer', () => {
it('is true for plain objects and arrays', () => {
expect(isContainer({})).toBe(true);
expect(isContainer({ a: 1 })).toBe(true);
expect(isContainer([])).toBe(true);
expect(isContainer([1, 2])).toBe(true);
});
it('is false for null and undefined', () => {
expect(isContainer(null)).toBe(false);
expect(isContainer(undefined)).toBe(false);
});
it('is false for scalars', () => {
expect(isContainer('')).toBe(false);
expect(isContainer('str')).toBe(false);
expect(isContainer(0)).toBe(false);
expect(isContainer(42)).toBe(false);
expect(isContainer(NaN)).toBe(false);
expect(isContainer(true)).toBe(false);
expect(isContainer(false)).toBe(false);
});
it('is false for functions and symbols', () => {
expect(isContainer(noop)).toBe(false);
expect(isContainer(Symbol('x'))).toBe(false);
});
it('is true for exotic objects like Date (typeof object)', () => {
expect(isContainer(new Date(0))).toBe(true);
});
});
describe('isEmptyContainer', () => {
it('is true only for [] and {}', () => {
expect(isEmptyContainer([])).toBe(true);
expect(isEmptyContainer({})).toBe(true);
});
it('is false for non-empty containers', () => {
expect(isEmptyContainer([1])).toBe(false);
expect(isEmptyContainer({ a: 1 })).toBe(false);
});
it('is false for scalars, null and undefined', () => {
expect(isEmptyContainer(null)).toBe(false);
expect(isEmptyContainer(undefined)).toBe(false);
expect(isEmptyContainer('')).toBe(false);
expect(isEmptyContainer(0)).toBe(false);
});
it('treats objects with only non-enumerable keys (Date) as empty', () => {
// Date has no own *enumerable* keys, so Object.keys() is empty.
expect(isEmptyContainer(new Date(0))).toBe(true);
});
});
describe('isLeaf', () => {
it('is true for every scalar', () => {
['', 'str', 0, 1, -1, 3.14, true, false].forEach((value) => {
expect(isLeaf(value)).toBe(true);
});
});
it('is true for null and undefined', () => {
expect(isLeaf(null)).toBe(true);
expect(isLeaf(undefined)).toBe(true);
});
it('is true for empty containers', () => {
expect(isLeaf([])).toBe(true);
expect(isLeaf({})).toBe(true);
});
it('is false for non-empty containers', () => {
expect(isLeaf([1])).toBe(false);
expect(isLeaf({ a: 1 })).toBe(false);
});
it('counts a key whose value is undefined as non-empty (not a leaf)', () => {
expect(isLeaf({ a: undefined })).toBe(false);
});
});
});

View File

@@ -0,0 +1,178 @@
import { Json, PathSeg } from './predicates';
export const DiffCode = {
Set: 1,
Delete: 2,
} as const;
export type DiffCodeValue = (typeof DiffCode)[keyof typeof DiffCode];
/**
* A single diff operation: `[code, path, value]`. `value` is `undefined` for deletes.
*/
export type DiffOp = [code: DiffCodeValue, path: PathSeg[], value: Json];
/**
* Keys that must never reach a downstream `set`/rebuild step. Walking these
* would let a crafted query poison `Object.prototype`. They are skipped on both
* sides of the diff, so neither a SET nor a DELETE op is ever produced for them.
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* Array paths that use template-based diffing (added elements diff against [0]).
* These are query builder arrays where added items are structurally similar.
*/
const TEMPLATE_ARRAY_PATHS = [
['builder', 'queryData'],
['builder', 'queryFormulas'],
['builder', 'queryTraceOperator'],
['promql'],
['clickhouse_sql'],
];
function isTemplateArrayPath(path: PathSeg[]): boolean {
return TEMPLATE_ARRAY_PATHS.some(
(pattern) =>
pattern.length === path.length && pattern.every((seg, i) => seg === path[i]),
);
}
const hasOwn = (obj: object, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);
const leavesEqual = (a: Json, b: Json): boolean =>
JSON.stringify(a) === JSON.stringify(b);
/**
* Diff two arrays element-wise.
* Extra query items are SET; missing ones DELETE.
* Special case: if query is empty but baseline isn't, emit a single SET of `[]`
* rather than individual DELETEs, so the empty array survives the round-trip.
*
* For known query builder arrays (queryData, queryFormulas, etc.), added elements
* diff against baseArr[0] as template to minimize output size.
*/
export function diffArrays(
baseArr: Json[],
queryArr: Json[],
path: PathSeg[] = [],
): DiffOp[] {
// If query is empty but baseline has elements, emit SET of [] to preserve it.
if (queryArr.length === 0 && baseArr.length > 0) {
return [[DiffCode.Set, path, []]];
}
// Use template diffing for known query builder arrays
const useTemplate = isTemplateArrayPath(path) && baseArr.length > 0;
const template = useTemplate ? baseArr[0] : undefined;
const ops: DiffOp[] = [];
const maxLen = Math.max(baseArr.length, queryArr.length);
for (let i = 0; i < maxLen; i += 1) {
const segPath = [...path, i];
if (i >= queryArr.length) {
ops.push([DiffCode.Delete, segPath, undefined]);
} else if (i >= baseArr.length) {
// Use template diffing if available, otherwise wholesale SET
if (template !== undefined) {
ops.push(...diffNodes(template, queryArr[i], segPath));
} else {
ops.push([DiffCode.Set, segPath, queryArr[i]]);
}
} else {
ops.push(...diffNodes(baseArr[i], queryArr[i], segPath));
}
}
return ops;
}
/**
* Diff two plain objects by own keys. Forbidden keys are skipped entirely.
* Special case: if query is empty but baseline isn't, emit a single SET of `{}`
* rather than individual DELETEs, so the empty object survives the round-trip.
*/
export function diffObjects(
baseObj: Record<string, Json>,
queryObj: Record<string, Json>,
path: PathSeg[] = [],
): DiffOp[] {
const baseKeys = Object.keys(baseObj).filter((k) => !FORBIDDEN_KEYS.has(k));
const queryKeys = Object.keys(queryObj).filter((k) => !FORBIDDEN_KEYS.has(k));
// If query is empty but baseline has keys, emit SET of {} to preserve it.
if (queryKeys.length === 0 && baseKeys.length > 0) {
return [[DiffCode.Set, path, {}]];
}
const ops: DiffOp[] = [];
const allKeys = new Set([...baseKeys, ...queryKeys]);
for (const key of allKeys) {
const segPath = [...path, key];
if (!hasOwn(queryObj, key)) {
ops.push([DiffCode.Delete, segPath, undefined]);
} else if (!hasOwn(baseObj, key)) {
ops.push([DiffCode.Set, segPath, queryObj[key]]);
} else {
ops.push(...diffNodes(baseObj[key], queryObj[key], segPath));
}
}
return ops;
}
/**
* Diff any two nodes, dispatching on their shape.
*/
export function diffNodes(
baseline: Json,
query: Json,
path: PathSeg[] = [],
): DiffOp[] {
const baseIsArray = Array.isArray(baseline);
const queryIsArray = Array.isArray(query);
const baseIsObj =
typeof baseline === 'object' && baseline !== null && !baseIsArray;
const queryIsObj =
typeof query === 'object' && query !== null && !queryIsArray;
// Both arrays: walk element-wise even if one is empty. This ensures paths
// like `['builder', 'queryFormulas', 0, ...]` are emitted (not a wholesale
// SET on the array itself), which is required for prefix substitution.
if (baseIsArray && queryIsArray) {
return diffArrays(baseline, query, path);
}
// Both plain objects (including empty ones): walk key-wise.
if (baseIsObj && queryIsObj) {
return diffObjects(
baseline as Record<string, Json>,
query as Record<string, Json>,
path,
);
}
// Both scalars (non-containers): emit a SET only when they differ.
if (!baseIsArray && !baseIsObj && !queryIsArray && !queryIsObj) {
return leavesEqual(baseline, query) ? [] : [[DiffCode.Set, path, query]];
}
// Mixed container types (array-vs-object): walk key-wise, treating array
// indices as string keys. This is an edge case but preserves intent.
if ((baseIsArray || baseIsObj) && (queryIsArray || queryIsObj)) {
return diffObjects(
baseline as Record<string, Json>,
query as Record<string, Json>,
path,
);
}
// True shape mismatch: scalar vs container → replace wholesale.
return [[DiffCode.Set, path, query]];
}
/**
* Entry point: diff a baseline against a query, rooted at the empty path.
*/
export function computeDiff(baseline: Json, query: Json): DiffOp[] {
return diffNodes(baseline, query, []);
}

View File

@@ -0,0 +1,21 @@
/**
* Value-shape predicates shared by the diff algorithm and the codec's leaf
* walker. A "leaf" is anything the serializer emits as a single token: a
* scalar (string/number/boolean/null/undefined), or an *empty* container
* (`[]` / `{}`). Non-empty containers are walked recursively.
*/
export type Json = unknown;
export type PathSeg = string | number;
export const isContainer = (
value: Json,
): value is Record<string, Json> | Json[] =>
typeof value === 'object' && value !== null;
export const isEmptyContainer = (value: Json): boolean =>
isContainer(value) &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
export const isLeaf = (value: Json): boolean =>
!isContainer(value) || isEmptyContainer(value);

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