Compare commits

..

11 Commits

Author SHA1 Message Date
Nityananda Gohain
1361c070e0 Merge branch 'main' into hotfix/spanmapper 2026-07-13 16:23:49 +05:30
Swapnil Nakade
86313ae561 feat: clousql - adding metrics in definition and dashboard JSON (#12072)
* feat: adding metrics in definition and dashboard JSON

* refactor: renaming cloudsql to cloudsql_postgres

* refactor: generating openapi specs
2026-07-13 09:57:49 +00:00
Gaurav Tewari
f764a8d9af feat(llm-attribute-mapping): read-only listing [2/5] (#11779)
* 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

* feat(llm-attribute-mapping): read-only listing on CSS modules [2/5]

Rebase the listing slice onto the foundation's CSS-module refactor
(which deleted the global stylesheet) and migrate it accordingly:

- Merge listing styles into LLMObservabilityAttributeMapping.module.scss
  (groups/mappers tables, source chips, index badge, error/footer).
- Convert all listing components from global BEM classNames to
  styles.* module access; drop dead/style-less classes (am-table,
  am-row-actions, am-add-row, *_edited, mappers-table__error).
- Adopt theme-aware semantic tokens (--l2/l3-*, --accent-primary,
  --callout-error-*) in place of --bg-* primitives.

* chore: migrate tanstack table

* 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

* chore: update ui and add animation

* refactor: components update

* chore: typography changes

* chore: typography changes

* chore: use badge

* refactor: basic components

* chore: remove hardcoded value

* chore: add comments & tests

* 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

* chore: update test cases

* chore: remove unused component

* chore: update types

* chore: update ui

* refactor: minor things

* chore: break down thingsinto comps

* chore: update files

* fix: update mapping

* chore: remove draft logic no need for now

* chore: more refactor

* chore: remove comments

* chore: refactor route

* chore: update query refech on mount

* chore: update skeleton

* refactor: code

* refactor: code

* refactor: eslint disable

* chore: update selector

* chore: sort

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-13 09:48:51 +00:00
nityanandagohain
ebe7a264d5 fix: format properly 2026-07-13 15:00:55 +05:30
Abhi kumar
3e971a902a fix(dashboard-v2): carry unsaved panel edits into view mode (#12049)
* fix(dashboard-v2): carry unsaved panel edits into view mode

Switching from the panel editor to View Mode dropped un-saved config edits
(thresholds, units, columns, legend, formatting, axes) — the View modal
re-seeded from the saved panel spec, carrying only the live query.

Hand the live draft spec off via a tab-scoped sessionStorage handoff,
correlated to the panel by dashboardId + panelId. The query stays in the URL
(compositeQuery) so the query builder hydrates; the rest of the spec rides in
sessionStorage so the edits survive a refresh without bloating the URL. The
handoff is cleared on plain View, grid drilldown, and close so it can't seed
a stale view.

* fix(dashboard-v2): hide 'Switch to Edit Mode' without edit permission

The View panel modal is reachable by read-only users, so gate the
Switch to Edit Mode button on canEditDashboard && !isLocked, mirroring
how the panel actions menu gates the Edit panel item.

* feat(dashboard-v2): track panel view/edit mode switch events

Add DashboardEvents enum and log SWITCH_TO_EDIT_MODE (View modal) and
SWITCH_TO_VIEW_MODE (panel editor) when the user toggles between the
two panel modes.

* fix(dashboard-v2): bound query cacheTime to 0 under auto-refresh

Under auto-refresh each cycle mints a fresh time-keyed query, so unused
entries accumulate and can OOM the tab. Drop cacheTime to 0 when
auto-refresh is enabled (V1 parity) for panel queries and the query/
dynamic variable selectors; keep DASHBOARD_CACHE_TIME otherwise.
2026-07-13 09:27:40 +00:00
Srikanth Chekuri
e0a0f49fb4 fix(alerts): surface individual validation errors in API response (#11756)
* fix(alerts): surface individual validation errors in API response

* chore: address lint
2026-07-13 09:23:28 +00:00
Shivam Gupta
03abb3ca90 fix: update stale docs links in backend and remaining frontend (#12096)
Repairs 10 broken signoz.io/docs links (5 hard 404s + 5 dead anchors)
that survived the frontend-only sweep in #11319 because they live in
the Go backend and two frontend files it did not cover.

- infra-monitoring readiness checks: drop the removed `user-guides/`
  path segment and remap to the current hostmetrics/k8s-metrics anchors
- querybuilder / telemetrylogs search-troubleshooting errors: point to
  the reworded Q&A anchors (update matching test assertion)
- alert generatorURL fallback: `alerts-management/#generator-url` ->
  `alerts/` (anchor removed in docs restructure)
- missing-spans banner: -> traces-management troubleshooting FAQ anchor
- agent-skills install link: `#installation` -> `#install-the-plugin`

Every changed URL verified live (200 + anchor present).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:49:31 +00:00
Aditya Singh
e4f9daf7d2 feat(qb): add time_series export support + data-export foundation for other format supports (#12025)
* feat: rename existing export logic to follow the new export data structure

* feat(data-export): add time_series serializer

Pure serializer that walks the V5 time_series tree (results → aggregations → series) into a format-agnostic SerializedTable — tidy layout, one row per (series, timestamp), labels as columns, raw values, y-axis unit in the value header. Series names match the chart legend (getLabelName + getLegend resolve legend templates and aggregation aliases/expressions from the builder query).

* feat(data-export): add csv/jsonl formatters and timestamped client download

toCsv/toJsonl turn a SerializedTable into CSV or newline-delimited JSON; downloadFile triggers a client-side blob download with a timestamped filename (base-YYYY-MM-DD_HH-mm-ss.ext) so repeated exports never collide and record when they were taken.

* feat(data-export): add useClientExport dispatch hook

Frontend-driven export hook: narrows a V5 queryRange response by request type, serializes time_series (scalar lands with the next sub-issue), formats as csv/jsonl and downloads. Takes the builder query for chart-parity series naming. Backend-driven export stays in useServerExport.

* test(data-export): assert timestamped filenames via the naming helper

Review feedback: the filename-format regexes duplicated the format spec across tests. The hook tests now freeze the clock and assert delegation to getTimestampedFileName; the format itself stays pinned by the single exact-string test beside the function.

* feat: comment update

* feat: use exsiting request type
2026-07-13 08:05:27 +00:00
Nityananda Gohain
dbacd34ec2 Merge branch 'main' into hotfix/spanmapper 2026-07-13 11:45:02 +05:30
nityanandagohain
0cbf3e8ee2 fix: change group_id to groupId in response 2026-07-13 11:43:45 +05:30
nityanandagohain
1f057f041b fix: set correct opapi response model for span mapper list 2026-07-13 11:23:33 +05:30
96 changed files with 5228 additions and 4358 deletions

View File

@@ -1494,7 +1494,7 @@ components:
- cosmosdb
- cassandradb
- redis
- cloudsql
- cloudsql_postgres
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -2682,6 +2682,7 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3654,6 +3655,7 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3690,6 +3692,7 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -7995,6 +7998,15 @@ components:
required:
- items
type: object
SpantypesGettableSpanMappers:
properties:
items:
items:
$ref: '#/components/schemas/SpantypesSpanMapper'
type: array
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
@@ -8147,7 +8159,7 @@ components:
type: boolean
fieldContext:
$ref: '#/components/schemas/SpantypesFieldContext'
group_id:
groupId:
type: string
id:
type: string
@@ -8160,7 +8172,7 @@ components:
type: string
required:
- id
- group_id
- groupId
- name
- fieldContext
- config
@@ -13739,7 +13751,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
status:
type: string
required:

View File

@@ -2813,7 +2813,7 @@ export enum CloudintegrationtypesServiceIDDTO {
cosmosdb = 'cosmosdb',
cassandradb = 'cassandradb',
redis = 'redis',
cloudsql = 'cloudsql',
cloudsql_postgres = 'cloudsql_postgres',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -3395,6 +3395,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -3922,6 +3923,7 @@ export interface DashboardtypesComparisonThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -4210,6 +4212,7 @@ export interface DashboardtypesTableThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -9191,6 +9194,76 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
/**
* @type integer
*/
priority: number;
}
export interface SpantypesSpanMapperConfigDTO {
/**
* @type array,null
*/
sources: SpantypesSpanMapperSourceDTO[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
fieldContext: SpantypesFieldContextDTO;
/**
* @type string
*/
groupId: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface SpantypesGettableSpanMappersDTO {
/**
* @type array
*/
items: SpantypesSpanMapperDTO[];
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -9437,30 +9510,6 @@ export interface SpantypesPostableFlamegraphDTO {
selectedSpanId?: string;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
/**
* @type integer
*/
priority: number;
}
export interface SpantypesSpanMapperConfigDTO {
/**
* @type array,null
*/
sources: SpantypesSpanMapperSourceDTO[] | null;
}
export interface SpantypesPostableSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
@@ -9509,45 +9558,6 @@ export interface SpantypesPostableWaterfallDTO {
uncollapsedSpans?: string[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
fieldContext: SpantypesFieldContextDTO;
/**
* @type string
*/
group_id: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface SpantypesUpdatableSpanMapperDTO {
config?: SpantypesSpanMapperConfigDTO;
/**
@@ -10849,7 +10859,7 @@ export type ListSpanMappersPathParameters = {
groupId: string;
};
export type ListSpanMappers200 = {
data: SpantypesGettableSpanMapperGroupsDTO;
data: SpantypesGettableSpanMappersDTO;
/**
* @type string
*/

View File

@@ -3,7 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';

View File

@@ -1,3 +1,4 @@
export enum SESSIONSTORAGE {
RETRY_LAZY_REFRESHED = 'retry-lazy-refreshed',
VIEW_PANEL_HANDOFF = 'view-panel-handoff',
}

View File

@@ -0,0 +1,7 @@
.pageError {
padding: var(--padding-3) var(--padding-4);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,21 @@
import styles from './AttributeMappingsTab.module.scss';
import MappingsTable from './components/MappingsTable/MappingsTable';
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
function AttributeMappingsTab(): JSX.Element {
const store = useAttributeMappingStore();
return (
<div data-testid="attribute-mappings-tab">
{store.isError ? (
<div className={styles.pageError} role="alert">
Failed to load mapping groups. Please try again.
</div>
) : (
<MappingsTable store={store} />
)}
</div>
);
}
export default AttributeMappingsTab;

View File

@@ -0,0 +1,267 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
makeMapper,
makeMappersResponse,
mappersEndpoint,
mockGroups,
mockMappers,
} from 'container/LLMObservability/AttributeMapping/__tests__/fixtures';
import AttributeMappingsTab from '../AttributeMappingsTab';
function setupGroups(groups = mockGroups): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(groups))),
),
);
}
function setupMappers(mappers = mockMappers, groupId = 'group-1'): void {
server.use(
rest.get(mappersEndpoint(groupId), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
),
);
}
async function expandGroup(
user: ReturnType<typeof userEvent.setup>,
groupId = 'group-1',
): Promise<void> {
await user.click(screen.getByTestId(`group-expand-${groupId}`));
}
describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
// Reset URL state between tests — jsdom shares window.location across a file.
window.history.pushState(null, '', '/');
});
afterEach(() => {
server.resetHandlers();
});
it('renders no error banner on a successful load', async () => {
setupGroups();
render(<AttributeMappingsTab />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows an error banner when the groups request fails', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<AttributeMappingsTab />);
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
'Failed to load mapping groups. Please try again.',
);
});
it('shows the empty state when there are no groups', async () => {
setupGroups([]);
render(<AttributeMappingsTab />);
await expect(
screen.findByTestId('mapper-groups-empty'),
).resolves.toHaveTextContent('No mapping groups yet.');
});
it('renders each group header row with its name, condition count and status', async () => {
setupGroups();
render(<AttributeMappingsTab />);
// Condition filters are no longer shown inline as clauses — the header
// carries a count instead (the keys surface in the group drawer, later PR).
// Group headers are antd Collapse panels, so rows scope to the panel item.
// group-1: enabled, with attribute + resource condition keys.
const enabledRow = (await screen.findByTestId('group-name-group-1')).closest(
'.ant-collapse-item',
) as HTMLElement;
expect(
within(enabledRow).getByTestId('group-name-group-1'),
).toHaveTextContent('demo');
expect(
within(enabledRow).getByTestId('group-condition-count-group-1'),
).toHaveTextContent('2 conditions');
expect(within(enabledRow).getByTestId('group-enabled-group-1')).toBeChecked();
// group-2: disabled, with no condition keys.
const disabledRow = screen
.getByTestId('group-name-group-2')
.closest('.ant-collapse-item') as HTMLElement;
expect(within(disabledRow).getByText('Tool')).toBeInTheDocument();
expect(
within(disabledRow).getByTestId('group-condition-count-group-2'),
).toHaveTextContent('0 conditions');
expect(
within(disabledRow).getByTestId('group-enabled-group-2'),
).not.toBeChecked();
});
it('renders the group enable state as a read-only switch', async () => {
setupGroups();
render(<AttributeMappingsTab />);
// The status switch reflects enabled state but is non-interactive in this
// read-only listing — editing lands in a later PR.
const toggle = await screen.findByTestId('group-enabled-group-1');
expect(toggle).toBeChecked();
expect(toggle).toBeDisabled();
});
it("reveals a group's mappers on expand and hides them on collapse", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1' })]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
// The toggle is the antd Collapse header, which owns the expanded state.
const header = screen
.getByTestId('group-expand-group-1')
.closest('.ant-collapse-header') as HTMLElement;
expect(header).toHaveAttribute('aria-expanded', 'false');
await expandGroup(user);
expect(header).toHaveAttribute('aria-expanded', 'true');
await expect(
screen.findByTestId('mapper-target-mapper-1'),
).resolves.toBeInTheDocument();
await expandGroup(user);
await waitFor(() =>
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument(),
);
});
it("lazily fetches and renders a group's mappers on first expand", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
// Mappers are not fetched until the row is expanded.
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument();
await expandGroup(user);
const target = await screen.findByTestId('mapper-target-mapper-1');
expect(target).toHaveTextContent('gen_ai.request.model');
const mapperRow = target.closest('tr') as HTMLElement;
// Sources ordered by priority, highest first (see fixtures).
const sources = within(mapperRow).getByTestId('mapper-sources-mapper-1');
expect(sources).toHaveTextContent('genai.model');
expect(sources).toHaveTextContent('llm.model');
// Writes-to field context + enabled status (an inline Switch, not text).
expect(within(mapperRow).getByText('attribute')).toBeInTheDocument();
expect(
within(mapperRow).getByTestId('mapper-enabled-mapper-1'),
).toBeChecked();
});
it("renders a mapper's enable state as a read-only switch", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', enabled: true })]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
// Like the group switch, a mapper's status switch reflects state without
// accepting flips in this read-only listing.
const toggle = await screen.findByTestId('mapper-enabled-mapper-1');
expect(toggle).toBeChecked();
expect(toggle).toBeDisabled();
});
it('shows the mappers error state when the mappers request fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(500)),
),
);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(
screen.findByTestId('mappers-error-group-1'),
).resolves.toHaveTextContent('Failed to load mappings. Please try again.');
});
it('shows the mappers empty state when a group has no mappers', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(
screen.findByTestId('mappers-empty-group-1'),
).resolves.toHaveTextContent('No mappings in this group yet.');
});
it('collapses extra mapper sources into a "+N more" label beyond the visible cap', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([
makeMapper({
id: 'mapper-1',
config: {
sources: [1, 2, 3, 4, 5].map((priority) => ({
key: `source-${priority}`,
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
})),
},
}),
]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(screen.findByText('+2 more')).resolves.toBeInTheDocument();
});
it('shows a muted placeholder when a mapper has no sources', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await waitFor(() =>
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
);
});
});

View File

@@ -0,0 +1,19 @@
.groupHeaderLabel {
display: flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
}
.groupName {
color: var(--l1-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.groupCount {
color: var(--l3-foreground);
font-size: var(--font-size-xs);
white-space: nowrap;
}

View File

@@ -0,0 +1,36 @@
import { Typography } from '@signozhq/ui/typography';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import styles from './GroupHeader.module.scss';
interface GroupHeaderProps {
group: MappingGroup;
}
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
const conditionCount = group.attributes.length + group.resource.length;
return (
<div
className={styles.groupHeaderLabel}
data-testid={`group-expand-${group.id}`}
>
<Typography.Text
as="span"
className={styles.groupName}
testId={`group-name-${group.id}`}
>
{group.name}
</Typography.Text>
<Typography.Text
as="span"
className={styles.groupCount}
testId={`group-condition-count-${group.id}`}
>
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
</Typography.Text>
</div>
);
}
export default GroupHeader;

View File

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

View File

@@ -0,0 +1,6 @@
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
}

View File

@@ -0,0 +1,26 @@
import { Switch } from '@signozhq/ui/switch';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import styles from './GroupHeaderActions.module.scss';
interface GroupHeaderActionsProps {
group: MappingGroup;
}
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
return (
<div
className={styles.actions}
onClick={(event): void => event.stopPropagation()}
>
<Switch
value={group.enabled}
// We don't yet support toggling a group's enabled state in this read-only PR, so disable the switch. A later PR will add the toggle handler and its drawer.
disabled
testId={`group-enabled-${group.id}`}
/>
</div>
);
}
export default GroupHeaderActions;

View File

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

View File

@@ -0,0 +1,14 @@
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.mapperStateRow .stateCell {
color: var(--l3-foreground);
}
.stateCell {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,98 @@
import { useListSpanMappers } from 'api/generated/services/spanmapper';
import { motion } from 'motion/react';
import {
MappingGroup,
Mapping,
} from 'container/LLMObservability/AttributeMapping/types';
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
import { COLUMN_COUNT } from '../constants';
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
import MappingsColgroup from '../MappingsColgroup';
import styles from './GroupMappers.module.scss';
const MAPPER_SKELETON_ROWS = 1;
const STATE_ROW_MOTION = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.18, ease: 'easeOut' },
} as const;
interface StateRowProps {
groupId: string;
}
function ErrorRow({ groupId }: StateRowProps): JSX.Element {
return (
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
<td
colSpan={COLUMN_COUNT}
className={styles.stateCell}
data-testid={`mappers-error-${groupId}`}
>
Failed to load mappings. Please try again.
</td>
</motion.tr>
);
}
function EmptyRow({ groupId }: StateRowProps): JSX.Element {
return (
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
<td
colSpan={COLUMN_COUNT}
className={styles.stateCell}
data-testid={`mappers-empty-${groupId}`}
>
No mappings in this group yet.
</td>
</motion.tr>
);
}
interface GroupMappersProps {
group: MappingGroup;
}
function GroupMappers({ group }: GroupMappersProps): JSX.Element {
const {
data: mappers = [],
isLoading,
isError,
} = useListSpanMappers<Mapping[]>(
{
groupId: group.id,
},
{
query: {
refetchOnMount: false,
select: buildMappingsFromListResponse,
},
},
);
let rows: JSX.Element[];
if (isError) {
rows = [<ErrorRow key="error" groupId={group.id} />];
} else if (isLoading) {
rows = Array.from({ length: MAPPER_SKELETON_ROWS }).map((_, index) => (
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
));
} else if (mappers.length === 0) {
rows = [<EmptyRow key="empty" groupId={group.id} />];
} else {
rows = mappers.map((mapper, index) => (
<MapperRow key={mapper.id} mapper={mapper} index={index} />
));
}
return (
<table className={styles.table}>
<MappingsColgroup />
<tbody>{rows}</tbody>
</table>
);
}
export default GroupMappers;

View File

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

View File

@@ -0,0 +1,65 @@
.mapperRow {
&:hover {
background: var(--l2-background-hover);
}
}
.cell {
padding: var(--spacing-4) var(--spacing-6);
vertical-align: middle;
color: var(--l1-foreground);
font-size: var(--periscope-font-size-base);
}
// Indent the first cell so mapper rows read as nested under their group.
.targetCell {
padding-left: var(--spacing-12);
}
// Shorter vertical padding so the loading state reads as a compact placeholder.
.skeletonCell {
composes: cell;
:global(.ant-skeleton-input) {
min-height: 18px !important;
height: 18px !important;
}
:global(.ant-skeleton-button) {
min-height: 18px !important;
height: 18px !important;
}
}
.statusCell {
text-align: right;
}
.rowActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
}
.sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-3);
}
.sourceChipText {
display: block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sourceMore {
font-size: var(--font-size-xs);
white-space: nowrap;
}
.muted {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,102 @@
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { motion } from 'motion/react';
import { Mapping } from 'container/LLMObservability/AttributeMapping/types';
import styles from './MapperRow.module.scss';
const MAX_VISIBLE_SOURCES = 3;
const ROW_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
const MAX_STAGGERED_ROWS = 6;
const STAGGER_STEP = 0.03;
interface MapperRowProps {
mapper: Mapping;
index: number;
}
function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
const sources = mapper.sources ?? [];
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
const remainingSources = sources.length - visibleSources.length;
return (
<motion.tr
className={styles.mapperRow}
data-testid={`mapper-row-${mapper.id}`}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
...ROW_TRANSITION,
delay: Math.min(index, MAX_STAGGERED_ROWS) * STAGGER_STEP,
}}
>
<td className={cx(styles.cell, styles.targetCell)}>
<Typography.Text
truncate={1}
title={mapper.name}
data-testid={`mapper-target-${mapper.id}`}
>
{mapper.name}
</Typography.Text>
</td>
<td className={styles.cell}>
{sources.length === 0 ? (
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
</span>
) : (
<div
className={styles.sources}
data-testid={`mapper-sources-${mapper.id}`}
>
{visibleSources.map((source) => (
<Badge
variant="outline"
color="vanilla"
className={styles.sourceChip}
key={`${source.context}:${source.key}`}
>
<span className={styles.sourceChipText} title={source.key}>
{source.key}
</span>
</Badge>
))}
{remainingSources > 0 && (
<span className={cx(styles.sourceMore, styles.muted)}>
+{remainingSources} more
</span>
)}
</div>
)}
</td>
<td className={styles.cell}>
<Badge
color={
mapper.fieldContext === SpantypesFieldContextDTO.resource
? 'amber'
: 'robin'
}
variant="outline"
>
{mapper.fieldContext}
</Badge>
</td>
<td className={cx(styles.cell, styles.statusCell)}>
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
disabled
testId={`mapper-enabled-${mapper.id}`}
/>
</div>
</td>
</motion.tr>
);
}
export default MapperRow;

View File

@@ -0,0 +1,30 @@
import { Skeleton } from 'antd';
import cx from 'classnames';
import styles from './MapperRow.module.scss';
function MapperRowSkeleton(): JSX.Element {
return (
<tr className={styles.mapperRow}>
<td className={cx(styles.skeletonCell, styles.targetCell)}>
<Skeleton.Input active size="small" style={{ width: '55%' }} />
</td>
<td className={styles.skeletonCell}>
<div className={styles.sources}>
<Skeleton.Button active size="small" style={{ width: 88 }} />
<Skeleton.Button active size="small" style={{ width: 56 }} />
</div>
</td>
<td className={styles.skeletonCell}>
<Skeleton.Button active size="small" style={{ width: 72 }} />
</td>
<td className={cx(styles.skeletonCell, styles.statusCell)}>
<div className={styles.rowActions}>
<Skeleton.Button active size="small" shape="round" />
</div>
</td>
</tr>
);
}
export default MapperRowSkeleton;

View File

@@ -0,0 +1,2 @@
export { default } from './MapperRow';
export { default as MapperRowSkeleton } from './MapperRowSkeleton';

View File

@@ -0,0 +1,11 @@
.colTarget {
width: 32%;
}
.colWritesTo {
width: 140px;
}
.colStatus {
width: 120px;
}

View File

@@ -0,0 +1,14 @@
import styles from './MappingsColgroup.module.scss';
function MappingsColgroup(): JSX.Element {
return (
<colgroup>
<col className={styles.colTarget} />
<col />
<col className={styles.colWritesTo} />
<col className={styles.colStatus} />
</colgroup>
);
}
export default MappingsColgroup;

View File

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

View File

@@ -0,0 +1,124 @@
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.headerRow {
border-bottom: 1px solid var(--l2-border);
}
.headerCell {
padding: var(--spacing-4) var(--spacing-6);
text-align: left;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
&:last-child {
text-align: right;
}
}
.groupsCollapse:global(.ant-collapse) {
background: transparent;
border: none;
border-radius: 0;
> :global(.ant-collapse-item) {
border-bottom: none;
border-top: 1px solid var(--l2-border);
border-radius: 0;
&:last-child {
border-bottom: 1px solid var(--l2-border);
border-radius: 0;
}
> :global(.ant-collapse-header) {
align-items: center;
gap: var(--spacing-3);
background: var(--l2-background);
border-radius: 0;
padding: var(--spacing-3) var(--spacing-6);
color: var(--l3-foreground);
:global(.ant-collapse-expand-icon) {
display: flex;
align-items: center;
height: auto;
padding-inline-end: 0;
color: var(--l3-foreground);
}
:global(.ant-collapse-header-text) {
min-width: 0;
}
:global(.ant-collapse-extra) {
display: flex;
align-items: center;
}
}
}
:global(.ant-collapse-content) {
background: transparent;
border-top: none;
color: inherit;
> :global(.ant-collapse-content-box) {
padding: 0;
}
}
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.skeletonList {
display: flex;
flex-direction: column;
}
// Mirrors the Collapse header banner while groups load.
.skeletonBanner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
&:last-child {
border-bottom: 1px solid var(--l2-border);
}
:global(.ant-skeleton-input) {
min-height: 18px !important;
height: 18px !important;
}
:global(.ant-skeleton-button) {
min-height: 18px !important;
height: 18px !important;
}
}
.skeletonGroupLeft {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 1;
min-width: 0;
}
.skeletonGroupRight {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex-shrink: 0;
}

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Collapse, type CollapseProps, Skeleton } from 'antd';
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
import GroupHeader from './GroupHeader';
import GroupHeaderActions from './GroupHeaderActions';
import GroupMappers from './GroupMappers';
import MappingsColgroup from './MappingsColgroup';
import styles from './MappingsTable.module.scss';
const SKELETON_ROW_COUNT = 3;
interface MappingsTableProps {
store: AttributeMappingStore;
}
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const isEmpty = !store.isLoading && store.groups.length === 0;
const items: CollapseProps['items'] = store.groups.map((group) => ({
key: group.id,
label: <GroupHeader group={group} />,
extra: <GroupHeaderActions group={group} />,
children: <GroupMappers group={group} />,
}));
const skeletonBanners = (
<div className={styles.skeletonList}>
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`group-skeleton-${index}`}
className={styles.skeletonBanner}
>
<div className={styles.skeletonGroupLeft}>
<Skeleton.Input
active
size="small"
style={{ width: index % 2 === 0 ? 200 : 140 }}
/>
<Skeleton.Input active size="small" style={{ width: 64 }} />
</div>
<div className={styles.skeletonGroupRight}>
<Skeleton.Button active size="small" shape="round" />
</div>
</div>
))}
</div>
);
if (isEmpty) {
return (
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
No mapping groups yet.
</div>
);
}
return (
<div data-testid="mappings-table">
<table className={styles.table}>
<MappingsColgroup />
<thead>
<tr className={styles.headerRow}>
<th className={styles.headerCell}>Target</th>
<th className={styles.headerCell}>Sources</th>
<th className={styles.headerCell}>Writes to</th>
<th className={styles.headerCell}>Status</th>
</tr>
</thead>
</table>
{store.isLoading ? (
skeletonBanners
) : (
<Collapse
className={styles.groupsCollapse}
activeKey={expandedGroups}
onChange={(keys): void =>
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
}
bordered={false}
destroyInactivePanel
expandIcon={({ isActive }): JSX.Element =>
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
items={items}
/>
)}
</div>
);
}
export default MappingsTable;

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react';
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import { buildMappingGroup } from 'container/LLMObservability/AttributeMapping/utils';
export interface AttributeMappingStore {
groups: MappingGroup[];
isLoading: boolean;
isError: boolean;
}
// Read-only store for the listing view: loads the server groups only. Each
// group's mappers are fetched lazily when its panel is expanded (see
// GroupMappers), so page load is a single request instead of an N+1 fan-out
// across every group. Editing (enabled toggles, save/discard) and its drawers
// land in a later PR — this PR only lists.
export function useAttributeMappingStore(): AttributeMappingStore {
const groupsQuery = useListSpanMapperGroups();
const groups = useMemo<MappingGroup[]>(() => {
const serverGroups: SpantypesSpanMapperGroupDTO[] =
groupsQuery.data?.data?.items ?? [];
return serverGroups.map((group) => buildMappingGroup(group));
}, [groupsQuery.data]);
return {
groups,
isLoading: groupsQuery.isLoading,
isError: groupsQuery.isError,
};
}

View File

@@ -2,12 +2,5 @@
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);
padding: var(--spacing-0);
}

View File

@@ -1,9 +1,27 @@
import { Tabs } from '@signozhq/ui/tabs';
import AttributeMappingHeader from './components/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
function LLMObservabilityAttributeMapping(): JSX.Element {
const tabItems = [
{
key: 'attribute-mappings',
label: 'Attribute mappings',
children: <AttributeMappingsTab />,
},
{
key: 'test',
label: 'Test',
disabled: true,
disabledReason: 'Coming soon',
children: null,
},
];
return (
<div
className={styles.llmObservabilityAttributeMapping}
@@ -16,9 +34,11 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
onSave={noop}
/>
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
No mapping groups configured yet.
</div>
<Tabs
testId="attribute-mapping-tabs"
defaultValue="attribute-mappings"
items={tabItems}
/>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
function setupGroups(): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
}
describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupGroups();
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page shell', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('shows the attribute-mappings and test sub-tab labels', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByRole('tab', { name: 'Attribute mappings' }),
).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
});
it('activates the attribute-mappings tab by default and renders its content', async () => {
render(<LLMObservabilityAttributeMapping />);
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute mappings',
});
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('renders the header with its description and no Save/Discard while pristine', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
// The actions only appear once there are staged changes.
expect(screen.queryByTestId('save-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperDTO as Mapper,
SpantypesSpanMapperGroupDTO as MapperGroup,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
// Endpoint globs used by MSW handlers. The generated client hits relative
// `/api/v1/span_mapper_groups[...]`, so the `*` prefix matches regardless of
// base URL.
export const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
export function mappersEndpoint(groupId: string): string {
return `*/api/v1/span_mapper_groups/${groupId}/span_mappers`;
}
export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
return {
id: 'group-1',
orgId: 'org-1',
name: 'demo',
enabled: true,
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
...overrides,
};
}
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
return {
id: 'mapper-1',
group_id: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
config: {
sources: [
{
key: 'genai.model',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
},
],
},
...overrides,
};
}
// Both list endpoints share the same `{ status, data: { items } }` envelope —
// the generated schema mis-types the mappers response with the groups DTO
// (see GroupMappers), but the runtime envelope shape is identical.
export function makeGroupsResponse(groups: MapperGroup[]): {
status: string;
data: { items: MapperGroup[] };
} {
return { status: 'ok', data: { items: groups } };
}
export function makeMappersResponse(mappers: Mapper[]): {
status: string;
data: { items: Mapper[] };
} {
return { status: 'ok', data: { items: mappers } };
}
export const mockGroups: MapperGroup[] = [
makeGroup({
id: 'group-1',
name: 'demo',
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
}),
makeGroup({
id: 'group-2',
name: 'Tool',
enabled: false,
condition: { attributes: null, resource: null },
}),
];
export const mockMappers: Mapper[] = [
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
];

View File

@@ -5,23 +5,6 @@
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;

View File

@@ -1,4 +1,5 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './AttributeMappingHeader.module.scss';
@@ -17,38 +18,35 @@ function AttributeMappingHeader({
}: 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 && (
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{isDirty && (
<div className={styles.pageHeaderActions}>
<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>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,26 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
export interface Mapping {
id: string;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
export interface MappingGroup {
id: string;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}

View File

@@ -0,0 +1,48 @@
import {
ListSpanMappers200,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MappingGroup, Mapping, SourceConfig } from './types';
function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => a.priority - b.priority)
.map((source) => ({
key: source.key,
context: source.context,
operation: source.operation,
}));
}
export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
return {
id: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
export function buildMappingsFromListResponse(
response: ListSpanMappers200,
): Mapping[] {
const items = (response.data?.items ??
[]) as unknown as SpantypesSpanMapperDTO[];
return items.map(buildMapping);
}
export function buildMappingGroup(
group: SpantypesSpanMapperGroupDTO,
): MappingGroup {
return {
id: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
};
}

View File

@@ -0,0 +1,130 @@
import { act, renderHook } from '@testing-library/react';
import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { ExportFormat } from 'lib/exportData/types';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { useClientExport } from '../useClientExport';
jest.mock('lib/exportData/downloadFile', () => ({
...jest.requireActual('lib/exportData/downloadFile'),
downloadFile: jest.fn(),
}));
const mockMessageError = jest.fn();
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: { error: (...args: unknown[]): void => mockMessageError(...args) },
};
});
const mockDownloadFile = downloadFile as jest.Mock;
function timeSeriesResponse(): QueryRangeResponseV5 {
return {
type: 'time_series',
data: {
results: [
{
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
meta: {},
series: [
{
labels: [{ key: { name: 'service' }, value: 'a' }],
values: [{ timestamp: 1000, value: 12 }],
},
],
},
],
},
],
},
meta: {},
} as unknown as QueryRangeResponseV5;
}
describe('useClientExport', () => {
beforeEach(() => {
jest.clearAllMocks();
// Freeze the clock so filenames are deterministic — asserted against the
// real getTimestampedFileName (the format itself is pinned by an exact
// string in downloadFile.test).
jest.useFakeTimers().setSystemTime(new Date(2026, 6, 13, 14, 32, 5));
});
afterEach(() => {
jest.useRealTimers();
});
it('exports time_series as CSV to a timestamped <fileName>.csv', () => {
const { result } = renderHook(() =>
useClientExport({
response: timeSeriesResponse(),
fileName: 'chart',
legendMap: { A: '{{service}}' },
}),
);
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [content, name, mime] = mockDownloadFile.mock.calls[0];
// delegation: the hook names files via getTimestampedFileName
expect(name).toBe(getTimestampedFileName('chart', 'csv'));
expect(mime).toContain('text/csv');
expect(content).toContain('service');
expect(content).toContain('a');
});
it('exports as JSONL to a timestamped <fileName>.jsonl with the ndjson mime', () => {
const { result } = renderHook(() =>
useClientExport({ response: timeSeriesResponse() }),
);
act(() => {
result.current.handleExport({ format: ExportFormat.Jsonl });
});
const [content, name, mime] = mockDownloadFile.mock.calls[0];
expect(name).toBe(getTimestampedFileName('export', 'jsonl'));
expect(mime).toContain('ndjson');
expect(content).toContain('"series"');
});
it('does nothing when there is no response', () => {
const { result } = renderHook(() => useClientExport({}));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).not.toHaveBeenCalled();
expect(mockMessageError).not.toHaveBeenCalled();
});
it('shows an error and does not download for unsupported result types', () => {
const raw = {
type: 'raw',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const { result } = renderHook(() => useClientExport({ response: raw }));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).not.toHaveBeenCalled();
expect(mockMessageError).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,93 @@
import { message } from 'antd';
import { REQUEST_TYPES } from 'api/v5/queryRange/constants';
import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
import { toCsv } from 'lib/exportData/toCsv';
import { toJsonl } from 'lib/exportData/toJsonl';
import { ExportFormat, SerializedTable } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
[ExportFormat.Csv]: { mime: 'text/csv;charset=utf-8;', extension: 'csv' },
[ExportFormat.Jsonl]: {
mime: 'application/x-ndjson;charset=utf-8;',
extension: 'jsonl',
},
};
// Picks the serializer for the response's request type. Narrows the results
// union via the response discriminant. scalar lands with #5591; raw/trace are
// server-exported, distribution is never emitted.
function serialize(
response: QueryRangeResponseV5,
yAxisUnit?: string,
legendMap?: Record<string, string>,
query?: Query,
): SerializedTable {
if (response.type === REQUEST_TYPES.TIME_SERIES) {
return exportTimeseriesData({
data: response.data.results as TimeSeriesData[],
yAxisUnit,
legendMap,
query,
});
}
throw new Error(`Export is not supported for "${response.type}" results`);
}
interface UseClientExportProps {
response?: QueryRangeResponseV5;
query?: Query;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
format: ExportFormat;
}
interface UseClientExportReturn {
isExporting: boolean;
handleExport: (options: ClientExportOptions) => void;
}
export function useClientExport({
response, // currently supports only qb v5 response. Can extend to support future responses.
query,
yAxisUnit,
fileName = 'export',
legendMap,
}: UseClientExportProps): UseClientExportReturn {
const [isExporting, setIsExporting] = useState<boolean>(false);
const handleExport = useCallback(
({ format }: ClientExportOptions): void => {
if (!response) {
return;
}
setIsExporting(true);
try {
const table = serialize(response, yAxisUnit, legendMap, query);
const content =
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
const { mime, extension } = FORMAT_META[format];
downloadFile(content, getTimestampedFileName(fileName, extension), mime);
} catch {
message.error('Failed to export data. Please try again.');
} finally {
setIsExporting(false);
}
},
[response, query, yAxisUnit, fileName, legendMap],
);
return { isExporting, handleExport };
}

View File

@@ -0,0 +1,56 @@
import { downloadFile, getTimestampedFileName } from '../downloadFile';
// jsdom doesn't implement the object-URL APIs; define stubs so jest.spyOn can wrap them.
if (typeof URL.createObjectURL !== 'function') {
URL.createObjectURL = (): string => '';
}
if (typeof URL.revokeObjectURL !== 'function') {
URL.revokeObjectURL = (): void => undefined;
}
describe('downloadFile', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('builds a blob anchor, clicks it, and revokes the object URL', () => {
const click = jest.fn();
const remove = jest.fn();
const anchor = {
href: '',
download: '',
click,
remove,
} as unknown as HTMLAnchorElement;
(
jest.spyOn(document, 'createElement') as unknown as jest.Mock
).mockReturnValue(anchor);
const createObjectURL = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock');
const revokeObjectURL = jest.spyOn(URL, 'revokeObjectURL');
downloadFile('hello', 'export.csv', 'text/csv');
expect(anchor.download).toBe('export.csv');
expect(anchor.href).toBe('blob:mock');
expect(click).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock');
});
});
describe('getTimestampedFileName', () => {
afterEach(() => {
jest.useRealTimers();
});
it('appends a local timestamp between base and extension', () => {
jest.useFakeTimers().setSystemTime(new Date(2026, 6, 8, 14, 32, 5));
expect(getTimestampedFileName('logs-timeseries', 'csv')).toBe(
'logs-timeseries-2026-07-08_14-32-05.csv',
);
});
});

View File

@@ -0,0 +1,188 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { exportTimeseriesData } from '../exportTimeseriesData';
const iso = (ms: number): string => new Date(ms).toISOString();
function makeSeries(
labels: Record<string, string>,
values: [number, number][],
): TimeSeries {
return {
labels: Object.entries(labels).map(([name, value]) => ({
key: { name },
value,
})),
values: values.map(([timestamp, value]) => ({ timestamp, value })),
};
}
function makeQuery(
queryName: string,
buckets: { index?: number; alias?: string; series: TimeSeries[] }[],
): TimeSeriesData {
return {
queryName,
aggregations: buckets.map((bucket, i) => ({
index: bucket.index ?? i,
alias: bucket.alias ?? '',
meta: {},
series: bucket.series,
})),
};
}
describe('exportTimeseriesData', () => {
it('one row per point: query column, label columns, unit in value header, legend naming', () => {
const data = [
makeQuery('A', [
{
series: [
makeSeries({ service_name: 'frontend' }, [
[1000, 12],
[2000, 15],
]),
],
},
]),
];
const table = exportTimeseriesData({
data,
yAxisUnit: 'ms',
legendMap: { A: '{{service_name}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service_name',
'value (ms)',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'frontend', 'frontend', 12],
[iso(2000), 'A', 'frontend', 'frontend', 15],
]);
});
it('no legend falls back to the label-set name from getLabelName', () => {
const data = [
makeQuery('A', [
{ series: [makeSeries({ service_name: 'frontend' }, [[1000, 12]])] },
]),
];
const table = exportTimeseriesData({ data });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', '{service_name="frontend"}', 'frontend', 12],
]);
});
it('multi-query: query is its own column; label keys are unioned', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
];
const table = exportTimeseriesData({
data,
legendMap: { A: '{{service}}', B: '{{service}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service',
'value',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'x', 'x', 1],
[iso(1000), 'B', 'y', 'y', 2],
]);
});
it('multi-aggregation with the builder query: names match the chart legend', () => {
const data = [
makeQuery('A', [
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
{
index: 1,
alias: '__result_1',
series: [makeSeries({}, [[1000, 300]])],
},
]),
makeQuery('B', [
{
index: 0,
alias: '__result_0',
series: [
makeSeries({ 'cloud.account.id': 'signoz-staging' }, [[1000, 7]]),
],
},
]),
];
const query = {
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregations: [
{ expression: 'count()' },
{ expression: 'avg(code.lineno)' },
],
groupBy: [],
},
{
queryName: 'B',
dataSource: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ key: 'cloud.account.id' }],
},
],
queryFormulas: [],
},
} as unknown as Query;
const table = exportTimeseriesData({ data, query });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'count()-A', '', 5],
[iso(1000), 'A', 'avg(code.lineno)-A', '', 300],
[iso(1000), 'B', '{cloud.account.id="signoz-staging"}', 'signoz-staging', 7],
]);
});
it('multi-aggregation without the builder query: falls back to base names', () => {
const data = [
makeQuery('A', [
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
{
index: 1,
alias: '__result_1',
series: [makeSeries({}, [[1000, 300]])],
},
]),
];
const table = exportTimeseriesData({ data });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'A', 5],
[iso(1000), 'A', 'A', 300],
]);
});
it('empty data: returns a headers-only table', () => {
expect(exportTimeseriesData({ data: [] })).toStrictEqual({
headers: ['timestamp', 'query', 'series', 'value'],
rows: [],
});
});
});

View File

@@ -0,0 +1,42 @@
import { toCsv } from '../toCsv';
import { toJsonl } from '../toJsonl';
import { SerializedTable } from '../types';
const table: SerializedTable = {
headers: ['timestamp', 'value'],
rows: [
['t1', 12],
['t2', 15],
],
};
describe('toCsv', () => {
it('emits a header row then one row per record, in column order', () => {
expect(toCsv(table).split(/\r?\n/)).toStrictEqual([
'timestamp,value',
't1,12',
't2,15',
]);
});
it('quotes values containing the delimiter', () => {
const csv = toCsv({ headers: ['name', 'value'], rows: [['a,b', 1]] });
expect(csv.split(/\r?\n/)).toStrictEqual(['name,value', '"a,b",1']);
});
it('emits only the header row when there are no data rows', () => {
expect(toCsv({ headers: ['timestamp'], rows: [] })).toBe('timestamp\r\n');
});
});
describe('toJsonl', () => {
it('emits one JSON object per row keyed by header', () => {
expect(toJsonl(table)).toBe(
'{"timestamp":"t1","value":12}\n{"timestamp":"t2","value":15}',
);
});
it('emits an empty string when there are no rows', () => {
expect(toJsonl({ headers: ['timestamp'], rows: [] })).toBe('');
});
});

View File

@@ -0,0 +1,29 @@
/** Triggers a browser download of in-memory string content as a file. */
export function downloadFile(
content: string,
fileName: string,
mime: string,
): void {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
link.remove();
URL.revokeObjectURL(url);
}
/** `base` + local timestamp + extension, e.g. `logs-timeseries-2026-07-08_14-32-05.csv`.
* Keeps repeated exports from colliding and records when the export was taken. */
export function getTimestampedFileName(
base: string,
extension: string,
): string {
const now = new Date();
const pad = (value: number): string => String(value).padStart(2, '0');
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate(),
)}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
return `${base}-${stamp}.${extension}`;
}

View File

@@ -0,0 +1,154 @@
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { SerializedTable } from './types';
interface ExportTimeseriesDataArgs {
data: TimeSeriesData[];
yAxisUnit?: string;
legendMap?: Record<string, string>;
// The builder query that produced the data — lets series names resolve
// aggregation aliases/expressions exactly like the chart legend does.
query?: Query;
}
// One row of the flattened V5 tree: a single (query, aggregation, label-set) series.
interface FlatSeries {
queryName: string;
labels: Record<string, string>;
name: string;
values: { timestamp: number; value: number }[];
}
// V5 labels [{key:{name}, value}] → {name: value} (the getLabelName contract).
function foldLabels(labels: TimeSeries['labels']): Record<string, string> {
const record: Record<string, string> = {};
(labels ?? []).forEach((label) => {
if (label.key?.name) {
record[label.key.name] = String(label.value);
}
});
return record;
}
// Series display name, matching the chart legend: getLabelName for the base
// (legend template / label-set), then getLegend to resolve the aggregation
// alias/expression from the builder query (the response only carries
// auto-generated `__result_N` aliases). Same chain the uPlot layer uses.
function seriesName(args: {
labels: Record<string, string>;
queryName: string;
legend: string;
aggIndex: number;
alias: string;
query?: Query;
}): string {
const { labels, queryName, legend, aggIndex, alias, query } = args;
const baseName = getLabelName(labels, queryName, legend);
if (!query) {
return baseName;
}
const legacySeries = {
queryName,
metric: labels,
values: [],
metaData: { alias, index: aggIndex, queryName },
} as QueryData;
return getLegend(legacySeries, query, baseName);
}
// Walk results → aggregations → series into a flat, named list.
function flatten(
data: TimeSeriesData[],
legendMap?: Record<string, string>,
query?: Query,
): FlatSeries[] {
const flat: FlatSeries[] = [];
data.forEach((result) => {
const queryName = result.queryName ?? '';
const legend = legendMap?.[queryName] ?? '';
(result.aggregations ?? []).forEach((bucket) => {
(bucket.series ?? []).forEach((series) => {
const labels = foldLabels(series.labels);
flat.push({
queryName,
labels,
name: seriesName({
labels,
queryName,
legend,
aggIndex: bucket.index ?? 0,
alias: bucket.alias ?? '',
query,
}),
values: (series.values ?? []).map((value) => ({
timestamp: value.timestamp,
value: value.value,
})),
});
});
});
});
return flat;
}
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
}
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString();
}
// Tidy (LONG) layout: one row per (series, timestamp). query is its own column.
function buildTable(flat: FlatSeries[], yAxisUnit?: string): SerializedTable {
const labelKeySet = new Set<string>();
flat.forEach((series) => {
Object.keys(series.labels).forEach((key) => labelKeySet.add(key));
});
const labelKeys = Array.from(labelKeySet).sort();
const headers = [
'timestamp',
'query',
'series',
...labelKeys,
withUnit('value', yAxisUnit),
];
const rows: (string | number)[][] = [];
flat.forEach((series) => {
series.values.forEach(({ timestamp, value }) => {
rows.push([
toIso(timestamp),
series.queryName,
series.name,
...labelKeys.map((key) => series.labels[key] ?? ''),
value,
]);
});
});
return { headers, rows };
}
/**
* Serializes a V5 time_series result into a format-agnostic tidy table — one
* row per (series, timestamp), labels as columns, raw values.
* Pure — walks the V5 tree directly; series names match the chart legend.
*/
export function exportTimeseriesData({
data,
yAxisUnit,
legendMap,
query,
}: ExportTimeseriesDataArgs): SerializedTable {
return buildTable(flatten(data, legendMap, query), yAxisUnit);
}

View File

@@ -0,0 +1,8 @@
import { unparse } from 'papaparse';
import { SerializedTable } from './types';
/** Serializes a table to CSV. `fields` pins column order regardless of row keys. */
export function toCsv(table: SerializedTable): string {
return unparse({ fields: table.headers, data: table.rows });
}

View File

@@ -0,0 +1,12 @@
import { SerializedTable } from './types';
/** Serializes a table to newline-delimited JSON: one object per row, keyed by header. */
export function toJsonl(table: SerializedTable): string {
return table.rows
.map((row) =>
JSON.stringify(
Object.fromEntries(table.headers.map((header, i) => [header, row[i]])),
),
)
.join('\n');
}

View File

@@ -0,0 +1,13 @@
/** Format-agnostic tabular result produced by every exporter. Consumed by the
* CSV/JSONL formatters */
export interface SerializedTable {
headers: string[];
// One entry per header, in header order. Empty string marks a gap.
rows: (string | number)[][];
}
/** File formats a client-side export can be downloaded as. */
export enum ExportFormat {
Csv = 'csv',
Jsonl = 'jsonl',
}

View File

@@ -1,5 +1,10 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
clearViewPanelHandoff,
readViewPanelHandoff,
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useSwitchToViewMode } from '../useSwitchToViewMode';
@@ -18,11 +23,16 @@ jest.mock('hooks/useUrlQuery', () => ({
}));
const query = { queryType: 'builder' } as unknown as Query;
const spec = {
plugin: { kind: 'signoz/TimeSeriesPanel' },
display: { name: 'CPU' },
} as unknown as DashboardtypesPanelSpecDTO;
describe('useSwitchToViewMode', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSearch = '';
clearViewPanelHandoff();
});
function invoke(): void {
@@ -32,6 +42,7 @@ describe('useSwitchToViewMode', () => {
panelId: 'panel-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
spec,
}),
);
result.current();
@@ -52,6 +63,21 @@ describe('useSwitchToViewMode', () => {
).toStrictEqual(query);
});
it('stashes the live draft spec in the sessionStorage handoff, not the URL', () => {
invoke();
expect(readViewPanelHandoff('dash-1', 'panel-1')).toStrictEqual(spec);
// The spec must not bloat the URL — the config-only display name never leaks into it.
expect(mockSafeNavigate.mock.calls[0][0]).not.toContain('CPU');
});
it('scopes the handoff to the exact dashboard + panel', () => {
invoke();
expect(readViewPanelHandoff('dash-1', 'other-panel')).toBeNull();
expect(readViewPanelHandoff('other-dash', 'panel-1')).toBeNull();
});
it('carries dashboard variables through and drops other editor URL state', () => {
mockSearch = 'variables=%7B%22a%22%3A1%7D&compositeQuery=stale';
invoke();

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { generatePath } from 'react-router-dom';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -7,27 +8,35 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { writeViewPanelHandoff } from '../../PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
interface UseSwitchToViewModeArgs {
dashboardId: string;
panelId: string;
panelType: PANEL_TYPES;
query: Query;
/** Live (un-saved) draft spec — the query rides in the URL, the rest via the handoff. */
spec: DashboardtypesPanelSpecDTO;
}
/**
* Callback that leaves the editor for the dashboard with this panel expanded in the
* View modal, seeded with the live (un-saved) query — V1's "Switch to View Mode".
* Leaves the editor for the dashboard with this panel expanded in the View modal, seeded with
* the live (un-saved) query + config — V1's "Switch to View Mode". The query rides in the URL
* (`compositeQuery`); the rest of the spec rides in a tab-scoped sessionStorage handoff.
*/
export function useSwitchToViewMode({
dashboardId,
panelId,
panelType,
query,
spec,
}: UseSwitchToViewModeArgs): () => void {
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
return useCallback((): void => {
writeViewPanelHandoff({ dashboardId, panelId, spec });
const params = new URLSearchParams();
const variables = urlQuery.get(QueryParams.variables);
if (variables) {
@@ -42,5 +51,5 @@ export function useSwitchToViewMode({
safeNavigate(
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
);
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query]);
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query, spec]);
}

View File

@@ -38,6 +38,8 @@ import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
interface PanelEditorContainerProps {
dashboardId: string;
@@ -204,6 +206,7 @@ function PanelEditorContainer({
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
@@ -234,6 +237,13 @@ function PanelEditorContainer({
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
@@ -243,7 +253,7 @@ function PanelEditorContainer({
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={onSwitchToView}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
<ResizablePanelGroup

View File

@@ -16,6 +16,8 @@ import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
import logEvent from 'api/common/logEvent';
import { DashboardEvents } from 'pages/DashboardPageV2/constants/events';
interface ViewPanelModalContentProps {
panel: DashboardtypesPanelDTO;
@@ -97,6 +99,14 @@ function ViewPanelModalContent({
return null;
}
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
};
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
@@ -114,10 +124,7 @@ function ViewPanelModalContent({
refreshWindow();
}
}}
onSwitchToEdit={(): void =>
// Carry the drilldown edits so the editor opens on them, not the saved panel.
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}

View File

@@ -13,6 +13,7 @@ import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/
import type { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
interface ViewPanelModalHeaderProps {
selectedInterval: Time | CustomTimeType;
@@ -64,6 +65,10 @@ function ViewPanelModalHeader({
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const canSwitchToEdit = canEditDashboard && !isLocked;
return (
<div className={styles.toolbar}>
@@ -75,15 +80,17 @@ function ViewPanelModalHeader({
onChange={onChangePanelKind}
/>
</div>
<Button
variant="outlined"
color="secondary"
prefix={<PenLine />}
onClick={onSwitchToEdit}
data-testid="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
{canSwitchToEdit && (
<Button
variant="outlined"
color="secondary"
prefix={<PenLine />}
onClick={onSwitchToEdit}
data-testid="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
)}
<Button
variant="link"
color="primary"

View File

@@ -23,8 +23,11 @@ import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { EQueryType } from 'types/common/dashboard';
import { readViewPanelHandoff } from './viewPanelHandoffStore';
interface UseViewPanelModeArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -77,25 +80,33 @@ export function useViewPanelMode({
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
// Seed the draft from the URL (`compositeQuery` + `graphType`) when present, else the saved
// panel — mount-only, so a refresh re-seeds from the URL and in-modal edits survive (V1 parity).
const urlQuery = useGetCompositeQueryParam();
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
const dashboardId = useDashboardStore((s) => s.dashboardId);
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
[],
);
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
const compositeQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() =>
urlQuery
compositeQuery
? {
...panel,
spec: buildViewPanelSpec({
spec: panel.spec,
query: urlQuery,
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
}),
}
: panel,
: { ...panel, spec: baseSpec },
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);

View File

@@ -0,0 +1,43 @@
import getSessionStorage from 'api/browser/sessionstorage/get';
import removeSessionStorage from 'api/browser/sessionstorage/remove';
import setSessionStorage from 'api/browser/sessionstorage/set';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { SESSIONSTORAGE } from 'constants/sessionStorage';
interface ViewPanelHandoff {
/** Correlator: the read returns the spec only for this exact dashboard + panel. */
dashboardId: string;
panelId: string;
spec: DashboardtypesPanelSpecDTO;
}
/**
* Tab-scoped handoff of the editor's un-saved draft spec to the View modal, so "Switch to View
* Mode" carries config edits — not just the query, which stays in the URL. sessionStorage keeps
* the link small yet survives a refresh, and clears the edits when the tab closes.
*/
export function writeViewPanelHandoff(handoff: ViewPanelHandoff): void {
setSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF, JSON.stringify(handoff));
}
export function readViewPanelHandoff(
dashboardId: string,
panelId: string,
): DashboardtypesPanelSpecDTO | null {
const raw = getSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
if (!raw) {
return null;
}
try {
const handoff = JSON.parse(raw) as ViewPanelHandoff;
return handoff.dashboardId === dashboardId && handoff.panelId === panelId
? handoff.spec
: null;
} catch {
return null;
}
}
export function clearViewPanelHandoff(): void {
removeSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
}

View File

@@ -6,6 +6,8 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
@@ -41,10 +43,11 @@ export function useViewPanel(): UseViewPanelApi {
// Copy before mutating: useUrlQuery returns a memoized instance.
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
// Drop any leftover in-modal query/kind so a plain View opens on the saved
// panel, not a stale URL query the modal would otherwise hydrate from.
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
// on the saved panel, not stale state the modal would otherwise hydrate from.
next.delete(QueryParams.compositeQuery);
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
@@ -55,6 +58,8 @@ export function useViewPanel(): UseViewPanelApi {
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
next.set(QueryParams.graphType, panelType);
// A grid drilldown opens on the saved panel, never a stale editor handoff.
clearViewPanelHandoff();
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
next.set(
@@ -73,6 +78,7 @@ export function useViewPanel(): UseViewPanelApi {
// (the in-modal query builder writes compositeQuery, V1 parity).
next.delete(QueryParams.compositeQuery);
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
const search = next.toString();
safeNavigate(search ? `${pathname}?${search}` : pathname);
}, [pathname, safeNavigate, urlQuery]);

View File

@@ -3,7 +3,10 @@ import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -48,9 +51,10 @@ function DynamicSelector({
onChange,
onAutoSelect,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
@@ -96,8 +100,10 @@ function DynamicSelector({
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)

View File

@@ -3,7 +3,10 @@ import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -44,9 +47,10 @@ function QuerySelector({
onChange,
onAutoSelect,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const {
@@ -80,8 +84,10 @@ function QuerySelector({
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)

View File

@@ -2,6 +2,10 @@
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { usePanelQuery } from '../usePanelQuery';
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
@@ -432,4 +436,28 @@ describe('usePanelQuery', () => {
expect(result.current.pagination?.pageIndex).toBe(0);
});
});
describe('cacheTime (auto-refresh OOM guard)', () => {
const withAutoRefreshDisabled = (disabled: boolean): void => {
mockUseSelector.mockImplementation((selector: unknown) =>
(selector as (state: { globalTime: unknown }) => unknown)({
globalTime: { ...DEFAULT_GLOBAL_TIME, isAutoRefreshDisabled: disabled },
}),
);
};
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
withAutoRefreshDisabled(true);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME);
});
it('drops cacheTime to 0 when auto-refresh is enabled', () => {
withAutoRefreshDisabled(false);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
});
});
});

View File

@@ -13,6 +13,8 @@ export interface UseGetQueryRangeV5Args {
enabled: boolean;
/** Retain prior data across a key change (list paging) so the table + pager stay mounted. */
keepPreviousData?: boolean;
/** Unused-entry TTL; callers drop to 0 under auto-refresh to bound cache growth (V1 parity). */
cacheTime?: number;
}
/**
@@ -46,6 +48,7 @@ export function useGetQueryRangeV5({
queryKey,
enabled,
keepPreviousData,
cacheTime,
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
return useQuery<QueryRangeV5200, Error>({
queryKey,
@@ -53,5 +56,6 @@ export function useGetQueryRangeV5({
enabled,
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,
});
}

View File

@@ -4,6 +4,10 @@ import { useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -110,6 +114,7 @@ export function usePanelQuery({
selectedTime: globalSelectedInterval,
maxTime,
minTime,
isAutoRefreshDisabled,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Resolved variable values for this dashboard, published by useResolvedVariables.
@@ -243,6 +248,10 @@ export function usePanelQuery({
enabled: enabled && runnable && !isWaitingOnVariable,
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
keepPreviousData: isPaginated,
// 0 under auto-refresh so time-keyed entries don't accumulate and OOM the tab (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
});
const queryClient = useQueryClient();

View File

@@ -0,0 +1,4 @@
export enum DashboardEvents {
SWITCH_TO_EDIT_MODE = 'View Panel: Switch to edit mode',
SWITCH_TO_VIEW_MODE = 'Edit Panel: Switch to view mode',
}

View File

@@ -5,7 +5,7 @@ import { ArrowUpRight } from '@signozhq/icons';
import styles from './MissingSpansBanner.module.scss';
const MISSING_SPANS_DOCS_URL =
'https://signoz.io/docs/userguide/traces/#missing-spans';
'https://signoz.io/docs/traces-management/troubleshooting/faqs/#q-why-are-some-spans-missing-from-a-trace';
function MissingSpansBanner(): JSX.Element | null {
// Session-only dismissal — not persisted, so the banner returns on reload.

View File

@@ -14,7 +14,8 @@ const DOCLINKS = {
'https://signoz.io/docs/userguide/logs_clickhouse_queries/',
QUERY_CLICKHOUSE_METRICS:
'https://signoz.io/docs/userguide/write-a-metrics-clickhouse-query/',
AGENT_SKILL_INSTALL: 'https://signoz.io/docs/ai/agent-skills/#installation',
AGENT_SKILL_INSTALL:
'https://signoz.io/docs/ai/agent-skills/#install-the-plugin',
};
export default DOCLINKS;

View File

@@ -98,7 +98,7 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
Description: "Returns all mappers belonging to a mapping group.",
Request: nil,
RequestContentType: "",
Response: new(spantypes.GettableSpanMapperGroups),
Response: new(spantypes.GettableSpanMappers),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -1,27 +0,0 @@
{
"id": "cloudsql",
"title": "GCP Cloud SQL",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud SQL Overview",
"description": "Overview of GCP Cloud SQL metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor GCP Cloud SQL with SigNoz
Collect key GCP Cloud SQL metrics and view them with an out of the box dashboard.

View File

@@ -0,0 +1,106 @@
{
"id": "cloudsql_postgres",
"title": "GCP Cloud SQL for PostgreSQL",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "cloudsql.googleapis.com/database/up",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/cpu/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/memory/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/memory/usage",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/bytes_used",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/num_backends",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/transaction_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
"unit": "Microseconds",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
"unit": "Microseconds",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag",
"unit": "Bytes",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud SQL for PostgreSQL Overview",
"description": "Overview of GCP Cloud SQL for PostgreSQL metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor GCP Cloud SQL for PostgreSQL with SigNoz
Collect key GCP Cloud SQL for PostgreSQL metrics and view them with an out of the box dashboard.

View File

@@ -16,11 +16,11 @@ const (
// Documentation links — one per component. User-facing; emitted on missing-entries.
const (
docLinkHostMetricsReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/hostmetrics/#configure-the-hostmetrics-receiver"
docLinkKubeletStatsReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#setup-kubelet-stats-receiver"
docLinkK8sClusterReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#setup-k8s-cluster-receiver"
docLinkResourceDetectionProcessor = "https://signoz.io/docs/infrastructure-monitoring/user-guides/hostmetrics/#configure-the-resourcedetection-processor"
docLinkK8sAttributesProcessor = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#3-setup-k8sattributesprocessor-to-enable-kubernetes-metadata"
docLinkHostMetricsReceiver = "https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#configure-the-hostmetrics-receiver"
docLinkKubeletStatsReceiver = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#2-configure-the-kubelet-stats-receiver"
docLinkK8sClusterReceiver = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#1-configure-the-k8s-cluster-receiver"
docLinkResourceDetectionProcessor = "https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#configure-the-processors"
docLinkK8sAttributesProcessor = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#3-enable-kubernetes-metadata"
)
var (

View File

@@ -124,7 +124,7 @@ const (
// alert related constants
const (
// AlertHelpPage is used in case default alert repo url is not set
AlertHelpPage = "https://signoz.io/docs/userguide/alerts-management/#generator-url"
AlertHelpPage = "https://signoz.io/docs/alerts/"
AlertTimeFormat = "2006-01-02 15:04:05"
)

View File

@@ -13,10 +13,10 @@ const (
// to multiple field context / data type combinations.
FieldContextDataTypesDocURL = "https://signoz.io/docs/userguide/field-context-data-types/"
// KeyNotFoundDocURL documents the "key not found" error.
KeyNotFoundDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#key-fieldname-not-found"
KeyNotFoundDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#q-im-getting-key-fieldname-not-found--why-cant-it-find-my-field"
// Doc URLs for the has/hasAny/hasAll and hasToken "unsupported" errors.
functionBodyJSONSearchDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#function-supports-only-body-json-search"
functionBodyJSONSearchDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#q-im-getting-function-supports-only-body-json-search--can-i-use-functions-on-other-fields"
hasTokenFunctionDocURL = "https://signoz.io/docs/userguide/functions-reference/#hastoken-function"
)

View File

@@ -169,7 +169,7 @@ func TestVisitKey(t *testing.T) {
},
expectedKeys: []telemetrytypes.TelemetryFieldKey{},
expectedErrors: []string{"key `unknown_key` not found"},
expectedMainErrURL: "https://signoz.io/docs/userguide/search-troubleshooting/#key-fieldname-not-found",
expectedMainErrURL: "https://signoz.io/docs/userguide/search-troubleshooting/#q-im-getting-key-fieldname-not-found--why-cant-it-find-my-field",
expectedWarnings: nil,
expectedMainWrnURL: "",
},
@@ -351,7 +351,7 @@ func TestVisitKey(t *testing.T) {
ignoreNotFoundKeys: false,
expectedKeys: []telemetrytypes.TelemetryFieldKey{},
expectedErrors: []string{"key `unknown_key` not found"},
expectedMainErrURL: "https://signoz.io/docs/userguide/search-troubleshooting/#key-fieldname-not-found",
expectedMainErrURL: "https://signoz.io/docs/userguide/search-troubleshooting/#q-im-getting-key-fieldname-not-found--why-cant-it-find-my-field",
expectedWarnings: nil,
expectedMainWrnURL: "",
},

View File

@@ -53,7 +53,7 @@ const (
// Documentation URLs attached to function-call errors so the visitor can
// surface them to the user without knowing function-specific details.
hasTokenFunctionDocURL = "https://signoz.io/docs/userguide/functions-reference/#hastoken-function"
functionBodyJSONSearchDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#function-supports-only-body-json-search"
functionBodyJSONSearchDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#q-im-getting-function-supports-only-body-json-search--can-i-use-functions-on-other-fields"
)
var (

View File

@@ -10,7 +10,6 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -24,10 +23,119 @@ 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 {
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
// 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,
}
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -1,353 +0,0 @@
// 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

@@ -41,7 +41,7 @@ var (
AzureServiceRedis = ServiceID{valuer.NewString("redis")}
// GCP services.
GCPServiceCloudSQL = ServiceID{valuer.NewString("cloudsql")}
GCPServiceCloudSQLPostgres = ServiceID{valuer.NewString("cloudsql_postgres")}
)
func (ServiceID) Enum() []any {
@@ -73,7 +73,7 @@ func (ServiceID) Enum() []any {
AzureServiceCosmosDB,
AzureServiceCassandraDB,
AzureServiceRedis,
GCPServiceCloudSQL,
GCPServiceCloudSQLPostgres,
}
}
@@ -111,7 +111,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
AzureServiceRedis,
},
CloudProviderTypeGCP: {
GCPServiceCloudSQL,
GCPServiceCloudSQLPostgres,
},
}

View File

@@ -7,6 +7,7 @@ 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"
@@ -21,7 +22,6 @@ 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 {
@@ -413,26 +413,27 @@ 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, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
compositeQueries = append(compositeQueries, migrate.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, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, migrate.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, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -106,7 +106,7 @@ func (d *DashboardSpec) validatePanels() error {
}
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) != 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
}
allowed := allowedQueryKinds[panelKind]
for qi, q := range panel.Spec.Queries {
@@ -269,8 +269,8 @@ func (d *DashboardSpec) validateLayouts() error {
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
if grid.Display != nil {
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxLayoutTitleLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxLayoutTitleLen, n)
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
}
}
if err := validateGridLayoutGeometry(grid, li); err != nil {

View File

@@ -1059,34 +1059,6 @@ 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": {
@@ -1669,61 +1641,55 @@ func TestInvalidateDuplicatePanelReference(t *testing.T) {
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
}
// Every display name — dashboard, panel, variable — is bounded at MaxDisplayNameLen,
// while the grid layout title has its own, larger bound (MaxLayoutTitleLen). The name
// is one over the relevant limit in each case, and the message reads "<json path>:
// <field> name must be at most ...", pairing the locatable path (like the other spec
// errors) with a human field label.
// Every display name — dashboard, panel, variable — and the grid layout title is
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
// the message reads "<json path>: <field> name must be at most ...", pairing the
// locatable path (like the other spec errors) with a human field label.
func TestInvalidateDisplayNameTooLong(t *testing.T) {
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
testCases := []struct {
scenario string
limit int
dashboardJSONFmt string
expectedPath string
expectedLabel string
scenario string
dashboardJSON string
expectedPath string
expectedLabel string
}{
{
scenario: "dashboard display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"display": {"name": "%s"}, "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
scenario: "dashboard display name",
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
scenario: "panel display name",
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
scenario: "list variable display name",
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
scenario: "text variable display name",
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
limit: MaxLayoutTitleLen,
dashboardJSONFmt: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
scenario: "layout title",
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
tooLong := strings.Repeat("x", testCase.limit+1)
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", testCase.limit, testCase.limit+1)
_, err := unmarshalDashboard([]byte(fmt.Sprintf(testCase.dashboardJSONFmt, tooLong)))
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
require.Error(t, err)
// Message is "<path>: <label> name must be at most N characters, got M".
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg

View File

@@ -16,14 +16,10 @@ import (
"github.com/swaggest/jsonschema-go"
)
// MaxDisplayNameLen bounds the human-readable display names — dashboard, panel,
// and variable. The grid layout title has its own, larger bound (MaxLayoutTitleLen).
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
// and variable display names, plus the grid layout title.
const MaxDisplayNameLen = 128
// MaxLayoutTitleLen bounds a grid layout title. It is larger than MaxDisplayNameLen
// because v1 section (row) titles ran longer.
const MaxLayoutTitleLen = 256
type Display struct {
Name string `json:"name" required:"true"`
Description string `json:"description,omitempty"`

View File

@@ -252,20 +252,14 @@ type Legend struct {
}
type ThresholdWithLabel struct {
// 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"`
Value float64 `json:"value" validate:"required" required:"true"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label"`
}
type ComparisonThreshold struct {
// Value is a pointer so a threshold at 0 is valid (see ThresholdWithLabel.Value).
Value *float64 `json:"value" validate:"required" required:"true" nullable:"false"`
Value float64 `json:"value" validate:"required" required:"true"`
Operator ComparisonOperator `json:"operator"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`

View File

@@ -1,94 +0,0 @@
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(retainPlacedWidgets(storable.Data))
spec := DashboardSpec{
Display: Display{Name: clipName(title, MaxDisplayNameLen), 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

@@ -1,214 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strconv"
"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 {
v, present := m[key]
if !present || v == nil {
return 0
}
f, ok := coerceFloat(v)
if !ok {
d.noteMalformedField(key, v)
return 0
}
return f
}
// coerceFloat accepts a JSON number or a numeric string (v1 sometimes stores
// numbers like softMin as quoted strings). A blank string is "unset", not a
// number, so it fails to coerce.
func coerceFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0, false
}
return f, true
}
return 0, false
}
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
}
// A blank string means "unset" (v1's empty softMin/softMax), not malformed.
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
return nil
}
f, ok := coerceFloat(v)
if !ok {
d.noteMalformedField(key, v)
return nil
}
return &f
}
// clipName truncates s to at most limit runes so a v1 name over a v2 length bound
// (MaxDisplayNameLen / MaxLayoutTitleLen) is shortened rather than failing migration.
func clipName(s string, limit int) string {
r := []rune(s)
if len(r) <= limit {
return s
}
return string(r[:limit])
}
func (d *v1Decoder) readStringMap(m map[string]any, key string) map[string]string {
// An empty list is a stand-in for an empty map here; tolerate it silently
// rather than flagging the wrong-type as malformed.
if s, ok := m[key].([]any); ok && len(s) == 0 {
return nil
}
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

@@ -1,296 +0,0 @@
package dashboardtypes
import (
"sort"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
)
// panelRefPrefix is the JSON-ref prefix a grid item uses to point at a panel:
// "#/spec/panels/<id>".
const panelRefPrefix = "#/spec/panels/"
// ══════════════════════════════════════════════
// 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
}
// retainPlacedWidgets drops widgets the v1 layout never places, returning the
// filtered widgets. v1 doesn't render an unplaced widget, so converting it — and
// noting any problems it has — is pure noise; filter before conversion so only
// rendered widgets reach convertV1Panels. A non-array widgets value is returned
// untouched for convertV1Panels to flag; non-map entries are kept so it still
// flags them as malformed.
func retainPlacedWidgets(data StorableDashboardData) any {
widgets, ok := data["widgets"].([]any)
if !ok {
return data["widgets"]
}
placed := placedWidgetIDs(data)
kept := make([]any, 0, len(widgets))
for _, w := range widgets {
wm, ok := w.(map[string]any)
if !ok {
kept = append(kept, w) // malformed entry — leave it for convertV1Panels to note
continue
}
if id, _ := wm["id"].(string); placed[id] {
kept = append(kept, w)
}
}
return kept
}
// placedWidgetIDs returns the set of widget ids the v1 layout actually renders:
// every id in `layout`, plus the collapsed-row children stashed in panelMap.
// Read leniently (no malformed notes) — convertV1Layouts re-reads these and
// reports any genuine problems.
func placedWidgetIDs(data StorableDashboardData) map[string]bool {
ids := make(map[string]bool)
if layout, ok := data["layout"].([]any); ok {
for _, e := range layout {
if m, ok := e.(map[string]any); ok {
if i, ok := m["i"].(string); ok && i != "" {
ids[i] = true
}
}
}
}
if panelMap, ok := data["panelMap"].(map[string]any); ok {
for _, v := range panelMap {
m, ok := v.(map[string]any)
if !ok {
continue
}
widgets, ok := m["widgets"].([]any)
if !ok {
continue
}
for _, w := range widgets {
if wm, ok := w.(map[string]any); ok {
if i, ok := wm["i"].(string); ok && i != "" {
ids[i] = true
}
}
}
}
}
return ids
}
// 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: clipName(row.title, MaxLayoutTitleLen),
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: panelRefPrefix + 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

@@ -1,487 +0,0 @@
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
}
// A non-string (or missing) id can't be referenced by any layout entry, and
// v1 doesn't render such widgets either — skip silently, don't flag it as
// malformed. Read directly (not via readString) to avoid a malformed note.
id, ok := widget["id"].(string)
if !ok || id == "" {
continue
}
var panel *Panel
panelType := d.readString(widget, "panelTypes")
switch panelType {
case "graph":
panel = d.convertGraphWidget(widget)
case "time_series", "TIME_SERIES":
// Malformed panelTypes: the canonical v1 value is "graph". Some dashboards
// stored the v2/enum-style name instead; accept it as a time-series 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
}
if len(panel.Spec.Queries) == 0 {
d.note("widgets[%d] %q produced no queries; skipping", i, id)
continue
}
// A lone metric query with no aggregation or no metric name can't render (v1)
// and fails v2 validation; drop the widget silently, as v1 effectively does.
if isUnrenderableMetricQuery(panel) {
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: clipName(d.readString(w, "title"), MaxDisplayNameLen), 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:
return SpanGaps{FillOnlyBelow: true, FillLessThan: time.Duration(v * float64(time.Second)).String()}
}
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
case "":
// v1 often leaves the operator empty; default to "above" without flagging.
return ComparisonOperatorAbove
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

@@ -1,372 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"strconv"
"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
}
// List panels accept only a bare BuilderQuery — never a CompositeQuery. Keep the
// first query and drop the rest so a multi-query v1 list widget still migrates.
if panelKind == PanelKindList && len(envelopes) > 1 {
envelopes = envelopes[:1]
}
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},
},
}}
}
// isUnrenderableMetricQuery reports whether the panel's only query is a metric
// builder query that v2 rejects: no aggregations at all ("at least one aggregation
// is required") or an aggregation with no metric name ("metric name is required").
// v1 doesn't render either, so the widget is skipped silently.
func isUnrenderableMetricQuery(panel *Panel) bool {
if len(panel.Spec.Queries) != 1 {
return false
}
plugin := panel.Spec.Queries[0].Spec.Plugin
if plugin.Kind != QueryKindBuilder {
return false
}
bqs, ok := plugin.Spec.(*BuilderQuerySpec)
if !ok {
return false
}
mq, ok := bqs.Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
if !ok {
return false
}
if len(mq.Aggregations) == 0 {
return true
}
for _, agg := range mq.Aggregations {
if agg.MetricName == "" {
return true
}
}
return false
}
// 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":
promQueries := d.readObjects(queryMap, "promql")
var out []map[string]any
for _, q := range promQueries {
// With multiple promql queries, drop the empty ones; a lone query is
// kept even if empty (nothing else would remain).
if len(promQueries) > 1 && d.readString(q, "query") == "" {
continue
}
out = append(out, promQLEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "clickhouse_sql":
chQueries := d.readObjects(queryMap, "clickhouse_sql")
var out []map[string]any
for _, q := range chQueries {
// With multiple clickhouse queries, drop the empty ones; a lone query is
// kept even if empty (nothing else would remain).
if len(chQueries) > 1 && d.readString(q, "query") == "" {
continue
}
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")
queries := d.readObjects(builder, "queryData")
assignQueryDataNames(queries)
for _, q := range queries {
normalizePreV5QueryData(q, widgetType)
normalizePreV5SelectColumns(q)
normalizePreV5GroupBy(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"])
}
}
formulas := d.readObjects(builder, "queryFormulas")
assignMissingFormulaNames(formulas)
for _, f := range formulas {
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{}
}
// maxQueries mirrors the frontend MAX_QUERIES; builder query names run A..Z.
const maxQueries = 26
// assignQueryDataNames names builder data queries the way the frontend does: each
// unnamed query takes the first unused A..Z, deduped against existing names. It also
// forces expression == queryName, since a data query's expression is always its own
// name and WrapInV5Envelope's name != expression heuristic would otherwise
// misclassify the query as a formula.
func assignQueryDataNames(queries []map[string]any) {
taken := make(map[string]bool, len(queries))
for _, q := range queries {
if name, _ := q["queryName"].(string); name != "" {
taken[name] = true
}
}
for _, q := range queries {
name, _ := q["queryName"].(string)
if name == "" {
for i := 0; i < maxQueries; i++ {
candidate := string(rune('A' + i))
if !taken[candidate] {
name = candidate
taken[candidate] = true
break
}
}
q["queryName"] = name
}
q["expression"] = name
}
}
// maxFormulas mirrors the frontend MAX_FORMULAS; formula names run F1..F20.
const maxFormulas = 20
// assignMissingFormulaNames fills queryName for unnamed formulas, mirroring the
// frontend: pick the first F{n} (n in 1..20) not already used by another formula.
// Formulas that already have a name keep it.
func assignMissingFormulaNames(formulas []map[string]any) {
taken := make(map[string]bool, len(formulas))
for _, f := range formulas {
if name, _ := f["queryName"].(string); name != "" {
taken[name] = true
}
}
for _, f := range formulas {
if name, _ := f["queryName"].(string); name != "" {
continue
}
for i := 1; i <= maxFormulas; i++ {
candidate := "F" + strconv.Itoa(i)
if !taken[candidate] {
f["queryName"] = candidate
taken[candidate] = true
break
}
}
}
}
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

@@ -1,242 +0,0 @@
package dashboardtypes
import (
"context"
"log/slog"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// 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) {
dropLegacyFilter(query)
preV5Migrator.MigrateQueryDataShapeSafe(context.Background(), query, widgetType)
normalizePreV5LogTraceAggregations(query)
normalizeMetricSpaceAggregation(query)
}
// dropLegacyFilter removes a v4-shaped filter ({items, op}) stored under the v5
// `filter` key. The v5 filter is {expression}; the migrator only rewrites the v4
// `filters` key and skips when `filter` is present, so this stale shape would reach
// WrapInV5Envelope and fail v5 validation. The v1 UI ignores it — it types
// IBuilderQuery.filter as {expression} (frontend queryBuilderData.ts, filter?: Filter)
// and only ever reads filter.expression, so items/op go unread. We drop it before the
// migrator, which can then rebuild `filter` from `filters` if present.
func dropLegacyFilter(query map[string]any) {
filter, ok := query["filter"].(map[string]any)
if !ok {
return
}
_, hasItems := filter["items"]
_, hasOp := filter["op"]
if hasItems || hasOp {
delete(query, "filter")
}
}
// normalizeMetricSpaceAggregation defaults an invalid spaceAggregation on a metric
// query to "sum". v1 bodies often leave it empty or carry a stale/unknown value,
// which fails v5 validation (metrictypes.SpaceAggregation.IsValid). Only metrics
// carry spaceAggregation; a valid value (including a histogram percentile) is left
// alone. The metric type isn't in the dashboard body, so we can't prefer a
// percentile default for histograms — sum is the safe fallback.
func normalizeMetricSpaceAggregation(query map[string]any) {
if signalFromDataSource(query["dataSource"]) != telemetrytypes.SignalMetrics {
return
}
aggs, ok := query["aggregations"].([]any)
if !ok {
return
}
for _, a := range aggs {
agg, ok := a.(map[string]any)
if !ok {
continue
}
sa, _ := agg["spaceAggregation"].(string)
if !(metrictypes.SpaceAggregation{String: valuer.NewString(sa)}).IsValid() {
agg["spaceAggregation"] = metrictypes.SpaceAggregationSum.StringValue()
}
}
}
// 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 / normalizePreV5GroupBy let WrapInV5Envelope (which
// reads the old {key,dataType,type}) handle selectColumns/groupBy stored the v5 way
// ({name,…}) — see backfillPreV5FieldKeys. Inverse of normalizePreV5FieldKeys (the
// two consumers want opposite shapes).
func normalizePreV5SelectColumns(query map[string]any) {
if cols, ok := query["selectColumns"].([]any); ok {
query["selectColumns"] = backfillPreV5FieldKeys(cols)
}
}
func normalizePreV5GroupBy(query map[string]any) {
if gb, ok := query["groupBy"].([]any); ok {
query["groupBy"] = backfillPreV5FieldKeys(gb)
}
}
// backfillPreV5FieldKeys copies v5 field names (name/fieldDataType/fieldContext)
// down to their v4 equivalents (key/dataType/type) so WrapInV5Envelope, which reads
// the v4 names, sees a field stored the v5 way. Fields with no resolvable key are
// dropped.
func backfillPreV5FieldKeys(fields []any) []any {
out := make([]any, 0, len(fields))
for _, f := range fields {
field, ok := f.(map[string]any)
if !ok {
continue
}
if _, ok := field["key"]; !ok {
if name, ok := field["name"]; ok {
field["key"] = name
}
}
if _, ok := field["dataType"]; !ok {
if fdt, ok := field["fieldDataType"]; ok {
field["dataType"] = fdt
}
}
if _, ok := field["type"]; !ok {
if fc, ok := field["fieldContext"]; ok {
field["type"] = fc
}
}
if key, _ := field["key"].(string); key == "" {
continue
}
out = append(out, field)
}
return 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

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

@@ -1,208 +0,0 @@
package dashboardtypes
import (
"sort"
"strconv"
"strings"
"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 {
// v1 sometimes stores variables as a list. The frontend consumes it via
// Object.entries/keys, which for an array yields the stringified index as the
// key, so mirror that: [{...}] is treated as {"0":{...}}. An empty list is
// simply "no variables".
rawSlice, isSlice := raw.([]any)
if !isSlice {
d.noteMalformedField("variables", raw)
return nil
}
rawVariablesMap = make(map[string]any, len(rawSlice))
for i, v := range rawSlice {
rawVariablesMap[strconv.Itoa(i)] = v
}
}
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")
// v1 stores the type upper-cased (QUERY/CUSTOM/…); tolerate any casing.
kind := strings.ToUpper(d.readString(v, "type"))
switch kind {
case "TEXTBOX":
spec := &TextVariableSpec{
Display: Display{Name: clipName(name, MaxDisplayNameLen), 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
}
// Drop a custom variable with no recoverable option list — v2 requires one.
if kind == "CUSTOM" && d.readString(v, "customValue") == "" && d.readString(v, "selectedValue") == "" && d.readString(v, "defaultValue") == "" {
return Variable{}, false
}
// Drop a query variable with no query — it can't resolve.
if kind == "QUERY" && d.readString(v, "queryValue") == "" {
return Variable{}, false
}
listSpec := &ListVariableSpec{
Display: Display{Name: clipName(name, MaxDisplayNameLen), 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, listSpec.AllowMultiple); 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":
// Some v1 dashboards stored the option list in selectedValue/defaultValue
// instead of customValue; fall back so the variable survives migration.
customValue := d.readString(v, "customValue")
if customValue == "" {
customValue = d.readString(v, "selectedValue")
}
if customValue == "" {
customValue = d.readString(v, "defaultValue")
}
return VariablePlugin{
Kind: VariableKindCustom,
Spec: &CustomVariableSpec{CustomValue: 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, allowMultiple bool) *VariableDefaultValue {
if raw, ok := v["selectedValue"]; ok {
return defaultValueFromAny(raw, allowMultiple)
}
if raw, ok := v["defaultValue"]; ok {
return defaultValueFromAny(raw, allowMultiple)
}
return nil
}
func defaultValueFromAny(raw any, allowMultiple bool) *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
}
// A single-select variable can't carry a list default; collapse a lone value.
if !allowMultiple && len(values) == 1 {
return &VariableDefaultValue{variable.DefaultValue{SingleValue: values[0]}}
}
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

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

View File

@@ -338,6 +338,7 @@ func isValidLabelValue(v string) bool {
// validate runs during UnmarshalJSON (read + write path).
// Preserves the original pre-existing checks only so that stored rules
// continue to load without errors.
// TODO(srikanthccv): remove this once v1 is deprecated and removed.
func (r *PostableRule) validate() error {
var errs []error
@@ -366,9 +367,13 @@ func (r *PostableRule) validate() error {
errs = append(errs, testTemplateParsing(r)...)
joined := errors.Join(errs...)
if joined != nil {
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
if len(errs) > 0 {
messages := make([]string, len(errs))
for i, e := range errs {
messages[i] = e.Error()
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "alert rule definition is not valid").
WithAdditional(messages...)
}
return nil
}
@@ -466,9 +471,13 @@ func (r *PostableRule) Validate() error {
errs = append(errs, testTemplateParsing(r)...)
joined := errors.Join(errs...)
if joined != nil {
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
if len(errs) > 0 {
messages := make([]string, len(errs))
for i, e := range errs {
messages[i] = e.Error()
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "alert rule is not valid").
WithAdditional(messages...)
}
return nil
}

View File

@@ -4,8 +4,23 @@ import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
)
func errorContains(err error, substr string) bool {
j := errors.AsJSON(err)
if strings.Contains(j.Message, substr) {
return true
}
for _, e := range j.Errors {
if strings.Contains(e.Message, substr) {
return true
}
}
return false
}
// validV1Builder returns a minimal valid v1 builder rule JSON.
func validV1Builder() string {
return `{
@@ -494,7 +509,7 @@ func TestValidate_PostableRule_Common(t *testing.T) {
if tt.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
} else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
} else if tt.errSubstr != "" && !errorContains(err, tt.errSubstr) {
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
}
} else {
@@ -687,7 +702,7 @@ func TestValidate_V1_ConditionFields(t *testing.T) {
if tt.wantErr {
if validateErr == nil {
t.Errorf("expected Validate() error containing %q, got nil", tt.errSubstr)
} else if tt.errSubstr != "" && !strings.Contains(validateErr.Error(), tt.errSubstr) {
} else if tt.errSubstr != "" && !errorContains(validateErr, tt.errSubstr) {
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, validateErr)
}
} else {
@@ -1029,7 +1044,7 @@ func TestValidate_V2Alpha1(t *testing.T) {
if tt.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
} else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
} else if tt.errSubstr != "" && !errorContains(err, tt.errSubstr) {
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
}
} else {
@@ -1337,7 +1352,7 @@ func TestValidate_MultipleErrors(t *testing.T) {
t.Fatal("expected unmarshal error for wrong version")
}
// The error should mention version
if !strings.Contains(err.Error(), "version") {
if !errorContains(err, "version") {
t.Errorf("expected error to mention version, got: %v", err)
}
})
@@ -1355,10 +1370,9 @@ func TestValidate_MultipleErrors(t *testing.T) {
if validateErr == nil {
t.Fatal("expected Validate() error")
}
errStr := validateErr.Error()
// Should contain errors for thresholds, evaluation, notificationSettings
for _, substr := range []string{"evaluation", "notificationSettings"} {
if !strings.Contains(errStr, substr) {
if !errorContains(validateErr, substr) {
t.Errorf("expected error to mention %q, got: %v", substr, validateErr)
}
}
@@ -1469,7 +1483,7 @@ func TestValidate_V2Alpha1_CumulativeEvaluation(t *testing.T) {
if tt.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
} else if !strings.Contains(err.Error(), tt.errSubstr) {
} else if !errorContains(err, tt.errSubstr) {
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
}
} else if err != nil {

View File

@@ -54,7 +54,7 @@ type SpanMapper struct {
types.UserAuditable
ID valuer.UUID `json:"id" required:"true"`
GroupID valuer.UUID `json:"group_id" required:"true"`
GroupID valuer.UUID `json:"groupId" required:"true"`
Name string `json:"name" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
Config SpanMapperConfig `json:"config" required:"true"`
@@ -63,7 +63,7 @@ type SpanMapper struct {
type PostableSpanMapper struct {
Name string `json:"name" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
Config SpanMapperConfig `json:"config" required:"true"`
Enabled bool `json:"enabled"`
}