Compare commits

...

56 Commits

Author SHA1 Message Date
Srikanth Chekuri
d4384eba1b Merge branch 'main' into nv/dashboard-migration 2026-07-08 20:04:56 +05:30
Gaurav Tewari
b1dcbe87fe feat(llm-attribute-mapping): foundation — route, permission, page shell [1/5] (#11778)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* feat(llm-pricing): add listing page and table

* chore(llm-pricing): drop search + source filters from list request

The list API does not honour the q (search) and source params yet, so
the controls did nothing. Remove the search input and source dropdown
along with the params we sent, and trim useModelPricingFilters to the
URL-backed page state that pagination still needs. Currency dropdown,
tabs, table and pagination are unchanged. Filters will return once the
backend supports them.

* refactor(llm-pricing): extract getRelativeTime helper in utils

Pull the relative-time formatting out of getRelativeLastSeen into a
small local getRelativeTime helper. Kept feature-local (not in the
shared utils/timeUtils) so the LLM pricing module owns its own dayjs
config; the local relativeTime extend stays for test self-sufficiency.

* refactor(llm-pricing): drop dead NaN guard in formatPricePerMillion

Pricing fields are typed as required numbers and JSON can't carry NaN,
so Number.isNaN was unreachable. Keep the null/undefined guard as API
defensiveness (toFixed on a missing value would crash the row). Also
trims the now-redundant dayjs.extend comment.

* refactor(llm-pricing): centralize constants and shared types

Extract PAGE_SIZE, PAGE_KEY, COLUMN_COUNT and CURRENCY_OPTIONS into a
new constants.ts, and move the ModelPricingFilters contract into
types.ts. Component prop interfaces stay colocated with their
components, matching the convention in the drawer PR.

* refactor(llm-pricing): use nuqs for list pagination URL state

Replace the hand-rolled useHistory + URLSearchParams plumbing in
useModelPricingFilters with nuqs useQueryState, matching the convention
used by the dashboards, alerts and k8s list pages. Behaviour is
unchanged: parseAsInteger.withDefault(1) keeps ?page=1 out of the URL
and history:'replace' avoids polluting the back-stack.

* refactor(llm-pricing): inline pagination, drop useModelPricingFilters

The hook had shrunk to a one-line nuqs wrapper after search/source were
removed, so inline the useQueryState call into the container and remove
the hook file plus the now-unused ModelPricingFilters type. When the
filters return (once the API honours them) they can move back into a
dedicated hook.

* feat(llm-pricing): disable currency selector (USD-only for now)

Only USD is priced today, so render the currency SelectSimple in a
disabled state pinned to USD. A disabled select can't fire onChange, so
the currency useState is dead — drop it (and the now-unused useState
import).

* refactor(llm-pricing): render model costs inside its tab + tab URL param

The listing was rendered outside the Tabs, so the tab was decorative.
Move all model-cost content (currency control, list query, table,
pagination, footer) into a ModelCostsTab component rendered as the
'Model costs' tab's children, and drive the active tab from a 'tab' URL
query param (nuqs). The container is now just the page shell. Unpriced
models stays a disabled placeholder for a later PR.

* style(llm-pricing): target @signozhq table slots, drop dead antd/leftover rules

The component uses @signozhq/ui Table/Tabs (Radix-based), not antd, so the
.ant-table-* and .ant-tabs-nav selectors never matched — the intended
uppercase/muted header styling wasn't applied. Retarget header/cell rules to
[data-slot='table-head'|'table-cell'] (no !important needed). Also remove dead
rules left over from the removed search/source/add UI (.filters-bar__search,
__source, __add, .page-header__actions) and the unused .source-badge--auto/
--override modifiers.

* fix(llm-pricing): constrain currency dropdown width, drop tab URL param

- Currency SelectSimple stretched to fill the filters bar; give it a fixed
  160px width (min-width couldn't cap the trigger).
- Model costs is the only enabled tab for now, so use Tabs defaultValue
  instead of a URL-backed param. Removes the nuqs tab state plus the now-unused
  TAB_KEYS/TAB_QUERY_KEY constants and TabKey type.

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

* feat(llm-pricing): add model cost drawer and wire into listing page

* fix(llm-pricing): restrict pricing management to admins

Align the frontend write gate with the backend, which protects the
LLM pricing create/update/delete endpoints with AdminAccess (admin
only). Previously manage_llm_pricing allowed EDITOR/AUTHOR, so those
roles saw the Add/Save affordances but their writes were rejected with
a 403. Also removes the AUTHOR entry, which could never reach the page
(the route gate excludes it).

* fix(llm-pricing): read-only drawer shows View title, hides source picker

Non-managers open the drawer in view mode (write APIs are Admin-only), so:
- the heading reads "View model cost" instead of "Edit model cost"
- the Source (auto vs. override) picker is hidden, since switching source is
  a manager-only action with nothing actionable for a viewer.

* refactor: form in edit / add modal

* chore: update color tokens

* fix: add error handling

* chore: update more self review changes

* chore: self review changes

* chore: self review changes

* fix: minor grammer thing

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* feat(llm-attribute-mapping): add attribute mapping foundation (route, permission, page shell)

* fix: css styling

* refactor: css module

* chore: remove comment

* fix: disable isDirty in case of llm pricing

* refactor: number

* feat: add search , dropdown and flag

* feat: feature flag on entire route and add mode costs tabs

* fix: add isFetchingFeatureFlags

* chore: update flag

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* chore: self review changes

* refactor: types and other things

* refactor: types and other things

* chore: add disable on source id

* empty commit

* chore: empty commit

* fix: add demo side nav on sidenav

* chore: remove demo side nav

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

* refactor(llm-pricing): break model-cost drawer into per-component files + tokens

Apply the CSS-module/component conventions to the drawer that came from
drawer-3:
- Move the drawer under ModelCostTabPanel/components/ModelCostDrawer/ to mirror
  the ModelCostsTable structure
- Split the single 395-LOC ModelCostDrawer.module.scss into per-component
  co-located modules; cross-component selectors live in shared.module.scss and
  are pulled in via CSS-modules `composes`
- shared.module.scss is a composes target (parsed as plain CSS), so it is kept
  flat with block comments — no SCSS nesting or // comments
- Use --text-vanilla-* (not --bg-vanilla-*) for text colors, matching the
  listing code

* refactor: more changes

* refactor: styling and components

* refactor: styling and components

* chore: add a tooltip on hover

* feat: add delete confirm modal

* fix: update title

* refactor: css variables

* refactor: use signoz button and minor css update

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: minior issues

* fix: llm pricing listing

* refactor: remove extra classes

* refactor: side nav changes

* fix: update missing styles

* chore: update edit and delete options

* chore: remove extra comment

* chore: revert env changes

* chore: add enable check

* chore: remove divider

* refactor: use delete confirm dialog

* chore: remove scss file

* feat: move ui to easily accessable tabs

* feat: update test cases

* chore: update text

* chore: self review changes

* chore: self review refactor

* chore: self review changes

* chore: remove worktree

* chore: revert env.ts

* chore: add attribute mapping foundation

* refactor: basic components

* chore: update env.ts

* chore: update tests

* chore: self review changes

* chore: update test cases

* chore: remove extra comments

* refactor(llm-pricing): share toast copy via constants

* chore: use constants

* chore: redclared constants

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-08 14:12:49 +00:00
Vinicius Lourenço
a36081b00c fix(query-builder): ensure contrast/border/colors are consistent (#11992)
* refactor(query-builder-v2): ensure query builder has consistent colors and allow customization

* fix(query-builder-v2-usages): ensure each place using query builder v2 has consistent colors

* fix(alert): revert l3 for v2 ui
2026-07-08 13:57:05 +00:00
Naman Verma
6151467b3e fix: add default aggregation count() if none is present in logs/traces 2026-07-07 11:18:49 +05:30
Naman Verma
daa999d1d6 Merge branch 'main' into nv/dashboard-migration 2026-07-07 10:10:53 +05:30
Naman Verma
b400846193 fix: ditch dynamic vars with no attr, rearrange overlapping layouts 2026-07-04 01:43:13 +05:30
Naman Verma
f5220d078f chore: add explanation comment for continuing on empty id widget 2026-07-04 00:16:12 +05:30
Naman Verma
ab750ffffd chore: better comments and names in layout migrator 2026-07-04 00:06:04 +05:30
Naman Verma
4a2b508636 chore: improve variable names 2026-07-03 23:52:07 +05:30
Naman Verma
33795999cf fix: match shape safe version to existing version 2026-07-03 23:38:59 +05:30
Naman Verma
9aac37eb94 fix: remove unused method 2026-07-03 22:48:31 +05:30
Naman Verma
12642e27da fix: validate dashboard spec after migration 2026-07-03 21:21:51 +05:30
Naman Verma
82f8bf88fc fix: add nil check on limit value when normalizing page size 2026-07-03 20:23:01 +05:30
Naman Verma
3befc9e8c6 fix: make none the default fill mode during migration 2026-07-03 20:22:36 +05:30
Naman Verma
ee5b85f7a4 revert: remove md file 2026-07-03 20:22:10 +05:30
Naman Verma
a05164f225 fix: fix go lint issue 2026-07-03 17:56:33 +05:30
Naman Verma
35e4013826 chore: reuse transition package (adds dependency, need to see what to do) 2026-07-03 17:40:50 +05:30
Naman Verma
5884c6aa90 Merge branch 'main' into nv/dashboard-migration 2026-07-03 16:38:22 +05:30
Naman Verma
533a430714 fix: add more malformation handling 2026-07-03 15:25:09 +05:30
Naman Verma
ba6af34714 fix: add more malformed query handling 2026-07-02 13:49:49 +05:30
Naman Verma
851c7b0ad7 chore: add temporary doc for other malformation handlers needed 2026-07-02 13:19:51 +05:30
Naman Verma
ef5a67495c fix: normalize telemetry field keys in query 2026-07-02 12:58:14 +05:30
Naman Verma
9f540ca84b fix: remove metric aggregation from non metric queries (malformed json) 2026-07-02 12:25:57 +05:30
Naman Verma
40a6b22aed fix: normalize having array from v4 schema 2026-07-02 11:38:37 +05:30
Naman Verma
6f16416f27 fix: add better error note down for failed conversions 2026-07-02 11:10:02 +05:30
Naman Verma
f8aa1c1c34 Merge branch 'main' into nv/dashboard-migration 2026-07-02 10:52:00 +05:30
Naman Verma
65835394c0 Merge branch 'main' into nv/dashboard-migration 2026-06-30 18:39:53 +05:30
Naman Verma
f132b7e53a fix: handle list of widget IDs in panel maps 2026-06-29 23:51:55 +05:30
Naman Verma
d4ae156dc4 fix: make threshold value non-nullable 2026-06-29 23:18:17 +05:30
Naman Verma
d6bdf9c2b2 fix: catch error from decodeTelemetryFields 2026-06-29 23:02:40 +05:30
Naman Verma
7ea654f1aa test: fix unit tests 2026-06-29 22:57:40 +05:30
Naman Verma
3fd7d013a1 fix: adjust to schema changes in variables 2026-06-29 22:56:36 +05:30
Naman Verma
fb921dd381 fix: read threshold value properly during migration 2026-06-29 22:56:12 +05:30
Naman Verma
58020d9e00 fix: note error in query parsing 2026-06-29 22:53:40 +05:30
Naman Verma
7a5933e822 fix: ignore react placeholders in layout 2026-06-29 22:52:48 +05:30
Naman Verma
2533683de6 fix: allow zero value for threshold values 2026-06-29 22:51:56 +05:30
Naman Verma
2670d53170 Merge branch 'main' into nv/dashboard-migration 2026-06-29 20:28:39 +05:30
Naman Verma
8943a9454b Merge branch 'main' into nv/dashboard-migration 2026-06-26 02:15:22 +05:30
Naman Verma
9a7ed5b711 feat: note down all errors in migration 2026-06-26 02:07:06 +05:30
Naman Verma
2d75e3d32d chore: separate error type for migration 2026-06-26 01:07:59 +05:30
Naman Verma
1d6eabf927 chore: remove spec md file 2026-06-25 22:37:52 +05:30
Naman Verma
082d7b1b77 test: fix ut to check for v2 internal name 2026-06-25 22:34:39 +05:30
Naman Verma
5019dee2d7 Merge branch 'main' into nv/dashboard-migration 2026-06-25 22:30:55 +05:30
Naman Verma
216de973fb fix: remove nil nil return 2026-06-25 03:07:58 +05:30
Naman Verma
18c0eec5e2 chore: catch typecast errors 2026-06-19 15:49:01 +05:30
Naman Verma
2ccdeb3631 chore: add a catch all panic check to log migration error 2026-06-19 15:15:17 +05:30
Naman Verma
ad12e50bbc fix: extract row and widget positions to build expanded sections 2026-06-19 15:00:32 +05:30
Naman Verma
e247bf3864 fix: sanitize tags instead of throwing error 2026-06-19 14:17:36 +05:30
Naman Verma
f4651ea134 fix: match with lower case signal for variables 2026-06-19 12:17:42 +05:30
Naman Verma
d449a2dbf2 fix: generate internal name from title 2026-06-19 11:24:40 +05:30
Naman Verma
d4b9f91062 Merge branch 'main' into nv/dashboard-migration 2026-06-18 12:28:22 +05:30
Naman Verma
530710b7bc Merge branch 'nv/dashboard-migration' of https://github.com/SigNoz/signoz into nv/dashboard-migration 2026-06-17 12:42:24 +05:30
Naman Verma
4fb5eec08d fix: move WrapInV5Envelope to types package 2026-06-17 12:42:20 +05:30
Naman Verma
f889d36f0f Merge branch 'main' into nv/dashboard-migration 2026-06-17 12:32:08 +05:30
Naman Verma
db12d44523 Merge branch 'main' into nv/dashboard-migration 2026-06-17 07:30:34 +05:30
Naman Verma
86fc0e81ba chore: add migration script from current to perses dashboard 2026-06-14 22:58:08 +05:30
50 changed files with 4429 additions and 316 deletions

View File

@@ -2682,7 +2682,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3649,7 +3648,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3686,7 +3684,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value

View File

@@ -513,6 +513,13 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
exact: true,

View File

@@ -3395,7 +3395,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -3923,7 +3922,6 @@ export interface DashboardtypesComparisonThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -4212,7 +4210,6 @@ export interface DashboardtypesTableThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}

View File

@@ -20,9 +20,9 @@
padding: 0px 8px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--l2-foreground);
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
display: flex;
justify-content: flex-start;
@@ -35,21 +35,54 @@
min-width: 150px;
font-family: 'Space Mono', monospace !important;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
--input-border-radius: 0px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
border-right: none;
border-left: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
font-size: 12px !important;
line-height: 27px;
line-height: 25px;
position: relative;
&:hover,
&:focus {
z-index: 1;
}
.ant-select-selector {
position: relative;
border-radius: inherit;
}
.ant-select:hover .ant-select-selector,
.ant-select-focused .ant-select-selector {
z-index: 1;
}
&.input__has-label-after {
margin-left: -1px;
.ant-select-selector {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.input__has-close-button {
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
~ .close-btn {
margin-left: -1px;
}
}
&::placeholder {
color: var(--l2-foreground) !important;
color: var(--input-with-label-color, var(--l3-foreground)) !important;
font-size: 12px !important;
}
&[type='number']::-webkit-inner-spin-button,
@@ -63,25 +96,35 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
height: 100%;
width: 38px;
position: relative;
&:hover,
&:focus {
z-index: 2;
}
}
&.labelAfter {
.input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 2px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.label {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-radius: 0px 2px 2px 0px;
}
}
}

View File

@@ -45,7 +45,10 @@ function InputWithLabel({
>
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
<Input
className="input"
className={cx('input', {
'input__has-label-after': !labelAfter,
'input__has-close-button': !!onClose,
})}
placeholder={placeholder}
type={type}
value={inputValue}

View File

@@ -80,8 +80,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -101,7 +101,8 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted var(--l1-border);
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
}
/* Horizontal line pointing from vertical to the item */
@@ -114,8 +115,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -241,7 +242,8 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted var(--l1-border);
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
}
/* Horizontal line pointing from vertical to the item */
@@ -254,8 +256,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -273,6 +275,16 @@
line-height: 16px; /* 128.571% */
resize: none;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
.formula-legend {
@@ -282,15 +294,42 @@
.ant-input-group-addon {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
background: var(--l2-background);
color: var(--l2-foreground);
background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
color: var(--query-builder-v2-color, var(--l2-foreground));
font-size: 12px;
font-weight: 300;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
}
.ant-input {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
height: 36px;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
margin-left: -1px;
&:hover,
&:focus {
z-index: 1;
border-color: var(--internal-ant-border-color-hover);
}
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
}
}
@@ -323,8 +362,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -395,8 +434,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -412,7 +451,7 @@
min-width: 120px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
@@ -457,13 +496,16 @@
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background) !important;
height: 34px !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
height: 36px !important;
box-sizing: border-box !important;
.ant-select-selection-item {
color: var(--l1-foreground);
color: var(--query-builder-v2-color, var(--l1-foreground));
}
}

View File

@@ -92,6 +92,11 @@
.ant-select {
width: 100%;
.ant-select-selector {
min-height: 36px;
}
.ant-select-selection-search-input {
min-width: max-content !important;
max-width: 100% !important;
@@ -100,9 +105,12 @@
.ant-select-selector {
border-radius: 2px;
border: 1.005px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -123,6 +131,7 @@
.input {
flex: initial;
width: 100px !important;
min-height: 36px;
}
}
}

View File

@@ -8,9 +8,9 @@
.ant-select-selection-search-input {
font-size: 12px !important;
line-height: 27px;
line-height: 25px;
&::placeholder {
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
font-size: 12px !important;
}
}
@@ -22,9 +22,12 @@
.ant-select-selector {
width: 100%;
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background);
color: var(--l1-foreground);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -33,36 +36,49 @@
min-height: 36px;
.ant-select-selection-placeholder {
color: var(--l2-foreground) !important;
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
font-size: 12px !important;
}
}
}
.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.qb-select-popover.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.ant-select-item {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
.ant-select-item {
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
&:hover,
&.ant-select-item-option-active {
background: var(--l3-background) !important;
}
&:not(:last-of-type) {
margin-bottom: 4px;
}
&.ant-select-item-option-selected {
background: var(--l3-background) !important;
border: 1px solid var(--l1-border);
font-weight: 600;
}
&:hover,
&.ant-select-item-option-active {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
}
&.ant-select-item-option-selected {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
font-weight: 600;
}
}
}

View File

@@ -142,6 +142,7 @@ export const MetricsSelect = memo(function MetricsSelect({
{signalSourceChangeEnabled && (
<Select
className="source-selector"
popupClassName="qb-select-popover"
placeholder="Source"
options={SOURCE_OPTIONS}
value={source}

View File

@@ -1,8 +1,23 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
.query-add-ons {
width: 100%;
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
.add-on-tab-title {
display: flex;
align-items: center;
@@ -29,32 +44,33 @@
font-style: normal;
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
}
> button {
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left: none;
min-width: 120px;
height: 36px;
line-height: 36px;
&:first-child {
border-left: 1px solid var(--l1-border);
border-left: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
}
&::before {
background: var(--l1-border);
background: var(--query-builder-v2-border-color, var(--l2-border));
}
&[data-state='on'] {
color: var(--text-robin-500);
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
display: none;
&::before {
background: var(--l1-border);
background: var(--query-builder-v2-border-color, var(--l2-border));
}
}
}
@@ -65,7 +81,7 @@
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
@@ -78,10 +94,13 @@
align-items: center;
.having-filter-select-container {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
background: var(--query-builder-v2-background-color, var(--l2-background));
padding-right: 38px;
.having-filter-select-editor {
border-radius: 2px;
@@ -106,15 +125,17 @@
}
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left-width: 0px;
border-right-width: 0px;
padding: 0px !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
@@ -218,17 +239,32 @@
}
.cm-line {
line-height: 36px !important;
min-height: 34px;
line-height: 32px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
@@ -237,8 +273,11 @@
}
.chip-decorator {
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -246,34 +285,38 @@
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
.cm-activeLine > span {
font-size: 12px !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
}
.close-btn {
position: absolute;
top: 0;
right: 0;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {
border-color: var(--l2-border);
border-left-color: transparent;
outline: none;
box-shadow: none;
}
}
}
}
@@ -300,20 +343,8 @@
font-size: 12px !important;
}
$add-on-row-height: 38px;
.periscope-input-with-label {
.input {
.ant-select {
height: $add-on-row-height;
}
}
}
.input-with-label {
.input {
height: $add-on-row-height;
}
input {
min-height: 36px;
}
}
}

View File

@@ -23,7 +23,7 @@
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
&.error {
.cm-editor {
@@ -51,14 +51,15 @@
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
padding: 0px !important;
background-color: var(--l1-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
@@ -74,7 +75,7 @@
right: 0px !important;
border-radius: 4px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
@@ -118,7 +119,7 @@
box-sizing: border-box;
overflow: hidden;
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
font-family: 'Space Mono', monospace !important;
.cm-completionIcon {
@@ -127,7 +128,10 @@
&:hover,
&[aria-selected='true'] {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
color: var(--l1-foreground) !important;
font-weight: 600 !important;
}
@@ -142,15 +146,24 @@
.cm-line {
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
@@ -159,8 +172,11 @@
}
.chip-decorator {
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l1-foreground)) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -168,7 +184,10 @@
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
}
@@ -201,12 +220,11 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
height: 38px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
@@ -217,13 +235,13 @@
height: 36px;
line-height: 36px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
font-family: 'Space Mono', monospace !important;
&::placeholder {
color: var(--l1-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
opacity: 0.5;
}
}
@@ -238,9 +256,10 @@
.query-aggregation-interval-input-container {
.query-aggregation-interval-input {
input {
min-height: 36px;
max-width: 120px;
&::placeholder {
color: var(--l2-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
}
}
}
@@ -251,8 +270,8 @@
.query-aggregation-error-popover {
.ant-popover-inner {
background-color: var(--l1-border);
border: 1px solid var(--l1-border);
background-color: var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: 4px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
}

View File

@@ -1,7 +1,7 @@
.add-trace-operator-button,
.add-new-query-button,
.add-formula-button {
border: 1px solid var(--l1-border);
background: var(--l2-background);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -40,11 +40,14 @@ $max-recents-shown: 5;
.query-status-container {
width: 32px;
background-color: var(--l1-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: 2px;
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
@@ -83,16 +86,16 @@ $max-recents-shown: 5;
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
padding: 0px !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
&.cm-focused {
outline: 1px solid var(--l1-border);
outline: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
}
.cm-tooltip-autocomplete {
@@ -183,11 +186,17 @@ $max-recents-shown: 5;
font-family: 'Space Mono', monospace !important;
background-color: var(--l1-background) !important;
color: var(--l2-foreground) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
&:hover {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
}
.cm-completionIcon {
@@ -205,7 +214,10 @@ $max-recents-shown: 5;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
font-weight: 600 !important;
}
}
@@ -274,25 +286,49 @@ $max-recents-shown: 5;
}
.cm-line {
line-height: 34px !important;
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
.cursor-position {

View File

@@ -65,6 +65,14 @@
display: flex;
flex-direction: column;
gap: 8px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
}
&-aggregation-container {

View File

@@ -89,6 +89,7 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',

View File

@@ -41,6 +41,28 @@
.steps-container {
width: 80%;
.alert-query-section-container {
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
}
}
.qb-chart-preview-container {

View File

@@ -3,6 +3,30 @@
overflow-x: auto;
overflow-y: hidden;
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
--input-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-border-color: var(--l3-border);
.full-view-header-container {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,13 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,26 @@
import AttributeMappingHeader from './components/AttributeMappingHeader';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
function LLMObservabilityAttributeMapping(): JSX.Element {
return (
<div
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={noop}
onSave={noop}
/>
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
No mapping groups configured yet.
</div>
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -0,0 +1,34 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
}
.title {
margin: 0;
font-size: var(--periscope-font-size-large);
font-weight: var(--font-weight-semibold);
}
.description {
margin: var(--spacing-2) 0 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -0,0 +1,56 @@
import { Button } from '@signozhq/ui/button';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
return (
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Attribute Mapping</h1>
<p className={styles.description}>
Configure source-to-target attribute remapping for LLM traces
</p>
</div>
<div className={styles.pageHeaderActions}>
{isDirty && (
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -0,0 +1 @@
export { default } from './AttributeMappingHeader';

View File

@@ -39,6 +39,9 @@ describe('LLMObservability (integration)', () => {
expect(
screen.getByRole('tab', { name: 'Model pricing' }),
).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: 'Attribute Mapping' }),
).toBeInTheDocument();
});
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
@@ -54,6 +57,29 @@ describe('LLMObservability (integration)', () => {
);
});
it('navigates to the attribute mapping route when that tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
);
});
it('renders the attribute mapping page on the attribute mapping route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
});
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('renders the model-pricing page on the configuration route', async () => {
setupList();
render(<LLMObservability />, undefined, {

View File

@@ -4,11 +4,13 @@ import { type TabItemProps } from '@signozhq/ui/tabs';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabilityAttributeMapping';
import Overview from '../Overview/Overview';
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
interface UseLLMObservabilityTabsResult {
items: TabItemProps[];
@@ -24,9 +26,12 @@ export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const activeTab = pathname.startsWith(CONFIGURATION_KEY)
? CONFIGURATION_KEY
: OVERVIEW_KEY;
let activeTab: string = OVERVIEW_KEY;
if (pathname.startsWith(CONFIGURATION_KEY)) {
activeTab = CONFIGURATION_KEY;
} else if (pathname.startsWith(ATTRIBUTE_MAPPING_KEY)) {
activeTab = ATTRIBUTE_MAPPING_KEY;
}
const onTabChange = useCallback(
(key: string): void => {
@@ -46,6 +51,11 @@ export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
label: 'Model pricing',
children: <LLMObservabilityModelPricing />,
},
{
key: ATTRIBUTE_MAPPING_KEY,
label: 'Attribute Mapping',
children: <LLMObservabilityAttributeMapping />,
},
];
return { items, activeTab, onTabChange };

View File

@@ -25,6 +25,14 @@
.meter-explorer-content-section {
width: 100%;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.explore-header {
display: flex;
align-items: center;

View File

@@ -1,6 +1,14 @@
.metrics-explorer-explore-container {
padding-bottom: 80px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.explore-header {
display: flex;
align-items: center;

View File

@@ -1,4 +1,12 @@
.dashboard-navigation {
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.run-query-dashboard-btn {
min-width: 180px;
}

View File

@@ -31,8 +31,14 @@
min-width: 32px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
--periscope-btn-border-color: var(
--query-builder-v2-border-color,
var(--l2-border)
);
--periscope-btn-background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -27,8 +27,11 @@
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
background-color: var(--l1-border) !important;
opacity: 0.8;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
margin-left: -1px;
&:disabled {
opacity: 0.4;
@@ -86,8 +89,8 @@
border-bottom-left-radius: 3px;
.ant-select-selector {
border: none;
background: var(--l3-background);
border: 1px solid var(--query-builder-v2-border-color, none);
background: var(--query-builder-v2-background-color, var(--l3-background));
}
&.showInput {
@@ -101,12 +104,20 @@
.query-function-value {
width: 70px;
border-left: 0;
background: var(--l2-background);
background: var(--query-builder-v2-background-color, var(--l2-background));
border-radius: 0;
border: 1px solid transparent;
border-top: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-bottom: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
height: 32px;
&:focus {
border-color: transparent !important;
border-color: var(--internal-ant-border-color-focus);
}
&:hover {
border-color: var(--internal-ant-border-color-hover);
}
}
@@ -114,7 +125,7 @@
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
border-left: 1px solid var(--l2-border);
border-left: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;

View File

@@ -205,6 +205,7 @@ export const routesToSkip = [
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -16,6 +16,14 @@
min-height: 0;
.log-explorer-query-container {
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
display: flex;
flex-direction: column;
flex: 1;

View File

@@ -67,6 +67,14 @@
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;

View File

@@ -13,9 +13,9 @@
padding: 6px;
border: 1px solid var(--l1-border);
border: 1px solid var(--periscope-btn-border-color, var(--l1-border));
border-radius: 3px;
background: var(--l2-background);
background: var(--periscope-btn-background-color, var(--l2-background));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
color: var(--l2-foreground);
@@ -135,11 +135,15 @@
display: flex;
flex-direction: row;
border-radius: 2px 0px 0px 2px;
background-color: var(
--input-with-label-background-color,
var(--l2-background)
);
.label {
font-size: 12px;
color: var(--l2-foreground);
color: var(--input-with-label-color, var(--l2-foreground));
font-size: 12px;
font-style: normal;
font-weight: 500;
@@ -153,9 +157,8 @@
text-overflow: ellipsis;
padding: 0px 8px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--l1-foreground);
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
display: flex;
justify-content: flex-start;
align-items: center;
@@ -165,7 +168,6 @@
.input {
flex: 1;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
border-right: none;
border-left: none;
border-top-right-radius: 0px;
@@ -173,7 +175,7 @@
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
background: var(--l1-background);
background: var(--input-with-label-background-color, var(--l2-background));
min-width: 0;
@@ -189,21 +191,44 @@
}
.ant-select-selector {
border: none;
background: var(--l2-background);
height: 36px;
border-color: var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-radius: 0;
position: relative;
margin-left: -1px;
}
.ant-select:hover .ant-select-selector,
.ant-select-focused .ant-select-selector {
z-index: 1;
}
.ant-select-disabled .ant-select-selector {
background: var(
--input-with-label-background-color,
var(--l2-background)
) !important;
}
.ant-select-selection-placeholder {
color: var(--l2-foreground);
color: var(--input-with-label-color, var(--l2-foreground));
}
}
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
height: 100%;
width: 38px;
margin-left: -1px;
position: relative;
&:hover,
&:focus {
z-index: 2;
}
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {

View File

@@ -839,6 +839,9 @@ body.ai-assistant-panel-open {
// design libraries.
--dropdown-menu-content-z-index: 1050;
--dropdown-menu-sub-content-z-index: 1050;
--internal-ant-border-color-focus: #3a52a8;
--internal-ant-border-color-hover: #6e8de8;
}
div[data-slot='callout'] {

View File

@@ -136,6 +136,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -0,0 +1,353 @@
// nolint
package transition
import (
"context"
"log/slog"
)
// ══════════════════════════════════════════════
// Shape-safe (idempotent) migration
// ══════════════════════════════════════════════
//
// A copy of the Migrate → updateWidget → updateQueryData chain with the
// "uniformly v4 input" assumption removed, so it is safe on a dashboard whose
// `version` tag lies (a "v5"-labelled dashboard with un-upgraded, possibly mixed,
// bodies — the v1→v2 converter's case). Versus the original: no version gate, and
// each step acts only on the pre-v5 shape (leaving a v5 field alone), so it is
// idempotent. The original Migrate is left unchanged (battle-tested, no test net).
// The *ShapeSafe methods below each note the original they copy; the reused steps
// (createFilterExpression, fixGroupBy, buildAggregationExpression, orderByExpr) are
// already v5-safe.
// MigrateQueryDataShapeSafe is the per-query entry point (the core of
// updateQueryDataShapeSafe) for callers that process queries one at a time (the
// v1→v2 converter). widgetType is the v1 panelTypes (metric reduceTo on tables);
// "" is safe.
func (m *dashboardMigrateV5) MigrateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
return m.updateQueryDataShapeSafe(ctx, queryData, widgetType)
}
// updateQueryDataShapeSafe copies updateQueryData, with each destructive step
// guarded to act only on the pre-v5 shape (see the file header).
func (mc *migrateCommon) updateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
updated := false
aggregateOp, _ := queryData["aggregateOperator"].(string)
hasAggregation := aggregateOp != "" && aggregateOp != "noop"
if mc.createAggregationsShapeSafe(ctx, queryData, widgetType) {
updated = true
}
// createFilterExpression only touches v4 `filters`; skip if a v5 `filter` exists.
if _, hasFilter := queryData["filter"]; !hasFilter {
if mc.createFilterExpression(ctx, queryData) {
updated = true
}
}
if mc.fixGroupBy(queryData) {
updated = true
}
if mc.createHavingExpressionShapeSafe(queryData) {
updated = true
}
if hasAggregation {
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
newOrderBy := make([]any, 0)
for _, order := range orderBy {
if orderMap, ok := order.(map[string]any); ok {
columnName, _ := orderMap["columnName"].(string)
// skip timestamp, id (logs, traces), samples(metrics) ordering for aggregation queries
if columnName != "timestamp" && columnName != "samples" && columnName != "id" {
if columnName == "#SIGNOZ_VALUE" {
if expr, has := mc.orderByExpr(queryData); has {
orderMap["columnName"] = expr
}
} else {
// if the order by key is not part of the group by keys, remove it
present := false
groupBy, ok := queryData["groupBy"].([]any)
if !ok {
return false
}
for idx := range groupBy {
item, ok := groupBy[idx].(map[string]any)
if !ok {
continue
}
key, ok := item["key"].(string)
if !ok {
continue
}
if key == columnName {
present = true
}
}
if !present {
mc.logger.WarnContext(ctx, "found a order by without group by, skipping", slog.String("order_col_name", columnName))
continue
}
}
newOrderBy = append(newOrderBy, orderMap)
}
}
}
queryData["orderBy"] = newOrderBy
updated = true
}
} else {
dataSource, _ := queryData["dataSource"].(string)
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
newOrderBy := make([]any, 0)
for _, order := range orderBy {
if orderMap, ok := order.(map[string]any); ok {
columnName, _ := orderMap["columnName"].(string)
// skip id and timestamp for (traces)
if (columnName == "id" || columnName == "timestamp") && dataSource == "traces" {
mc.logger.InfoContext(ctx, "skipping `id` order by for traces")
continue
}
// skip id for (logs)
if (columnName == "id" || columnName == "timestamp") && dataSource == "logs" {
mc.logger.InfoContext(ctx, "skipping `id`/`timestamp` order by for logs")
continue
}
newOrderBy = append(newOrderBy, orderMap)
}
}
queryData["orderBy"] = newOrderBy
updated = true
}
}
// Only the `&& functionsArePreV5(functions)` guard differs from updateQueryData.
if functions, ok := queryData["functions"].([]any); ok && functionsArePreV5(functions) {
v5Functions := make([]any, len(functions))
for i, fn := range functions {
if fnMap, ok := fn.(map[string]any); ok {
v5Function := map[string]any{
"name": fnMap["name"],
}
// Convert args from v4 format to v5 FunctionArg format
if args, ok := fnMap["args"].([]any); ok {
v5Args := make([]any, len(args))
for j, arg := range args {
// In v4, args were just values. In v5, they are FunctionArg objects
v5Args[j] = map[string]any{
"name": "", // v4 didn't have named args
"value": arg,
}
}
v5Function["args"] = v5Args
}
// Handle namedArgs if present (some functions might have used this)
if namedArgs, ok := fnMap["namedArgs"].(map[string]any); ok {
// Convert named args to the new format
existingArgs, _ := v5Function["args"].([]any)
if existingArgs == nil {
existingArgs = []any{}
}
for name, value := range namedArgs {
existingArgs = append(existingArgs, map[string]any{
"name": name,
"value": value,
})
}
v5Function["args"] = existingArgs
}
v5Functions[i] = v5Function
}
}
queryData["functions"] = v5Functions
updated = true
}
delete(queryData, "aggregateOperator")
delete(queryData, "aggregateAttribute")
delete(queryData, "temporality")
delete(queryData, "timeAggregation")
delete(queryData, "spaceAggregation")
delete(queryData, "reduceTo")
delete(queryData, "filters")
delete(queryData, "ShiftBy")
delete(queryData, "IsAnomaly")
delete(queryData, "QueriesUsedInFormula")
delete(queryData, "seriesAggregation")
return updated
}
// createHavingExpressionShapeSafe copies createHavingExpression but leaves an
// already-v5 having:{expression} alone instead of wiping it.
func (mc *migrateCommon) createHavingExpressionShapeSafe(queryData map[string]any) bool {
if _, ok := queryData["having"].(map[string]any); ok {
return false // already v5-shaped
}
having, ok := queryData["having"].([]any)
if !ok || len(having) == 0 {
queryData["having"] = map[string]any{"expression": ""}
return true
}
dataSource, _ := queryData["dataSource"].(string)
for idx := range having {
if havingItem, ok := having[idx].(map[string]any); ok {
havingCol, has := mc.orderByExpr(queryData)
if has {
havingItem["columnName"] = havingCol
havingItem["key"] = map[string]any{"key": havingCol}
}
having[idx] = havingItem
}
}
queryData["having"] = map[string]any{"expression": mc.buildExpression(context.Background(), having, "AND", dataSource)}
return true
}
// createAggregationsShapeSafe copies createAggregations but skips a query that
// already has a v5 aggregations[], and picks the metric time/space aggregation
// from the body's shape (has timeAggregation/spaceAggregation?) rather than the
// version tag.
func (mc *migrateCommon) createAggregationsShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
return false // already v5-shaped
}
aggregateOp, hasOp := queryData["aggregateOperator"].(string)
aggregateAttr, hasAttr := queryData["aggregateAttribute"].(map[string]any)
dataSource, _ := queryData["dataSource"].(string)
if aggregateOp == "noop" && dataSource != "metrics" {
return false
}
if !hasOp || !hasAttr {
return false
}
var aggregation map[string]any
switch dataSource {
case "metrics":
_, hasTime := queryData["timeAggregation"]
_, hasSpace := queryData["spaceAggregation"]
if hasTime || hasSpace { // acts as a check for v4 shape: the body carries its own time/space aggregation.
if _, ok := queryData["spaceAggregation"]; !ok {
queryData["spaceAggregation"] = aggregateOp
}
aggregation = map[string]any{
"metricName": aggregateAttr["key"],
"temporality": queryData["temporality"],
"timeAggregation": queryData["timeAggregation"],
"spaceAggregation": queryData["spaceAggregation"],
}
if reduceTo, ok := queryData["reduceTo"].(string); ok {
aggregation["reduceTo"] = reduceTo
}
} else {
// v3 shape: derive time/space from the compound operator.
var timeAgg, spaceAgg, reduceTo string
switch aggregateOp {
case "sum_rate", "rate_sum":
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
case "avg_rate", "rate_avg":
timeAgg, spaceAgg, reduceTo = "rate", "avg", "avg"
case "min_rate", "rate_min":
timeAgg, spaceAgg, reduceTo = "rate", "min", "min"
case "max_rate", "rate_max":
timeAgg, spaceAgg, reduceTo = "rate", "max", "max"
case "hist_quantile_50":
timeAgg, spaceAgg, reduceTo = "", "p50", "avg"
case "hist_quantile_75":
timeAgg, spaceAgg, reduceTo = "", "p75", "avg"
case "hist_quantile_90":
timeAgg, spaceAgg, reduceTo = "", "p90", "avg"
case "hist_quantile_95":
timeAgg, spaceAgg, reduceTo = "", "p95", "avg"
case "hist_quantile_99":
timeAgg, spaceAgg, reduceTo = "", "p99", "avg"
case "rate":
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
case "p99", "p90", "p75", "p50", "p25", "p20", "p10", "p05":
mc.logger.InfoContext(ctx, "found invalid config")
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
case "min":
timeAgg, spaceAgg, reduceTo = "min", "min", "min"
case "max":
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
case "avg":
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
case "sum":
timeAgg, spaceAgg, reduceTo = "sum", "sum", "sum"
case "count":
timeAgg, spaceAgg, reduceTo = "count", "sum", "sum"
case "count_distinct":
timeAgg, spaceAgg, reduceTo = "count_distinct", "sum", "sum"
case "noop":
mc.logger.WarnContext(ctx, "noop found in the aggregation data")
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
}
aggregation = map[string]any{
"metricName": aggregateAttr["key"],
"temporality": queryData["temporality"],
"timeAggregation": timeAgg,
"spaceAggregation": spaceAgg,
}
if widgetType == "table" {
aggregation["reduceTo"] = reduceTo
} else if reduceTo, ok := queryData["reduceTo"].(string); ok {
aggregation["reduceTo"] = reduceTo
}
}
case "logs", "traces":
aggregation = map[string]any{"expression": mc.buildAggregationExpression(aggregateOp, aggregateAttr)}
default:
return false
}
queryData["aggregations"] = []any{aggregation}
return true
}
// orderByIsPreV5 reports whether an orderBy slice is still in the v4 shape (an
// entry carries "columnName"); a v5 orderBy uses {key:{name}, direction}.
func orderByIsPreV5(orderBy []any) bool {
for _, o := range orderBy {
if m, ok := o.(map[string]any); ok {
if _, has := m["columnName"]; has {
return true
}
}
}
return false
}
// functionsArePreV5 reports whether a functions slice is still in the v4 shape
// (args are raw values); a v5 function's args are {name,value} objects.
func functionsArePreV5(functions []any) bool {
for _, f := range functions {
if m, ok := f.(map[string]any); ok {
args, ok := m["args"].([]any)
if !ok || len(args) == 0 {
continue
}
_, argIsObject := args[0].(map[string]any)
return !argIsObject
}
}
return false
}

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -22,6 +21,7 @@ var (
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
ErrCodeDashboardMigrationFailed = errors.MustNewCode("dashboard_migration_failed")
)
type StorableDashboard struct {
@@ -406,27 +406,26 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
widgetData := data.Widgets[widgetIndex]
switch widgetData.Query.QueryType {
case "builder":
migrate := transition.NewMigrateCommon(logger)
for _, query := range widgetData.Query.Builder.QueryData {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
}
for _, query := range widgetData.Query.Builder.QueryFormulas {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
}
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -1060,6 +1060,34 @@ func TestValidateRequiredFields(t *testing.T) {
}
}
// TestThresholdZeroValueAcceptedMissingRejected documents the *float64 Value:
// a threshold at 0 (or 0.0) is valid, because the pointer lets validate:"required"
// tell a present zero (non-nil) from an absent value (nil) — while a genuinely
// missing value is still rejected.
func TestThresholdZeroValueAcceptedMissingRejected(t *testing.T) {
numberPanel := func(thresholdSpec string) string {
return `{
"panels": {"p1": {"kind": "Panel", "spec": {
"plugin": {"kind": "signoz/NumberPanel", "spec": {"thresholds": [` + thresholdSpec + `]}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}}},
"layouts": []
}`
}
_, errZero := unmarshalDashboard([]byte(numberPanel(`{"value": 0, "operator": "above", "format": "text", "color": "Red"}`)))
require.NoError(t, errZero, `a threshold "value": 0 is valid`)
// "value": 0.0 is the same float64 zero as "value": 0 — JSON has one number
// type — and is accepted identically.
_, errZeroFloat := unmarshalDashboard([]byte(numberPanel(`{"value": 0.0, "operator": "above", "format": "text", "color": "Red"}`)))
require.NoError(t, errZeroFloat, `"value": 0.0 is the same valid zero`)
_, errMissing := unmarshalDashboard([]byte(numberPanel(`{"operator": "above", "format": "text", "color": "Red"}`)))
require.Error(t, errMissing, "a genuinely missing value is still rejected")
require.Contains(t, errMissing.Error(), "Value")
}
func TestTimeSeriesPanelDefaults(t *testing.T) {
data := []byte(`{
"panels": {

View File

@@ -251,14 +251,20 @@ type Legend struct {
}
type ThresholdWithLabel struct {
Value float64 `json:"value" validate:"required" required:"true"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label"`
// Value is a pointer so a threshold at 0 is valid: validate:"required" treats
// the float64 zero as "missing", but a non-nil *float64 to 0 passes (and nil
// still fails, so a genuinely absent value is still rejected). nullable:"false"
// keeps it a plain required number in the schema — it is never null in valid
// data (validation rejects nil), so the pointer must not leak as `number|null`.
Value *float64 `json:"value" validate:"required" required:"true" nullable:"false"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label"`
}
type ComparisonThreshold struct {
Value float64 `json:"value" validate:"required" required:"true"`
// Value is a pointer so a threshold at 0 is valid (see ThresholdWithLabel.Value).
Value *float64 `json:"value" validate:"required" required:"true" nullable:"false"`
Operator ComparisonOperator `json:"operator"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`

View File

@@ -0,0 +1,94 @@
package dashboardtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
)
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
// (see frontend/src/types/api/dashboard/getAll.ts); v2 is DashboardV2 /
// DashboardSpec.
//
// Assumes the v1 widget query data has already been migrated to v5 shape
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
// invalid v2 envelopes — run the v4→v5 migration first.
//
// The conversion is split across sibling files by concern:
// - perses_v1_to_v2_tags.go tags
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
// - perses_v1_to_v2_queries.go widget queries
// - perses_v1_to_v2_layouts.go grid layouts and sections
// - perses_v1_to_v2_variables.go variables
// - perses_v1_to_v2_decoder.go v1Decoder: typed field reads + malformed-field detection
// ══════════════════════════════════════════════
// Entry point
// ══════════════════════════════════════════════
func (storable StorableDashboard) IsV2() bool {
metadata, _ := storable.Data["metadata"].(map[string]any)
if metadata == nil {
return false
}
version, _ := metadata["schemaVersion"].(string)
return version == SchemaVersion
}
func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err error) {
// Legacy v1 data can be arbitrarily malformed. The accessors degrade
// gracefully, but recover from any unforeseen panic so one bad dashboard
// surfaces as an error (to be logged and skipped) rather than crashing the run.
defer func() {
if r := recover(); r != nil {
result, err = nil, errors.Newf(errors.TypeInternal, ErrCodeDashboardMigrationFailed, "panic converting dashboard %s: %v", storable.ID, r)
}
}()
if storable.IsV2() {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardMigrationFailed, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
}
d := &v1Decoder{}
title := d.readString(storable.Data, "title")
description := d.readString(storable.Data, "description")
image := d.readString(storable.Data, "image")
panels := d.convertV1Panels(storable.Data["widgets"])
spec := DashboardSpec{
Display: Display{Name: title, Description: description},
Variables: d.convertV1Variables(storable.Data["variables"]),
Panels: panels,
Layouts: d.convertV1Layouts(storable.Data, panels),
}
// marshal and unmarshal cycle to confirm full validation
raw, marshalErr := json.Marshal(spec)
if marshalErr != nil {
return nil, errors.WrapInternalf(marshalErr, errors.CodeInternal, "marshal converted dashboard %s", storable.ID)
}
if err := json.Unmarshal(raw, new(DashboardSpec)); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardMigrationFailed, "converted dashboard %s is invalid", storable.ID)
}
tags := d.convertV1TagsForOrg(storable.OrgID, storable.Data["tags"])
if err := d.errIfHasMalformedFields(); err != nil {
return nil, err
}
return &DashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Source: storable.Source,
DashboardV2MetadataBase: DashboardV2MetadataBase{
SchemaVersion: SchemaVersion,
Image: image,
},
Name: generateDashboardName(title),
Tags: tags,
Spec: spec,
}, nil
}

View File

@@ -0,0 +1,168 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
// ══════════════════════════════════════════════
// v1 decoder
// ══════════════════════════════════════════════
// v1Decoder reads fields out of the untyped v1 dashboard blob. Every read*
// method follows the same contract: a field that is absent or null yields the
// zero value; a field present with the wrong type yields zero AND records a
// malformed-field error. Conversion proceeds (so one bad field doesn't abort
// the rest) and ConvertV1ToV2 returns d.malformedFieldsErr() at the end so the
// dashboard is logged and skipped.
//
// Polymorphic v1 fields (spanGaps bool|number, selectedValue string|array, …)
// are read with a type switch on the already-extracted value, never through
// these accessors, so they stay lenient by construction.
type v1Decoder struct {
bad []string
seen map[string]struct{}
}
// note records a decoding problem (malformed field, unknown value, swallowed
// sub-parse error), deduping identical messages. ConvertV1ToV2 surfaces these
// via errIfHasMalformedFields.
func (d *v1Decoder) note(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if _, dup := d.seen[msg]; dup {
return
}
if d.seen == nil {
d.seen = make(map[string]struct{})
}
d.seen[msg] = struct{}{}
d.bad = append(d.bad, msg)
}
// noteMalformedField records a v1 field present with the wrong Go type.
func (d *v1Decoder) noteMalformedField(field string, raw any) {
d.note("%q has unexpected type %T", field, raw)
}
// detailErr renders an error for a diagnostic note, unfolding the structured
// detail our JSON binding attaches via WithAdditional. A plain %v on these
// errors prints only the innermost message ("request body contains invalid
// field value") and drops the field/type context that says which field was
// wrong — the part that actually tells you what to fix.
func detailErr(err error) string {
if err == nil {
return ""
}
j := errors.AsJSON(err)
if len(j.Errors) == 0 {
return err.Error()
}
details := make([]string, 0, len(j.Errors))
for _, e := range j.Errors {
details = append(details, e.Message)
}
return j.Message + ": " + strings.Join(details, "; ")
}
func (d *v1Decoder) errIfHasMalformedFields() error {
if len(d.bad) == 0 {
return nil
}
// One field per line: these lists run long (a bad widget query is reported
// once per widget), and a single "; "-joined line is an unscannable wall.
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "malformed v1 dashboard fields:\n %s", strings.Join(d.bad, "\n "))
}
func readField[T any](d *v1Decoder, m map[string]any, key string) T {
var zero T
v, present := m[key]
if !present || v == nil {
return zero
}
t, ok := v.(T)
if !ok {
d.noteMalformedField(key, v)
return zero
}
return t
}
func (d *v1Decoder) readString(m map[string]any, key string) string {
return readField[string](d, m, key)
}
func (d *v1Decoder) readFloat(m map[string]any, key string) float64 {
return readField[float64](d, m, key)
}
func (d *v1Decoder) readBool(m map[string]any, key string) bool { return readField[bool](d, m, key) }
func (d *v1Decoder) readArray(m map[string]any, key string) []any { return readField[[]any](d, m, key) }
func (d *v1Decoder) readObject(m map[string]any, key string) map[string]any {
return readField[map[string]any](d, m, key)
}
// readInt narrows a numeric field to int (JSON numbers decode as float64).
func (d *v1Decoder) readInt(m map[string]any, key string) int { return int(d.readFloat(m, key)) }
func (d *v1Decoder) readFloatPtr(m map[string]any, key string) *float64 {
v, present := m[key]
if !present || v == nil {
return nil
}
f, ok := v.(float64)
if !ok {
d.noteMalformedField(key, v)
return nil
}
return &f
}
func (d *v1Decoder) readStringMap(m map[string]any, key string) map[string]string {
raw := d.readObject(m, key)
if len(raw) == 0 {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
s, ok := v.(string)
if !ok {
d.noteMalformedField(key+"."+k, v)
continue
}
out[k] = s
}
return out
}
func (d *v1Decoder) readObjects(m map[string]any, key string) []map[string]any {
raw := d.readArray(m, key)
if len(raw) == 0 {
return nil
}
out := make([]map[string]any, 0, len(raw))
for i, item := range raw {
obj, ok := item.(map[string]any)
if !ok {
d.noteMalformedField(fmt.Sprintf("%s[%d]", key, i), item)
continue
}
out = append(out, obj)
}
return out
}
// decodeMapInto converts an untyped map[string]any into a typed T by
// round-tripping through JSON, letting encoding/json (struct tags, custom
// UnmarshalJSON) do the field mapping instead of hand-copying out of the map.
func decodeMapInto[T any](src map[string]any) (T, error) {
var dst T
bytes, err := json.Marshal(src)
if err != nil {
return dst, err
}
if err := json.Unmarshal(bytes, &dst); err != nil {
return dst, err
}
return dst, nil
}

View File

@@ -0,0 +1,230 @@
package dashboardtypes
import (
"fmt"
"sort"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
)
// ══════════════════════════════════════════════
// Layouts (data.layout + data.panelMap)
// ══════════════════════════════════════════════
// convertV1Layouts groups v1 react-grid-layout entries into v2 grid layouts.
// Membership is positional (as the frontend renders): each row widget owns the
// panels below it until the next row; panels above the first row form an unnamed
// grid with no section header. Collapsed rows are the exception — their children
// live in panelMap[rowID].widgets, not `layout`.
func (d *v1Decoder) convertV1Layouts(data StorableDashboardData, panels map[string]*Panel) []Layout {
layout := d.readObjects(data, "layout")
if len(layout) == 0 {
return nil
}
// react-grid-layout can persist the same widget id more than once. Drop the
// duplicates, mirroring the frontend's getUpdatedLayout: keep the first
// occurrence in stored order and discard the rest (the losing entry's
// geometry is thrown away, not merged). Dedupe here, before sortByPosition,
// so "first" means first-in-stored-order as the frontend sees it — not
// topmost. Entries with no id are left for the main loop to drop.
seenWidgetIds := make(map[string]bool, len(layout))
dedupedLayouts := layout[:0]
for _, item := range layout {
if id := d.readString(item, "i"); id != "" {
if seenWidgetIds[id] {
continue
}
seenWidgetIds[id] = true
}
dedupedLayouts = append(dedupedLayouts, item)
}
layout = dedupedLayouts
rows := d.extractRowsAndCollapsedWidgets(data)
// Skip collapsed-row children a malformed dashboard lists in `layout` too.
isWidgetCollapsed := make(map[string]bool)
for _, row := range rows {
for _, child := range row.collapsedWidgets {
if id := d.readString(child, "i"); id != "" {
isWidgetCollapsed[id] = true
break
}
}
}
d.sortByPosition(layout)
type section struct {
row *rowInfo // nil for the unnamed grid of ungrouped panels
items []map[string]any
}
topSectionWithoutHeader := &section{}
sectionsWithHeader := make([]*section, 0, len(rows))
currentRowHeader := topSectionWithoutHeader
for _, item := range layout {
id := d.readString(item, "i")
if id == "" || isWidgetCollapsed[id] {
// widgets in collapsed sectinos will be added when those sections' row widgets are handled.
continue
}
if row, ok := rows[id]; ok {
newRowHeader := &section{row: row, items: d.extractValidLayoutItemsForCollapsedSection(row.collapsedWidgets, panels)}
sectionsWithHeader = append(sectionsWithHeader, newRowHeader)
// A collapsed row owns only its stashed children; later panels → ungrouped.
if row.collapsed {
currentRowHeader = topSectionWithoutHeader
} else {
currentRowHeader = newRowHeader
}
continue
}
// Keep a layout entry only if its widget became a panel; otherwise (skipped
// widget, deleted id, or the "__dropping-elem__" drag placeholder) it would
// reference a panel that does not exist. Rows are handled above.
if _, ok := panels[id]; !ok {
continue
}
currentRowHeader.items = append(currentRowHeader.items, item)
}
out := make([]Layout, 0, len(sectionsWithHeader)+1)
if len(topSectionWithoutHeader.items) > 0 {
out = append(out, d.buildV2GridLayout(nil, topSectionWithoutHeader.items))
}
for _, sec := range sectionsWithHeader {
out = append(out, d.buildV2GridLayout(sec.row, sec.items))
}
return out
}
// extractValidLayoutItemsForCollapsedSection keeps only the collapsed-row children
// backed by a real panel, dropping ghosts. These come from panelMap and skip the
// main loop's per-item panel check, so a grid never references a missing panel.
func (d *v1Decoder) extractValidLayoutItemsForCollapsedSection(items []map[string]any, panels map[string]*Panel) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if id := d.readString(item, "i"); id != "" {
if _, ok := panels[id]; ok {
out = append(out, item)
}
}
}
return out
}
type rowInfo struct {
title string
collapsed bool
collapsedWidgets []map[string]any
}
// extractRowsAndCollapsedWidgets returns the row widgets keyed by id; collapsed
// rows also carry their children stashed under panelMap[id].widgets.
func (d *v1Decoder) extractRowsAndCollapsedWidgets(data StorableDashboardData) map[string]*rowInfo {
panelMap := d.readObject(data, "panelMap")
rows := make(map[string]*rowInfo)
for _, w := range d.readObjects(data, "widgets") {
id := d.readString(w, "id")
if d.readString(w, "panelTypes") != "row" || id == "" {
continue
}
row := &rowInfo{title: d.readString(w, "title")}
// Some templates store panelMap[id] as a bare []widgetID instead of the
// canonical {widgets, collapsed}. The frontend treats such a non-object
// entry as "not collapsed" (see GridCardLayout), so read it leniently: a
// non-map yields nil, which reads as not collapsed.
pm, _ := panelMap[id].(map[string]any)
if d.readBool(pm, "collapsed") {
row.collapsed = true
row.collapsedWidgets = d.readObjects(pm, "widgets")
}
rows[id] = row
}
return rows
}
// buildV2GridLayout builds one v2 grid. row is nil for the unnamed grid (no
// display); otherwise the grid takes the row's title and collapse state. Items are
// sorted by (y, x) then vertically compacted (see compactGridItemsVertically).
func (d *v1Decoder) buildV2GridLayout(row *rowInfo, items []map[string]any) Layout {
d.sortByPosition(items)
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
if row != nil {
spec.Display = &dashboard.GridLayoutDisplay{
Title: row.title,
Collapse: &dashboard.GridLayoutCollapse{Open: !row.collapsed},
}
}
for _, item := range items {
spec.Items = append(spec.Items, dashboard.GridItem{
X: d.readInt(item, "x"),
Y: d.readInt(item, "y"),
Width: d.readInt(item, "w"),
Height: d.readInt(item, "h"),
Content: &common.JSONRef{Ref: fmt.Sprintf("#/spec/panels/%s", d.readString(item, "i"))},
})
}
compactGridItemsVertically(spec.Items)
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
}
// compactGridItemsVertically mirrors react-grid-layout's correctBounds+compact
// (compactType "vertical", allowOverlap false): clamp each item into the grid (x,y>=0;
// x+width<=cols by shifting left), then move sorted-first items up to fill space and
// down past collisions. Fixes overlaps, gaps, and out-of-bounds coords so the migrated
// grid matches the v1 UI and passes v2 validation.
func compactGridItemsVertically(items []dashboard.GridItem) {
collides := func(a, b dashboard.GridItem) bool {
return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
}
firstCollision := func(l dashboard.GridItem, placed []dashboard.GridItem) (dashboard.GridItem, bool) {
for _, p := range placed {
if collides(l, p) {
return p, true
}
}
return dashboard.GridItem{}, false
}
for i := range items {
l := items[i]
if l.X+l.Width > gridColumnCount { // overflows right → shift left to fit
l.X = gridColumnCount - l.Width
}
if l.X < 0 {
l.X = 0
}
if l.Y < 0 {
l.Y = 0
}
for l.Y > 0 { // move up to fill space above
up := l
up.Y--
if _, hit := firstCollision(up, items[:i]); hit {
break
}
l.Y--
}
for { // then down past any collision with an already-placed item
c, hit := firstCollision(l, items[:i])
if !hit {
break
}
l.Y = c.Y + c.Height
}
items[i] = l
}
}
func (d *v1Decoder) sortByPosition(items []map[string]any) {
sort.SliceStable(items, func(i, j int) bool {
if yi, yj := d.readInt(items[i], "y"), d.readInt(items[j], "y"); yi != yj {
return yi < yj
}
return d.readInt(items[i], "x") < d.readInt(items[j], "x")
})
}

View File

@@ -0,0 +1,473 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Widgets → Panels
// ══════════════════════════════════════════════
// convertV1Panels walks the v1 `widgets` array and produces v2 panels keyed by
// the v1 widget id. WidgetRow entries (panelTypes == "row") are dropped here
// and consumed by convertV1Layouts as section headers.
func (d *v1Decoder) convertV1Panels(raw any) map[string]*Panel {
if raw == nil {
return nil
}
widgetsRaw, ok := raw.([]any)
if !ok {
d.noteMalformedField("widgets", raw)
return nil
}
panels := make(map[string]*Panel, len(widgetsRaw))
for i, widgetRaw := range widgetsRaw {
widget, ok := widgetRaw.(map[string]any)
if !ok {
d.noteMalformedField(fmt.Sprintf("widgets[%d]", i), widgetRaw)
continue
}
id := d.readString(widget, "id")
if id == "" {
// dead widget that can't be referenced anywhere, skipping.
continue
}
var panel *Panel
panelType := d.readString(widget, "panelTypes")
switch panelType {
case "graph":
panel = d.convertGraphWidget(widget)
case "bar":
panel = d.convertBarWidget(widget)
case "value":
panel = d.convertValueWidget(widget)
case "pie":
panel = d.convertPieWidget(widget)
case "table":
panel = d.convertTableWidget(widget)
case "histogram":
panel = d.convertHistogramWidget(widget)
case "list":
panel = d.convertListWidget(widget)
case "row":
// "row" (section header) is handled by the layout pass;
continue
default:
d.note("widgets[%d] has unknown panel type %q", i, panelType)
}
if panel == nil {
continue
}
panels[id] = panel
}
return panels
}
func (d *v1Decoder) convertGraphWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTimeSeries,
Spec: &TimeSeriesPanelSpec{
Visualization: TimeSeriesVisualization{
BasicVisualization: d.basicVisualization(w),
FillSpans: d.readBool(w, "fillSpans"),
},
Formatting: d.panelFormatting(w),
ChartAppearance: TimeSeriesChartAppearance{
LineInterpolation: mapV1Enum(d.readString(w, "lineInterpolation"), LineInterpolationSpline,
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
ShowPoints: d.readBool(w, "showPoints"),
LineStyle: mapV1Enum(d.readString(w, "lineStyle"), LineStyleSolid, LineStyleSolid, LineStyleDashed),
FillMode: mapV1Enum(d.readString(w, "fillMode"), FillModeNone, FillModeSolid, FillModeGradient, FillModeNone),
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
},
Axes: d.axesFromWidget(w),
Legend: d.legendFromWidget(w),
Thresholds: d.mapV1ThresholdsWithLabel(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindTimeSeries),
},
}
}
func (d *v1Decoder) convertBarWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindBarChart,
Spec: &BarChartPanelSpec{
Visualization: BarChartVisualization{
BasicVisualization: d.basicVisualization(w),
FillSpans: d.readBool(w, "fillSpans"),
StackedBarChart: d.readBool(w, "stackedBarChart"),
},
Formatting: d.panelFormatting(w),
Axes: d.axesFromWidget(w),
Legend: d.legendFromWidget(w),
Thresholds: d.mapV1ThresholdsWithLabel(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindBarChart),
},
}
}
func (d *v1Decoder) convertValueWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindNumber,
Spec: &NumberPanelSpec{
Visualization: d.basicVisualization(w),
Formatting: d.panelFormatting(w),
Thresholds: d.mapV1ComparisonThresholds(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindNumber),
},
}
}
func (d *v1Decoder) convertPieWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindPieChart,
Spec: &PieChartPanelSpec{
Visualization: d.basicVisualization(w),
Formatting: d.panelFormatting(w),
Legend: d.legendFromWidget(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindPieChart),
},
}
}
func (d *v1Decoder) convertTableWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTable,
Spec: &TablePanelSpec{
Visualization: d.basicVisualization(w),
Formatting: TableFormatting{
ColumnUnits: d.readStringMap(w, "columnUnits"),
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
},
Thresholds: d.mapV1TableThresholds(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindTable),
},
}
}
func (d *v1Decoder) convertHistogramWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindHistogram,
Spec: &HistogramPanelSpec{
HistogramBuckets: HistogramBuckets{
BucketCount: d.readFloatPtr(w, "bucketCount"),
BucketWidth: d.readFloatPtr(w, "bucketWidth"),
MergeAllActiveQueries: d.readBool(w, "mergeAllActiveQueries"),
},
Legend: d.legendFromWidget(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindHistogram),
},
}
}
func (d *v1Decoder) convertListWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindList,
Spec: &ListPanelSpec{
SelectFields: d.mapV1SelectFields(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindList),
},
}
}
// ══════════════════════════════════════════════
// Panel-spec shared helpers
// ══════════════════════════════════════════════
func (d *v1Decoder) widgetDisplay(w map[string]any) Display {
return Display{Name: d.readString(w, "title"), Description: d.readString(w, "description")}
}
func (d *v1Decoder) basicVisualization(w map[string]any) BasicVisualization {
return BasicVisualization{TimePreference: mapV1TimePreference(d.readString(w, "timePreferance"))}
}
func (d *v1Decoder) panelFormatting(w map[string]any) PanelFormatting {
return PanelFormatting{Unit: d.readString(w, "yAxisUnit"), DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
}
func (d *v1Decoder) axesFromWidget(w map[string]any) Axes {
return Axes{
SoftMin: d.readFloatPtr(w, "softMin"),
SoftMax: d.readFloatPtr(w, "softMax"),
IsLogScale: d.readBool(w, "isLogScale"),
}
}
func (d *v1Decoder) legendFromWidget(w map[string]any) Legend {
return Legend{
Position: mapV1Enum(d.readString(w, "legendPosition"), LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
CustomColors: d.readStringMap(w, "customLegendColors"),
}
}
func (d *v1Decoder) mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
field := "selectedLogFields"
raw := d.readArray(w, field)
if len(raw) == 0 {
field = "selectedTracesFields"
raw = d.readArray(w, field)
}
if len(raw) == 0 {
return nil
}
normalizePreV5FieldKeys(raw)
fields, err := decodeTelemetryFields(raw)
if err != nil {
d.note("widget %q has malformed %s: %v", d.readString(w, "id"), field, err)
return nil
}
// Drop nameless entries (blank column rows) — v2 requires a name, and the v1
// UI renders nothing for them anyway.
out := fields[:0]
for _, f := range fields {
if f.Name != "" {
out = append(out, f)
}
}
return out
}
func decodeTelemetryFields(raw []any) ([]telemetrytypes.TelemetryFieldKey, error) {
bytes, err := json.Marshal(raw)
if err != nil {
return nil, err
}
var fields []telemetrytypes.TelemetryFieldKey
if err := json.Unmarshal(bytes, &fields); err != nil {
return nil, err
}
return fields, nil
}
// ══════════════════════════════════════════════
// Panel field mappers
// ══════════════════════════════════════════════
// v1 stores timePreferance as `GLOBAL_TIME`, `LAST_5_MIN`, … (see
// frontend/src/container/NewWidget/RightContainer/timeItems.ts). v2 uses the
// lowercase form, so the translation is just downcase.
func mapV1TimePreference(s string) TimePreference {
if s == "" {
return TimePreferenceGlobalTime
}
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
return TimePreferenceGlobalTime
}
// mapV1Precision is polymorphic (string|number), so it type-switches the raw
// value rather than reading through a typed accessor.
func mapV1Precision(raw any) PrecisionOption {
switch v := raw.(type) {
case string:
candidate := PrecisionOption{valuer.NewString(v)}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
case float64:
n := int(v)
if n >= 0 && n <= 4 {
return PrecisionOption{valuer.NewString(strconv.Itoa(n))}
}
}
return PrecisionOption2
}
// mapV1Enum picks the v1 string value if it matches one of the allowed v2
// values, otherwise returns the fallback. v1 frontend enums (lineInterpolation,
// lineStyle, fillMode, legendPosition) already use the v2 lowercase form.
func mapV1Enum[T interface{ StringValue() string }](s string, fallback T, allowed ...T) T {
if s == "" {
return fallback
}
for _, a := range allowed {
if a.StringValue() == s {
return a
}
}
return fallback
}
// v1 spanGaps is `boolean | number`. true → span every gap; false → never span;
// a number is interpreted (per frontend SeriesProps.spanGaps docs) as an
// X-axis threshold in seconds. Polymorphic, so it type-switches the raw value.
func mapV1SpanGaps(raw any) SpanGaps {
switch v := raw.(type) {
case bool:
if v {
return SpanGaps{FillOnlyBelow: false}
}
return SpanGaps{FillOnlyBelow: true}
case float64:
dur, err := valuer.ParseTextDuration(time.Duration(v * float64(time.Second)).String())
if err != nil {
return SpanGaps{FillOnlyBelow: false}
}
return SpanGaps{FillOnlyBelow: true, FillLessThan: dur}
}
return SpanGaps{FillOnlyBelow: false}
}
func (d *v1Decoder) mapV1ThresholdsWithLabel(w map[string]any) []ThresholdWithLabel {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]ThresholdWithLabel, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
label := d.readString(t, "thresholdLabel")
if color == "" || label == "" {
// v2 ThresholdWithLabel requires both; drop entries that wouldn't validate.
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, ThresholdWithLabel{Value: &value, Unit: d.readString(t, "thresholdUnit"), Color: color, Label: label})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1ComparisonThresholds(w map[string]any) []ComparisonThreshold {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]ComparisonThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
if color == "" {
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, ComparisonThreshold{
Value: &value,
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
Unit: d.readString(t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(d.readString(t, "thresholdFormat")),
})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1TableThresholds(w map[string]any) []TableThreshold {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]TableThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
columnName := d.readString(t, "thresholdTableOptions")
if color == "" || columnName == "" {
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, TableThreshold{
ComparisonThreshold: ComparisonThreshold{
Value: &value,
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
Unit: d.readString(t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(d.readString(t, "thresholdFormat")),
},
ColumnName: columnName,
})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1ComparisonOperator(s string) ComparisonOperator {
switch s {
case ">":
return ComparisonOperatorAbove
case ">=":
return ComparisonOperatorAboveOrEqual
case "<":
return ComparisonOperatorBelow
case "<=":
return ComparisonOperatorBelowOrEqual
case "=":
return ComparisonOperatorEqual
case "!=":
return ComparisonOperatorNotEqual
default:
d.note("threshold has unknown comparison operator %q", s)
return ComparisonOperatorAbove
}
}
func mapV1ThresholdFormat(s string) ThresholdFormat {
switch strings.ToLower(s) {
case "background":
return ThresholdFormatBackground
case "text":
return ThresholdFormatText
}
return ThresholdFormatText
}

View File

@@ -0,0 +1,258 @@
package dashboardtypes
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// ══════════════════════════════════════════════
// Queries
// ══════════════════════════════════════════════
// convertV1WidgetQuery returns exactly one Query (per Spec.Validate). The kind
// chosen depends on the v1 widget query shape:
// - a single query (promql / clickhouse_sql / builder) → its native kind
// - multiple queries → signoz/CompositeQuery
//
// A single query is never wrapped in a CompositeQuery; in particular List
// panels accept only a bare signoz/BuilderQuery. Builder queries are routed
// through qb.WrapInV5Envelope (in collectV1QueryEnvelopes), which translates v4
// builder-field names (orderBy/selectColumns/dataSource) into their v5
// equivalents and adds the `signal` field required by BuilderQuerySpec's
// per-signal dispatch.
func (d *v1Decoder) convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
envelopes, signal := d.collectV1QueryEnvelopes(widget, panelKind)
if len(envelopes) == 0 {
return nil
}
requestType := requestTypeForPanel(panelKind)
// A single query keeps its native kind — never wrapped in a CompositeQuery.
if len(envelopes) == 1 {
if q := singleQueryFromEnvelope(envelopes[0], requestType, signal); q != nil {
return []Query{*q}
}
}
// Default: wrap in CompositeQuery.
composite, err := parseCompositeFromEnvelopes(envelopes)
if err != nil || composite == nil {
d.note("widget %q: could not build query from %d envelope(s): %s", d.readString(widget, "id"), len(envelopes), detailErr(err))
return nil
}
return []Query{{
Kind: requestType,
Spec: QuerySpec{
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
},
}}
}
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
// shape) its queries produce. Mirrors the frontend's panelTypeToRequestType
// (buildQueryRangeRequest.ts): time series for line/bar/histogram (histogram
// bins client-side from raw time series, V1 parity), scalar for
// number/pie/table, raw rows for list.
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
switch panelKind {
case PanelKindTimeSeries, PanelKindBarChart, PanelKindHistogram:
return qb.RequestTypeTimeSeries
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
return qb.RequestTypeScalar
case PanelKindList:
return qb.RequestTypeRaw
}
return qb.RequestTypeTimeSeries
}
// collectV1QueryEnvelopes inspects widget.query.queryType and produces a
// flattened list of v5-shaped envelopes. The returned signal is the dominant
// builder signal (if any), used for typed builder-query dispatch.
func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind PanelPluginKind) ([]map[string]any, telemetrytypes.Signal) {
queryMap := d.readObject(widget, "query")
if queryMap == nil {
return nil, telemetrytypes.Signal{}
}
rowLimitPanel := panelKind == PanelKindList || panelKind == PanelKindTable
// Raw (list) panels legitimately have no aggregation; every other panel needs one.
needsAggregation := requestTypeForPanel(panelKind) != qb.RequestTypeRaw
queryType := d.readString(queryMap, "queryType")
switch queryType {
case "promql":
var out []map[string]any
for _, q := range d.readObjects(queryMap, "promql") {
out = append(out, promQLEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "clickhouse_sql":
var out []map[string]any
for _, q := range d.readObjects(queryMap, "clickhouse_sql") {
out = append(out, clickhouseEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "builder":
builder := d.readObject(queryMap, "builder")
if builder == nil {
return nil, telemetrytypes.Signal{}
}
var out []map[string]any
var signal telemetrytypes.Signal
widgetType := d.readString(widget, "panelTypes")
for _, q := range d.readObjects(builder, "queryData") {
normalizePreV5QueryData(q, widgetType)
normalizePreV5SelectColumns(q)
normalizePreV5PageSize(q, rowLimitPanel)
if needsAggregation {
ensureDefaultAggregation(q)
}
name := d.readString(q, "queryName")
out = append(out, qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
if signal.IsZero() {
signal = signalFromDataSource(q["dataSource"])
}
}
for _, f := range d.readObjects(builder, "queryFormulas") {
normalizePreV5QueryData(f, widgetType)
name := d.readString(f, "queryName")
out = append(out, qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue())))
}
for _, op := range d.readObjects(builder, "queryTraceOperator") {
normalizePreV5QueryData(op, widgetType)
name := d.readString(op, "queryName")
out = append(out, qb.WrapInV5Envelope(name, op, string(qb.QueryTypeTraceOperator.StringValue())))
}
return out, signal
default:
d.note("widget %q has unknown queryType %q", d.readString(widget, "id"), queryType)
}
return nil, telemetrytypes.Signal{}
}
func promQLEnvelope(q map[string]any) map[string]any {
return map[string]any{
"type": qb.QueryTypePromQL.StringValue(),
"spec": map[string]any{
"name": q["name"],
"query": q["query"],
"disabled": q["disabled"],
"legend": q["legend"],
},
}
}
func clickhouseEnvelope(q map[string]any) map[string]any {
return map[string]any{
"type": qb.QueryTypeClickHouseSQL.StringValue(),
"spec": map[string]any{
"name": q["name"],
"query": q["query"],
"disabled": q["disabled"],
"legend": q["legend"],
},
}
}
// singleQueryFromEnvelope returns a typed Query for one envelope, using its
// native query kind (promql/clickhouse_sql/builder) rather than wrapping it in
// a CompositeQuery. A bare signoz/BuilderQuery is valid for every panel kind
// and is the only kind List panels accept.
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType, signal telemetrytypes.Signal) *Query {
t, _ := envelope["type"].(string)
spec, _ := envelope["spec"].(map[string]any)
switch t {
case qb.QueryTypePromQL.StringValue():
prom, err := decodeMapInto[qb.PromQuery](spec)
if err != nil {
return nil
}
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: prom.Name,
Plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &prom},
},
}
case qb.QueryTypeClickHouseSQL.StringValue():
ch, err := decodeMapInto[qb.ClickHouseQuery](spec)
if err != nil {
return nil
}
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: ch.Name,
Plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &ch},
},
}
case qb.QueryTypeBuilder.StringValue():
builderSpec := parseBuilderQuerySpec(spec, signal)
if builderSpec == nil {
return nil
}
name, _ := spec["name"].(string)
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: name,
Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: &BuilderQuerySpec{Spec: builderSpec}},
},
}
}
return nil
}
func parseCompositeFromEnvelopes(envelopes []map[string]any) (*CompositeQuerySpec, error) {
bytes, err := json.Marshal(envelopes)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v1 query envelopes")
}
var parsed []qb.QueryEnvelope
if err := json.Unmarshal(bytes, &parsed); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidWidgetQuery, "decode v5 query envelopes")
}
return &CompositeQuerySpec{Queries: parsed}, nil
}
func parseBuilderQuerySpec(rawSpec any, signal telemetrytypes.Signal) any {
spec, ok := rawSpec.(map[string]any)
if !ok {
return nil
}
if !signal.IsZero() {
spec["signal"] = signal.StringValue()
}
bytes, err := json.Marshal(spec)
if err != nil {
return nil
}
parsed, err := qb.UnmarshalBuilderQueryBySignal(bytes)
if err != nil {
return nil
}
return parsed
}
// signalFromDataSource maps a v1 data-source string to a v5 signal. Casing
// varies by source: builder queries store lowercase ("traces"), while variable
// `dynamicVariablesSource` stores capitalized ("Traces"), so match
// case-insensitively. Unknown values (e.g. "All telemetry") map to the zero
// Signal.
func signalFromDataSource(raw any) telemetrytypes.Signal {
s, _ := raw.(string)
switch strings.ToLower(s) {
case "traces":
return telemetrytypes.SignalTraces
case "logs":
return telemetrytypes.SignalLogs
case "metrics":
return telemetrytypes.SignalMetrics
}
return telemetrytypes.Signal{}
}

View File

@@ -0,0 +1,182 @@
package dashboardtypes
import (
"context"
"log/slog"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// ══════════════════════════════════════════════
// Malformed-field normalization
// ══════════════════════════════════════════════
//
// Pre-v5 query-body reshapes for dashboards whose bodies aren't actually v5-shaped
// (e.g. stamped version:"v5" but never upgraded). The bulk of the upgrade is
// delegated to transition.MigrateQueryDataShapeSafe (see normalizePreV5QueryData);
// this file keeps only the reshapes it doesn't cover.
// preV5Migrator runs transition's shape-safe (idempotent) v4→v5 upgrade. Stateless
// after construction, so a shared instance with a discard logger / no ambiguity
// keys is fine.
var preV5Migrator = transition.NewDashboardMigrateV5(slog.New(slog.DiscardHandler), nil, nil)
// normalizePreV5QueryData upgrades one builder queryData/formula in place: the
// shared migrator, then a reshape of any existing aggregations[] it leaves alone.
func normalizePreV5QueryData(query map[string]any, widgetType string) {
preV5Migrator.MigrateQueryDataShapeSafe(context.Background(), query, widgetType)
normalizePreV5LogTraceAggregations(query)
}
// aggExprRe matches one "func(args)" with an optional "as alias". Mirrors the
// frontend's parseAggregations regex; matching only well-formed func(args)
// discards trailing junk ("sum(x) ) )" → "sum(x)").
var aggExprRe = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+('[^']*'|"[^"]*"|[a-zA-Z0-9_-]+))?`)
// normalizePreV5LogTraceAggregations reshapes an existing logs/traces aggregations[]
// via parseAggregations (extract func(args), lift inline "as alias", split
// multi-part, drop metric-only fields; empty → count()). Covers the case the
// migrator skips: it builds from flat fields but leaves a present-but-malformed
// aggregations[] alone. A query with none is left as-is.
func normalizePreV5LogTraceAggregations(query map[string]any) {
switch signalFromDataSource(query["dataSource"]) {
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
default:
return
}
aggs, ok := query["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return
}
out := make([]any, 0, len(aggs))
for _, a := range aggs {
agg, ok := a.(map[string]any)
if !ok {
continue
}
expr, _ := agg["expression"].(string)
alias, _ := agg["alias"].(string)
parsed := parseAggregations(expr, alias)
if len(parsed) == 0 {
parsed = []any{map[string]any{"expression": "count()"}}
}
out = append(out, parsed...)
}
query["aggregations"] = out
}
// ensureDefaultAggregation defaults an empty logs/traces aggregations[] to count(),
// mirroring the frontend. Callers gate this to aggregation panels. Metrics are skipped:
// count() can't stand in for a missing metricName.
func ensureDefaultAggregation(query map[string]any) {
switch signalFromDataSource(query["dataSource"]) {
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
default:
return
}
if aggs, ok := query["aggregations"].([]any); ok && len(aggs) > 0 {
return
}
query["aggregations"] = []any{map[string]any{"expression": "count()"}}
}
// parseAggregations pulls every func(args) (with inline or passed-through alias,
// quotes stripped) out of a v1 expression. Mirrors the frontend's
// parseAggregations; empty result if none.
func parseAggregations(expression, availableAlias string) []any {
matches := aggExprRe.FindAllStringSubmatch(expression, -1)
out := make([]any, 0, len(matches))
for _, m := range matches {
alias := m[2]
if alias == "" {
alias = availableAlias
}
agg := map[string]any{"expression": m[1]}
if alias != "" {
agg["alias"] = strings.Trim(alias, `'"`)
}
out = append(out, agg)
}
return out
}
// normalizePreV5SelectColumns lets WrapInV5Envelope (which reads the old
// {key,dataType,type}) handle selectColumns stored the v5 way ({name,…}): backfill
// key/dataType/type from name/fieldDataType/fieldContext, drop columns with no
// resolvable key. Inverse of normalizePreV5FieldKeys (the two consumers want
// opposite shapes).
func normalizePreV5SelectColumns(query map[string]any) {
cols, ok := query["selectColumns"].([]any)
if !ok {
return
}
out := make([]any, 0, len(cols))
for _, c := range cols {
col, ok := c.(map[string]any)
if !ok {
continue
}
if _, ok := col["key"]; !ok {
if name, ok := col["name"]; ok {
col["key"] = name
}
}
if _, ok := col["dataType"]; !ok {
if fdt, ok := col["fieldDataType"]; ok {
col["dataType"] = fdt
}
}
if _, ok := col["type"]; !ok {
if fc, ok := col["fieldContext"]; ok {
col["type"] = fc
}
}
if key, _ := col["key"].(string); key == "" {
continue
}
out = append(out, col)
}
query["selectColumns"] = out
}
// normalizePreV5FieldKeys renames list-panel field keys {key,dataType,type} →
// {name,fieldDataType,fieldContext} in place (as WrapInV5Envelope does for
// groupBy/orderBy). Entries already carrying "name" are left as-is.
func normalizePreV5FieldKeys(fields []any) {
for _, f := range fields {
field, ok := f.(map[string]any)
if !ok {
continue
}
if _, hasName := field["name"]; hasName {
continue
}
if key, ok := field["key"]; ok {
field["name"] = key
}
if dataType, ok := field["dataType"]; ok {
field["fieldDataType"] = dataType
}
if typ, ok := field["type"]; ok {
field["fieldContext"] = typ
}
}
}
// normalizePreV5PageSize backfills limit from the legacy pageSize (frontend's
// `limit || pageSize`), for row-limited panels (list/table) only. Leaves a query
// that already has limit, or a non-row-limited panel, untouched.
func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
if !rowLimitPanel {
return
}
if limit, ok := query["limit"]; ok && limit != nil {
return
}
if ps, ok := query["pageSize"]; ok {
query["limit"] = ps
}
}

View File

@@ -0,0 +1,122 @@
package dashboardtypes
import (
"fmt"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Tags
// ══════════════════════════════════════════════
// v1 carries tags as a flat []string; v2 tags are (key, value) pairs. Each v1
// string is normalized into a pair (separator split, empty-side fallback,
// reserved-key prefix, `/` scrub). Tags that normalize to the same
// (lower(key), lower(value)) within a dashboard are collapsed, first occurrence
// winning the display casing.
//
// Characters still illegal after normalization (spaces, punctuation) are molded
// to fit the tag validators: disallowed runs collapse to "_" (see moldTagField).
// defaultV1TagKey is the key assigned when a v1 tag string has no usable
// separator (or one side of the split is empty).
const defaultV1TagKey = "tag"
func (d *v1Decoder) convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
if raw == nil {
return nil
}
rawTagsList, ok := raw.([]any)
if !ok {
d.noteMalformedField("tags", raw)
return nil
}
seen := make(map[string]struct{}, len(rawTagsList))
tagsV2 := make([]*tagtypes.Tag, 0, len(rawTagsList))
for i, rawTag := range rawTagsList {
s, ok := rawTag.(string)
if !ok {
d.noteMalformedField(fmt.Sprintf("tags[%d]", i), rawTag)
continue
}
key, value, ok := normalizeV1Tag(s)
if !ok {
continue
}
dedupKey := strings.ToLower(key) + "\x00" + strings.ToLower(value)
if _, dup := seen[dedupKey]; dup {
continue
}
seen[dedupKey] = struct{}{}
tagsV2 = append(tagsV2, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
}
return tagsV2
}
// normalizeV1Tag derives a (key, value) pair from one v1 tag string. After
// splitting and molding both sides, a lone survivor becomes a value under the
// default key; ok is false if neither survives.
func normalizeV1Tag(s string) (string, string, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", "", false
}
var rawKey, rawValue string
switch {
case strings.Contains(s, ":"):
rawKey, rawValue, _ = strings.Cut(s, ":")
// Only the first ":" separates key from value; collapse the rest.
rawValue = strings.ReplaceAll(rawValue, ":", "_")
case strings.Contains(s, "/"):
rawKey, rawValue, _ = strings.Cut(s, "/")
default:
rawValue = s
}
rawKey = strings.TrimSpace(rawKey)
rawValue = strings.TrimSpace(rawValue)
// Reserved-key collision: prefix "_" so the list-query DSL stays unambiguous.
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(rawKey))]; rawKey != "" && reserved {
rawKey = "_" + rawKey
}
key := moldTagField(rawKey, tagKeyDisallowed, tagKeyNotLead, tagtypes.MAX_LEN_TAG_KEY)
value := moldTagField(rawValue, tagValueDisallowed, nil, tagtypes.MAX_LEN_TAG_VALUE)
switch {
case key == "" && value == "":
return "", "", false
case key == "":
return defaultV1TagKey, value, true
case value == "":
return defaultV1TagKey, key, true
default:
return key, value, true
}
}
// Inverse of tagKeyRegex/tagValueRegex ("/" always rejected); tagKeyNotLead
// matches a bad first char for a key. TestMoldedV1TagsPassValidation guards drift.
var (
tagKeyDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:-]+`)
tagValueDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:.+=-]+`)
tagKeyNotLead = regexp.MustCompile(`^[^a-zA-Z$_@{#]`)
)
// moldTagField collapses disallowed runs to "_", prefixes "_" if notLead hits
// the first char, and caps at max. Keeps a leading "_", trims a trailing one.
func moldTagField(s string, disallowed, notLead *regexp.Regexp, max int) string {
s = strings.TrimRight(disallowed.ReplaceAllString(s, "_"), "_")
if s != "" && notLead != nil && notLead.MatchString(s) {
s = "_" + s
}
if len(s) > max {
s = strings.TrimRight(s[:max], "_")
}
return s
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
package dashboardtypes
import (
"sort"
"github.com/perses/spec/go/dashboard/variable"
)
// ══════════════════════════════════════════════
// Variables
// ══════════════════════════════════════════════
// convertV1Variables walks the v1 `variables` map (UUID-keyed) and produces an
// ordered []Variable. Variables sort by `order` first, then by id for stable
// output. v1 variable types map as follows:
//
// QUERY → ListVariable + signoz/QueryVariable
// CUSTOM → ListVariable + signoz/CustomVariable
// DYNAMIC → ListVariable + signoz/DynamicVariable
// TEXTBOX → TextVariable
func (d *v1Decoder) convertV1Variables(raw any) []Variable {
if raw == nil {
return nil
}
rawVariablesMap, ok := raw.(map[string]any)
if !ok {
d.noteMalformedField("variables", raw)
return nil
}
type ordered struct {
variableID string
variableContent map[string]any
order float64
}
entries := make([]ordered, 0, len(rawVariablesMap))
for variableID, variableContentRaw := range rawVariablesMap {
variableContent, ok := variableContentRaw.(map[string]any)
if !ok {
d.noteMalformedField("variables."+variableID, variableContentRaw)
continue
}
entries = append(entries, ordered{variableID: variableID, variableContent: variableContent, order: d.readFloat(variableContent, "order")})
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].order != entries[j].order {
return entries[i].order < entries[j].order
}
return entries[i].variableID < entries[j].variableID
})
variablesV2 := make([]Variable, 0, len(entries))
for _, e := range entries {
v, ok := d.convertV1Variable(e.variableContent)
if !ok {
continue
}
variablesV2 = append(variablesV2, v)
}
return variablesV2
}
func (d *v1Decoder) convertV1Variable(v map[string]any) (Variable, bool) {
name := d.readString(v, "name")
if name == "" {
return Variable{}, false
}
description := d.readString(v, "description")
kind := d.readString(v, "type")
switch kind {
case "TEXTBOX":
spec := &TextVariableSpec{
Display: Display{Name: name, Description: description},
Value: d.readString(v, "textboxValue"),
Name: name,
}
return Variable{Kind: variable.KindText, Spec: spec}, true
case "QUERY", "CUSTOM", "DYNAMIC":
// Drop (don't fail on) a dynamic variable with no attribute — it can't resolve.
if kind == "DYNAMIC" && d.readString(v, "dynamicVariablesAttribute") == "" {
return Variable{}, false
}
listSpec := &ListVariableSpec{
Display: Display{Name: name, Description: description},
AllowAllValue: d.readBool(v, "showALLOption"),
AllowMultiple: d.readBool(v, "multiSelect"),
CustomAllValue: d.readString(v, "customAllValue"),
CapturingRegexp: d.readString(v, "capturingRegexp"),
Sort: mapV1Sort(d.readString(v, "sort")),
Plugin: d.variablePluginFor(kind, v),
Name: name,
}
if dv := mapV1VariableDefault(v); dv != nil {
listSpec.DefaultValue = dv
}
return Variable{Kind: variable.KindList, Spec: listSpec}, true
default:
d.note("variable %q has unknown type %q", name, kind)
return Variable{}, false
}
}
func (d *v1Decoder) variablePluginFor(kind string, v map[string]any) VariablePlugin {
switch kind {
case "QUERY":
return VariablePlugin{
Kind: VariableKindQuery,
Spec: &QueryVariableSpec{QueryValue: d.readString(v, "queryValue")},
}
case "CUSTOM":
return VariablePlugin{
Kind: VariableKindCustom,
Spec: &CustomVariableSpec{CustomValue: d.readString(v, "customValue")},
}
case "DYNAMIC":
spec := &DynamicVariableSpec{Name: d.readString(v, "dynamicVariablesAttribute")}
if signal := signalFromDataSource(v["dynamicVariablesSource"]); !signal.IsZero() {
spec.Signal = signal
}
return VariablePlugin{Kind: VariableKindDynamic, Spec: spec}
}
return VariablePlugin{}
}
// mapV1VariableDefault reads selectedValue/defaultValue, both polymorphic
// (string|array), so it indexes the raw value and lets defaultValueFromAny
// type-switch — no typed accessor, intentionally lenient.
func mapV1VariableDefault(v map[string]any) *VariableDefaultValue {
if raw, ok := v["selectedValue"]; ok {
return defaultValueFromAny(raw)
}
if raw, ok := v["defaultValue"]; ok {
return defaultValueFromAny(raw)
}
return nil
}
func defaultValueFromAny(raw any) *VariableDefaultValue {
switch v := raw.(type) {
case string:
if v == "" {
return nil
}
return &VariableDefaultValue{variable.DefaultValue{SingleValue: v}}
case []any:
if len(v) == 0 {
return nil
}
values := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
values = append(values, s)
}
}
if len(values) == 0 {
return nil
}
return &VariableDefaultValue{variable.DefaultValue{SliceValues: values}}
}
return nil
}
func mapV1Sort(s string) ListVariableSpecSort {
switch s {
case "ASC":
return SortAlphabeticalAsc
case "DESC":
return SortAlphabeticalDesc
}
return ListVariableSpecSort{} // zero (omitzero) — SortNone is the implicit default
}

View File

@@ -0,0 +1,127 @@
package querybuildertypesv5
// WrapInV5Envelope translates a single v4 builder query/formula map into a
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
// from the data source. queryType selects the envelope type, except a formula
// (detected when name != queryMap["expression"]) is always emitted as
// "builder_formula".
//
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
// produce v5 envelopes, so this lives here with the v5 query types rather than
// in an infra-level package.
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}