mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-03 11:40:40 +01:00
Compare commits
18 Commits
settings-e
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6c27ec5f | ||
|
|
9d919e166b | ||
|
|
8c86885090 | ||
|
|
e7be5ee17d | ||
|
|
49c11f51ac | ||
|
|
0c35a8f6e5 | ||
|
|
2c076a3d50 | ||
|
|
086040799c | ||
|
|
bdb0091c87 | ||
|
|
5198530056 | ||
|
|
6a4e694a34 | ||
|
|
bd9a6cc17d | ||
|
|
51f180453e | ||
|
|
2ad5cb19c3 | ||
|
|
5d711377ec | ||
|
|
c5d0fd8966 | ||
|
|
ed04ff09ff | ||
|
|
e1e9d516ac |
@@ -1357,6 +1357,14 @@ components:
|
||||
- appservice
|
||||
- containerapp
|
||||
- aks
|
||||
- sqldatabase
|
||||
- sqldatabasemi
|
||||
- mysqlflexibleserver
|
||||
- postgresqlflexibleserver
|
||||
- mongodb
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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`
|
||||
|
||||
513
frontend/docs/css-modules-guide.md
Normal file
513
frontend/docs/css-modules-guide.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
144
frontend/plugins/rules/no-css-module-bracket-access.mjs
Normal file
144
frontend/plugins/rules/no-css-module-bracket-access.mjs
Normal 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('');
|
||||
}
|
||||
@@ -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
715
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
24
frontend/src/api/saveView/getViewById.ts
Normal file
24
frontend/src/api/saveView/getViewById.ts
Normal 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}`);
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -18,7 +18,6 @@ export enum QueryParams {
|
||||
q = 'q',
|
||||
activeLogId = 'activeLogId',
|
||||
timeRange = 'timeRange',
|
||||
compositeQuery = 'compositeQuery',
|
||||
panelTypes = 'panelTypes',
|
||||
pageSize = 'pageSize',
|
||||
viewMode = 'viewMode',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?',
|
||||
},
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
74
frontend/src/container/AIAssistant/resolvePageType.ts
Normal file
74
frontend/src/container/AIAssistant/resolvePageType.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -29,3 +29,7 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
body.ai-assistant-panel-open .create-alert-v2-footer {
|
||||
right: var(--ai-assistant-panel-width, 380px);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
@@ -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'] = [
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
.right-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -68,10 +68,6 @@
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
|
||||
.new-view-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
|
||||
170
frontend/src/hooks/__tests__/useSafeNavigate.utils.test.ts
Normal file
170
frontend/src/hooks/__tests__/useSafeNavigate.utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
23
frontend/src/hooks/aiAssistant/useResolvePageType.ts
Normal file
23
frontend/src/hooks/aiAssistant/useResolvePageType.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
frontend/src/hooks/useSafeNavigate.utils.ts
Normal file
103
frontend/src/hooks/useSafeNavigate.utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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": "",
|
||||
}
|
||||
`;
|
||||
121
frontend/src/lib/compositeQuery/__tests__/baseline.test.ts
Normal file
121
frontend/src/lib/compositeQuery/__tests__/baseline.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal file
51
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
63
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal file
63
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
74
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal file
74
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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="`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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="`;
|
||||
@@ -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="`;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
302
frontend/src/lib/compositeQuery/adapters/qsAlias/codec.ts
Normal file
302
frontend/src/lib/compositeQuery/adapters/qsAlias/codec.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
frontend/src/lib/compositeQuery/adapters/qsAlias/diff/diff.ts
Normal file
178
frontend/src/lib/compositeQuery/adapters/qsAlias/diff/diff.ts
Normal 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, []);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user