Compare commits

..

7 Commits

Author SHA1 Message Date
Tushar Vats
6670328d85 fix: convert key not found to warnings for logs 2026-07-16 22:21:27 +05:30
Gaurav Tewari
98c0c53a2b feat: add dashboard in llm o11y (#12136)
* feat: add locked dashboard

* feat: update test cases

* chore: add comments

* chore: update comment

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-16 15:31:06 +00:00
Gaurav Tewari
6d3861d60f feat(llm-attribute-mapping): group + mapper drawers + diff-save [3-4/5] (#11780)
* 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

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

* feat(llm-attribute-mapping): read-only groups and mappers listing

* feat(llm-attribute-mapping): group create/edit/delete via drawer with diff-save

* fix: css styling

* style(llm-attribute-mapping): use design-token css variables for spacing and typography

* fix(llm-attribute-mapping): wrap long condition keys so the context badge isn't clipped

* fix(llm-attribute-mapping): unlock filters column shrink so long keys wrap within the cell

* fix: text overflow in attribute mapping

* fix: fetch data only when tab is open

* feat: add attribute mapping tab

* fix: update col count

* chore: review comments

* fix: input

* fix: update logic

* fix: update drawer

* feat: update draft and mapper

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to css module

* 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.

* refactor(llm-attribute-mapping): migrate styles to CSS modules

* 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(llm-attribute-mapping): remove orphaned IndexBadge

The TanStack mappers table dropped the leading index column, leaving
IndexBadge and its .indexBadge style unused. Remove both.

* 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 mapper

* chore: update test

* chore: minior ux update

* chore: lodash isEqual

* chore: use lodash

* chore: update save draft

* 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 design

* 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: self review changes

* chore: update skeleton

* refactor: code

* chore: sync up with group mappers

* chore: only 1 mapper row at a time

* refactor: code

* refactor: eslint disable

* chore: update selector

* chore: sort

* refactor: review changes

* chore: use refetch group

* feat(llm-attribute-mapping): mapper drawer + persistMappers [4/5] (#11781)

* feat(llm-attribute-mapping): mapper drawer + persistMappers [4/5]

Core of the mapper drawer work: the MapperFormDrawer + SourceAttributeRow
UI, persistMappers store logic, mapper row/actions, and supporting
utils/types/saveDraft changes.

Removes the barrel (index.ts) files introduced by this PR
(MapperFormDrawer, MapperActionsMenu, SourceAttributeRow) and imports
those modules directly instead.

Tests, public locales, the conditions tooltip, and the KeySearchInput
relocation follow in a stacked follow-up PR.

* test(llm-attribute-mapping): tooltip, locales, tests + drop barrels [4/5 follow-up]

Follow-up to the mapper drawer core PR. Contains:
- AttributeMappingsTab + LLMObservabilityAttributeMapping test suites
- public locale (titles.json) updates
- the group ConditionsTooltip and its GroupHeader wiring
- relocation of KeySearchInput to the shared components/ dir
- removal of the remaining barrel (index.ts) files across the
  AttributeMapping feature, importing each module directly

* chore: remove comments

* chore: reduce width

* fix: no refetch on open

* chore: add discard dialog

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>

* chore: remove reduced motion

* chore: self review changes

* chore: c should be capital

* chore: review changes

* refactor: sync server store

* chore: update tests

* Revert "chore: update tests"

This reverts commit 5f39c910df.

* Revert "refactor: sync server store"

This reverts commit b92f902d9f.

* chore: rename to editor

* chore: resolve review comments

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-16 14:32:35 +00:00
Nikhil Mantri
20e13f66e0 fix(infra-monitoring): use proper metric names corresponding to entities to be counted (#12130)
* chore: write proper lists of metricNames to lookup for counts

* chore: updated integration tests
2026-07-16 12:54:21 +00:00
Ashwin Bhatkal
07fe353b94 refactor(dashboard-v2): organize variable subsystem into components/ hooks/ utils/ (#12135)
* chore(dashboard-v2): drop self-explanatory comments from the variable subsystem

Remove comments that restate a well-named symbol or narrate obvious steps, and
trim verbose JSDoc to the non-obvious "why". No behavior change.

* refactor(dashboard-v2): organize VariablesBar into components/ hooks/ utils/

Split the flat VariablesBar directory into components/ (each component in its
own folder, co-locating a component-specific .module.scss), hooks/ (all use*
including useVariableOptions, moved out of selectors/), and utils/ (pure
modules). The bar's shared cross-cutting classes and .control stay in the root
VariablesBar.module.scss. Import paths updated across the subsystem; no
behavior change. Also updates a stale URL assertion in the seed test left over
from the store-source-of-truth change.

* refactor(dashboard-v2): organize DashboardSettings/Variables into components/ hooks/ utils/

Move the loose files out of the Variables root: pure modules into utils/
(rename variableDependencies -> variableCycleDetection to end the name clash
with VariablesBar's graph engine), useSaveVariables into hooks/, and VariableRow
and VariablesList into their own component folders with co-located .module.scss.
The shared model, adapters and types stay at the root as the folder's public
API. Import paths updated across the subsystem; no behavior change.

* refactor(dashboard-v2): factor the variable tooltip ref lists into one directional component

Extract TooltipRefSection so "Depends on" and "Used by" share one component,
differing only by color and an up/down arrow that depicts the dependency
direction (up = parents this variable depends on, down = children that use it).

* refactor(dashboard-v2): stack the variable name above its control

Move each variable's name into a notch label above the control: fixed-width
columns, name truncated with a hover title and a spaced/centered info icon,
single-line multi-select, ~33px control height, and a visible dropdown caret.
Nudge the toolbar spacing so the time selector aligns with the shorter bar.
Also render the impact dialog's "still references" warning via Typography
color="warning" instead of a bespoke CSS class.

* fix: top margin
2026-07-16 10:27:44 +00:00
Ashwin Bhatkal
9fb89fafc8 feat(dashboard-v2): variable runtime — resolver, fetch engine, panel loading, impact dialog (#12125)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboard-v2): single default resolver for variable selection

One source of truth for a variable's value (seed → reconcile → payload), shared by the bar, the fetch gate and the query payload so they can't disagree. Precedence stays URL → store → default; ALL materializes once, without an extra round-trip.

* feat(dashboard-v2): dependency-ordered, value-gated variable fetch engine

Query roots + dynamics fetch immediately; query children unblock only once their parents hold a committed value, so a chain fetches each variable once. The cycle is idempotent per (dashboard·order·time) and resets on page unmount, so a re-mount or editor return can't double it or inherit stale state.

* refactor(dashboard-v2): consolidate variable selectors; add bar tooltip + loading bar

Fold the per-type Query/Dynamic/Custom selectors into one VariableValueControl backed by useVariableOptions. Add a hover tooltip showing a variable's dependencies (Depends on / Used by) and a loading bar flush along the control's bottom edge; refine name/dependency colors to design-system tokens.

* feat(dashboard-v2): hold panels in loading until referenced variables resolve

A panel waits until every QUERY/DYNAMIC variable it references is ready — a concrete pick and a DYNAMIC ALL are ready immediately, an unselected value or a QUERY ALL waits until it resolves. The wait folds into the panel's loading flags (a disabled query reports neither), so it never flashes "no data". Only referenced variables re-key the cache.

* feat(dashboard-v2): rename/delete variable impact dialog

Renaming or deleting a referenced variable is blocked behind a dialog listing every usage (QB / PromQL / ClickHouse panels and other variable defs), with a current → resulting preview, per-usage selection, and inline edits; applied as one JSON-Patch.

* fix(dashboard-v2): don't flash ALL on a multi-select while its options load

CustomMultiSelect derives "all selected" from options + the current value; with options still empty a concrete selection reads as covering everything. Only enable ALL detection once options have loaded.

* refactor(dashboard-v2): make the store the source of truth for variable selection

The persisted store (localStorage) is now the sole source of truth for runtime
variable values. Stop writing ?variables= on every selection change; the URL
param is read once on load (a share link) to seed the store and then dropped,
so dashboard navigation and drilldown no longer round-trip through the URL. The
panel editor reads the selection from the store (a lagging URL snapshot could
clobber a just-changed value). Drops the now-unused withVariablesSearch.

* refactor(dashboard-v2): address review feedback on the variable subsystem

- consolidate the default resolver: one configuredDefault normalizer shared by
  resolveDefaultSelection and configuredDefaultValue, plus a textDefault helper
  for TEXT (drops the dead textValue/empty-string fallbacks)
- reuse computeVariableDependencies for the bar tooltip's depends-on / used-by
- split the fetch machinery out of useVariableOptions into useFetchedVariableOptions
- extract useVariableListActions from the variables settings page
- reuse removeVariableFromExpression for delete-clause removal (handles dangling
  syntax / parentheses) and drop the local removeVariableReferenceClause
- grey the impact dialog's Current field on uncheck; comment + spacing nits;
  guard useIsPanelWaitingOnVariable so it reads variableTypes without optional chaining
- fix the seed test's read-once URL assertion
2026-07-16 08:54:38 +00:00
Abhi kumar
0655328fa9 feat(dashboard-v2): export panels & dashboards from explorers (#12043)
* feat(dashboard-v2): new-panel editor route + query-preserving seed

Add the /dashboard/:id/panel/new route helpers (buildExportPanelLink,
parseNewPanelKind, layoutIndex) and buildNewPanelSeed, which converts an
exported explorer compositeQuery into a seeded panel.

Coerces a builder-only kind (List) to a query-compatible one (PromQL ->
TimeSeries, ClickHouse -> Table) so a non-builder query is preserved rather
than dropped. PanelEditorPage consumes the effective kind; new List panels
seed columns from the query's resolved signal.

* feat(dashboard-v2): flag-aware export-to-dashboard hooks

Add the hooks shared by the explorer "Add to dashboard" flow:

- useGetExportToDashboardLink: builds the V2 panel-editor link (or the V1
  new-widget link) based on the use_dashboard_v2 flag.
- useExportDashboards: flag-aware, search-filtered dashboard source. V2
  searches server-side via the name-contains filter DSL (debounced); V1
  filters its already-complete list in memory.
- useCreateExportDashboard: flag-aware "create a new dashboard" - V2 via the
  Perses createDashboardV2, V1 via the legacy create - normalized to Dashboard.

* feat(dashboard-v2): rebuild "Add to dashboard" export dialog

Replace the antd Modal-based ExportPanel with a @signozhq/ui DialogWrapper
dialog driven by the export hooks. The picker searches server-side and pins the
selected dashboard as an option so it survives a narrowing search; "New
dashboard" creates via the flag-aware hook and navigates to the panel editor.

Removes the old index.tsx / styles.ts.

* feat(dashboard-v2): wire explorers to flag-aware export

Point the Logs/Traces/Metrics explorer "Add to dashboard" actions at
useGetExportToDashboardLink, so an exported query lands in the V2 panel editor
when use_dashboard_v2 is on (the V1 new-widget link otherwise). ExplorerOptions
now renders the rebuilt ExportPanel dialog directly instead of wrapping it in an
antd Modal.

* chore: fixed failing tests

* refactor(dashboard-v2): address explorer-export review feedback

- rename isQueryTypeSupported → isQueryTypeSupportedByPanelKind for clarity
- buildExportPanelLink returns null (no silent TimeSeries default) when a
  panel type has no V2 kind; explorers guard navigation on the null link
- introduce a neutral ExportDashboard {id,title} for the picker instead of
  reusing the V1 Dashboard entity with faked fields
- note why an exported Query's single unit doesn't seed columnUnits

* chore: pr review changes
2026-07-16 05:49:06 +00:00
220 changed files with 9777 additions and 6404 deletions

View File

@@ -53,7 +53,6 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- querierai
- rawexportdata
- role
- rootuser

View File

@@ -8565,7 +8565,6 @@ components:
TelemetrytypesSource:
enum:
- meter
- ai
type: string
TelemetrytypesTelemetryFieldKey:
properties:

View File

@@ -63,5 +63,6 @@
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
}

View File

@@ -88,5 +88,6 @@
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
}

View File

@@ -3631,7 +3631,6 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
ai = 'ai',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
/**

View File

@@ -53,6 +53,7 @@ import {
defaultTraceSelectedColumns,
} from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
@@ -66,7 +67,6 @@ import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMapp
import { cloneDeep, isEqual, omit } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { FormattingOptions } from 'providers/preferences/types';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ViewProps } from 'types/api/saveViews/types';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -1031,26 +1031,19 @@ function ExplorerOptions({
/>
</div>
</Modal>
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
<ExportPanelContainer
open={isExport}
centered
destroyOnClose
>
<ExportPanelContainer
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
</Modal>
onClose={onCancel(false)}
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
</div>
);
}
@@ -1058,7 +1051,7 @@ function ExplorerOptions({
export interface ExplorerOptionsProps {
isLoading?: boolean;
onExport: (
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void;

View File

@@ -1,6 +1,7 @@
import { useHistory } from 'react-router-dom';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -80,7 +81,7 @@ const renderExplorerOptionWrapper = (
isLoading: false,
onExport: jest.fn() as jest.MockedFunction<
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -150,7 +151,7 @@ describe('ExplorerOptionWrapper', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -179,15 +180,16 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Click the "New Dashboard" button
const newDashboardButton = screen.getByRole('button', {
name: /new dashboard/i,
});
// Click the "New dashboard" button
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
await user.click(newDashboardButton);
// Wait for the API call to complete and onExport to be called
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(mockNewDashboard, true);
expect(testOnExport).toHaveBeenCalledWith(
{ id: NEW_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE },
true,
);
});
});
@@ -195,7 +197,7 @@ describe('ExplorerOptionWrapper', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -229,13 +231,12 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for dashboards to load and then click on the dashboard select dropdown
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
await waitFor(() => {
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -251,19 +252,21 @@ describe('ExplorerOptionWrapper', () => {
const dashboardOption = screen.getByText(mockDashboard1.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the Export button to be enabled
// Wait for the selection to be made and the export button to be enabled
await waitFor(() => {
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
});
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
await user.click(exportButton);
// Wait for onExport to be called with the selected dashboard
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(mockDashboard1, false);
expect(testOnExport).toHaveBeenCalledWith(
{ id: 'dashboard-1', title: 'Dashboard 1' },
false,
);
});
});
@@ -284,7 +287,7 @@ describe('ExplorerOptionWrapper', () => {
// Create a real handleExport function similar to LogsExplorerViews
// This should NOT call useUpdateDashboard (as per PR #8029)
const handleExport = (dashboard: Dashboard | null): void => {
const handleExport = (dashboard: ExportDashboard | null): void => {
if (!dashboard) {
return;
}
@@ -326,13 +329,12 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for dashboards to load and then click on the dashboard select dropdown
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
await waitFor(() => {
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -348,14 +350,13 @@ describe('ExplorerOptionWrapper', () => {
const dashboardOption = screen.getByText(mockDashboard.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the Export button to be enabled
// Wait for the selection to be made and the export button to be enabled
await waitFor(() => {
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
});
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
await user.click(exportButton);
// Wait for the handleExport function to be called and navigation to occur
@@ -375,7 +376,7 @@ describe('ExplorerOptionWrapper', () => {
it('should not show export buttons when component is disabled', () => {
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void

View File

@@ -0,0 +1,74 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select, SelectProps } from 'antd';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { getSelectOptions } from './utils';
import styles from './ExportPanel.module.scss';
interface ExportDashboardSelectProps {
dashboards: ExportDashboard[];
value: string | null;
/** The picked dashboard, pinned as an option so its label survives a later search. */
selectedDashboard: ExportDashboard | null;
loading?: boolean;
disabled?: boolean;
onChange: (dashboardId: string) => void;
onSearch: (search: string) => void;
}
/**
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
* dropdown.
*/
function ExportDashboardSelect({
dashboards,
value,
selectedDashboard,
loading,
disabled,
onChange,
onSearch,
}: ExportDashboardSelectProps): JSX.Element {
const options = useMemo<SelectProps['options']>(() => {
const base = getSelectOptions(dashboards) ?? [];
if (
selectedDashboard &&
!base.some((option) => option.value === selectedDashboard.id)
) {
return [
{ label: selectedDashboard.title, value: selectedDashboard.id },
...base,
];
}
return base;
}, [dashboards, selectedDashboard]);
return (
<Select
className={styles.dashboardSelect}
placeholder="Select a dashboard"
showSearch
filterOption={false}
loading={loading}
disabled={disabled}
value={value}
onChange={onChange}
onSearch={onSearch}
data-testid="export-dashboard-select"
options={options}
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
/>
);
}
ExportDashboardSelect.defaultProps = {
loading: false,
disabled: false,
};
export default ExportDashboardSelect;

View File

@@ -0,0 +1,42 @@
.body {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--l2-foreground);
}
.dashboardSelect {
width: 100%;
}
.newDashboard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--l2-border);
}
.hint {
font-size: 13px;
color: var(--l2-foreground);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
}

View File

@@ -1,127 +1,159 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { Button } from 'antd';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import createDashboard from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ExportPanelProps } from '.';
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
import {
DashboardSelect,
NewDashboardButton,
SelectWrapper,
Title,
Wrapper,
} from './styles';
import { filterOptions, getSelectOptions } from './utils';
ExportDashboard,
useExportDashboards,
} from 'hooks/dashboard/useExportDashboards';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ExportDashboardSelect from './ExportDashboardSelect';
import styles from './ExportPanel.module.scss';
export interface ExportPanelProps {
isLoading?: boolean;
onExport: (
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
) => void;
query: Query | null;
/** Controlled open state of the dialog. */
open: boolean;
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
onClose: () => void;
}
/**
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
*/
function ExportPanelContainer({
isLoading,
onExport,
open,
onClose,
}: ExportPanelProps): JSX.Element {
const { t } = useTranslation(['dashboard']);
const [dashboardId, setDashboardId] = useState<string | null>(null);
// Track the object, not just the id, so export survives a search that narrows it out.
const [selectedDashboard, setSelectedDashboard] =
useState<ExportDashboard | null>(null);
const [searchText, setSearchText] = useState('');
const {
data,
dashboards,
isLoading: isAllDashboardsLoading,
refetch,
} = useGetAllDashboard();
isFetching: isDashboardsFetching,
} = useExportDashboards(searchText);
const { showErrorModal } = useErrorModal();
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
useMutation(createDashboard, {
onSuccess: (data) => {
if (data.data) {
onExport(data?.data, true);
}
refetch();
},
onError: (error) => {
showErrorModal(error as APIError);
},
const { create: createNewDashboard, isLoading: createDashboardLoading } =
useCreateExportDashboard({
title: t('new_dashboard_title', { ns: 'dashboard' }),
onCreated: (dashboard) => onExport(dashboard, true),
});
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
const handleExportClick = useCallback((): void => {
const currentSelectedDashboard = data?.data?.find(
({ id }) => id === dashboardId,
);
onExport(currentSelectedDashboard || null, false);
}, [data, dashboardId, onExport]);
// Reset on close so each open starts fresh (the dialog stays mounted).
useEffect(() => {
if (!open) {
setSelectedDashboard(null);
setSearchText('');
}
}, [open]);
const handleSelect = useCallback(
(selectedDashboardId: string): void => {
setDashboardId(selectedDashboardId);
(dashboardId: string): void => {
setSelectedDashboard(
dashboards.find(({ id }) => id === dashboardId) ?? null,
);
},
[setDashboardId],
[dashboards],
);
const handleNewDashboard = useCallback(async () => {
try {
await createNewDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
} catch (error) {
showErrorModal(error as APIError);
}
}, [createNewDashboard, t, showErrorModal]);
const handleExportClick = useCallback((): void => {
onExport(selectedDashboard, false);
}, [selectedDashboard, onExport]);
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
const isDisabled =
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
const isExportDisabled =
isAllDashboardsLoading || !selectedDashboard || isLoading;
return (
<Wrapper direction="vertical">
<Title>Export Panel</Title>
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Add to dashboard"
testId="export-panel-dialog"
footer={
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={onClose}
testId="export-panel-cancel"
>
Cancel
</Button>
<Button
color="primary"
size="md"
loading={isLoading}
disabled={isExportDisabled}
onClick={handleExportClick}
testId="export-panel-export"
>
Add to dashboard
</Button>
</div>
}
>
<div className={styles.body}>
<div className={styles.field}>
<Typography.Text className={styles.label}>
Select a dashboard
</Typography.Text>
<ExportDashboardSelect
dashboards={dashboards}
value={selectedDashboard?.id ?? null}
selectedDashboard={selectedDashboard}
loading={isDashboardsFetching}
disabled={isAllDashboardsLoading || createDashboardLoading}
onChange={handleSelect}
onSearch={setSearchText}
/>
</div>
<SelectWrapper direction="horizontal">
<DashboardSelect
placeholder="Select Dashboard"
options={options}
showSearch
loading={isDashboardLoading}
disabled={isDashboardLoading}
value={dashboardId}
onSelect={handleSelect}
filterOption={filterOptions}
/>
<Button
type="primary"
loading={isLoading}
disabled={isDisabled}
onClick={handleExportClick}
>
Export
</Button>
</SelectWrapper>
<Typography>
Or create dashboard with this panel -
<NewDashboardButton
disabled={createDashboardLoading}
loading={createDashboardLoading}
type="link"
onClick={handleNewDashboard}
>
New Dashboard
</NewDashboardButton>
</Typography>
</Wrapper>
<div className={styles.newDashboard}>
<Typography.Text className={styles.hint}>
Or create a new dashboard with this panel
</Typography.Text>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<Plus size={14} />}
loading={createDashboardLoading}
disabled={createDashboardLoading}
onClick={createNewDashboard}
testId="export-panel-new-dashboard"
>
New dashboard
</Button>
</div>
</div>
</DialogWrapper>
);
}
ExportPanelContainer.defaultProps = {
isLoading: false,
};
export default ExportPanelContainer;

View File

@@ -1,49 +0,0 @@
import { useCallback, useState } from 'react';
import { Modal } from 'antd';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ExportPanelContainer from './ExportPanelContainer';
function ExportPanel({
isLoading,
onExport,
query,
}: ExportPanelProps): JSX.Element {
const [isExport, setIsExport] = useState<boolean>(false);
const onModalToggle = useCallback((value: boolean) => {
setIsExport(value);
}, []);
const onCancel = (value: boolean) => (): void => {
onModalToggle(value);
};
return (
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
open={isExport}
centered
destroyOnClose
>
<ExportPanelContainer
query={query}
isLoading={isLoading}
onExport={onExport}
/>
</Modal>
);
}
export interface ExportPanelProps {
isLoading?: boolean;
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
query: Query | null;
}
ExportPanel.defaultProps = { isLoading: false };
export default ExportPanel;

View File

@@ -1,34 +0,0 @@
import { FunctionComponent } from 'react';
import { Button, Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
Select,
)<SelectProps>`
width: 100%;
`;
export const SelectWrapper = styled(Space)`
width: 100%;
margin-bottom: 1rem;
.ant-space-item:first-child {
width: 100%;
max-width: 20rem;
}
`;
export const Wrapper = styled(Space)`
width: 100%;
`;
export const NewDashboardButton = styled(Button)`
&&& {
padding: 0 0.125rem;
}
`;
export const Title = styled(Typography.Text)`
font-size: 1rem;
`;

View File

@@ -1,16 +1,10 @@
import { SelectProps } from 'antd';
import { Dashboard } from 'types/api/dashboard/getAll';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
data.map(({ id, data }) => ({
label: data.title,
export const getSelectOptions = (
data: ExportDashboard[],
): SelectProps['options'] =>
data.map(({ id, title }) => ({
label: title,
value: id,
}));
export const filterOptions: SelectProps['filterOption'] = (
input,
options,
): boolean =>
(options?.label?.toString() ?? '')
?.toLowerCase()
.includes(input.toLowerCase());

View File

@@ -1,18 +1,34 @@
import { DraftGroup } from '../types';
import { AttributeMappingEditor } from '../hooks/useAttributeMappingEditor';
import styles from './AttributeMappingsTab.module.scss';
import MappingsTable from './components/MappingsTable/MappingsTable';
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
function AttributeMappingsTab(): JSX.Element {
const store = useAttributeMappingStore();
interface AttributeMappingsTabProps {
editor: AttributeMappingEditor;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
// "Attribute mappings" tab: the mapping-groups listing and its error state.
// The editor is owned by the container (the header's save/discard share it),
// so it's passed in rather than created here.
function AttributeMappingsTab({
editor,
onEditGroup,
onAddGroup,
}: AttributeMappingsTabProps): JSX.Element {
return (
<div data-testid="attribute-mappings-tab">
{store.isError ? (
{editor.isError ? (
<div className={styles.pageError} role="alert">
Failed to load mapping groups. Please try again.
</div>
) : (
<MappingsTable store={store} />
<MappingsTable
editor={editor}
onEditGroup={onEditGroup}
onAddGroup={onAddGroup}
/>
)}
</div>
);

View File

@@ -2,9 +2,19 @@ import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
},
}));
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
@@ -14,7 +24,9 @@ import {
mockGroups,
mockMappers,
} from 'container/LLMObservability/AttributeMapping/__tests__/fixtures';
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
import AttributeMappingsTab from '../AttributeMappingsTab';
import { useAttributeMappingEditor } from '../../hooks/useAttributeMappingEditor';
function setupGroups(groups = mockGroups): void {
server.use(
@@ -39,6 +51,53 @@ async function expandGroup(
await user.click(screen.getByTestId(`group-expand-${groupId}`));
}
async function openGroupActionsMenu(
user: ReturnType<typeof userEvent.setup>,
groupId: string,
): Promise<void> {
await user.click(screen.getByTestId(`group-actions-${groupId}`));
}
interface AttributeMappingsTabWithStoreProps {
onEditGroup?: (group: DraftGroup) => void;
onAddGroup?: () => void;
}
function AttributeMappingsTabWithStore({
onEditGroup,
onAddGroup,
}: AttributeMappingsTabWithStoreProps): JSX.Element {
const editor = useAttributeMappingEditor();
return (
<AttributeMappingsTab
editor={editor}
onEditGroup={onEditGroup ?? jest.fn()}
onAddGroup={onAddGroup ?? jest.fn()}
/>
);
}
function SaveableHarness(): JSX.Element {
const editor = useAttributeMappingEditor();
return (
<>
<button
type="button"
data-testid="save-button"
onClick={(): void => {
void editor.save();
}}
>
Save
</button>
<AttributeMappingsTab
editor={editor}
onEditGroup={jest.fn()}
onAddGroup={jest.fn()}
/>
</>
);
}
describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
// Reset URL state between tests — jsdom shares window.location across a file.
@@ -51,7 +110,7 @@ describe('AttributeMappingsTab (integration)', () => {
it('renders no error banner on a successful load', async () => {
setupGroups();
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
@@ -63,7 +122,7 @@ describe('AttributeMappingsTab (integration)', () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
'Failed to load mapping groups. Please try again.',
@@ -72,7 +131,7 @@ describe('AttributeMappingsTab (integration)', () => {
it('shows the empty state when there are no groups', async () => {
setupGroups([]);
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
await expect(
screen.findByTestId('mapper-groups-empty'),
@@ -81,58 +140,96 @@ describe('AttributeMappingsTab (integration)', () => {
it('renders each group header row with its name, condition count and status', async () => {
setupGroups();
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
// 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();
// carries a count instead (the keys surface in the group drawer).
// Every field carries a group-scoped testId, so assert on them directly.
await screen.findByTestId('group-name-group-1');
// 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();
// group-1: enabled, with attribute + resource condition keys.
expect(screen.getByTestId('group-name-group-1')).toHaveTextContent('demo');
expect(screen.getByTestId('group-condition-count-group-1')).toHaveTextContent(
'2 conditions',
);
expect(screen.getByTestId('group-enabled-group-1')).toBeChecked();
// group-2: disabled.
expect(screen.getByTestId('group-name-group-2')).toHaveTextContent('Tool');
expect(screen.getByTestId('group-condition-count-group-2')).toHaveTextContent(
'0 conditions',
);
expect(screen.getByTestId('group-enabled-group-2')).not.toBeChecked();
});
it('renders the group enable state as a read-only switch', async () => {
it("stages a toggle of the group's enabled state via the header switch", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
// 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();
await user.click(toggle);
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
});
it('invokes onAddGroup from the toolbar button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onAddGroup = jest.fn();
setupGroups();
render(<AttributeMappingsTabWithStore onAddGroup={onAddGroup} />);
await screen.findByTestId('group-name-group-1');
await user.click(screen.getByTestId('add-group-row'));
expect(onAddGroup).toHaveBeenCalledTimes(1);
});
it('invokes onEditGroup with the group when Edit is chosen from the actions menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onEditGroup = jest.fn();
setupGroups();
render(<AttributeMappingsTabWithStore onEditGroup={onEditGroup} />);
await screen.findByTestId('group-name-group-1');
await openGroupActionsMenu(user, 'group-1');
await user.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(onEditGroup).toHaveBeenCalledTimes(1);
expect(onEditGroup.mock.calls[0][0]).toMatchObject({
localId: 'group-1',
name: 'demo',
});
});
it('stages a group removal when Delete is chosen from the actions menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await openGroupActionsMenu(user, 'group-1');
await user.click(await screen.findByRole('menuitem', { name: 'Delete' }));
await waitFor(() =>
expect(screen.queryByTestId('group-name-group-1')).not.toBeInTheDocument(),
);
expect(screen.getByTestId('group-name-group-2')).toBeInTheDocument();
});
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 />);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
// The toggle is the antd Collapse header, which owns the expanded state.
// The clickable Collapse header owns the expanded state; find it by the
// aria-expanded contract rather than any framework-internal class.
const header = screen
.getByTestId('group-expand-group-1')
.closest('.ant-collapse-header') as HTMLElement;
.closest('[aria-expanded]') as HTMLElement;
expect(header).toHaveAttribute('aria-expanded', 'false');
await expandGroup(user);
@@ -155,7 +252,7 @@ describe('AttributeMappingsTab (integration)', () => {
setupMappers([
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
]);
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
// Mappers are not fetched until the row is expanded.
@@ -172,29 +269,12 @@ describe('AttributeMappingsTab (integration)', () => {
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();
@@ -203,7 +283,7 @@ describe('AttributeMappingsTab (integration)', () => {
res(ctx.status(500)),
),
);
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
@@ -217,7 +297,7 @@ describe('AttributeMappingsTab (integration)', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([]);
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
@@ -243,7 +323,7 @@ describe('AttributeMappingsTab (integration)', () => {
},
}),
]);
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
@@ -255,7 +335,7 @@ describe('AttributeMappingsTab (integration)', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
render(<AttributeMappingsTab />);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
@@ -264,4 +344,167 @@ describe('AttributeMappingsTab (integration)', () => {
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
);
});
it("re-fetches an open group's mappers on save instead of showing stale rows", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
let mappers = mockMappers;
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
),
);
render(<SaveableHarness />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
await user.click(screen.getByTestId('group-expand-group-1'));
await expect(
screen.findByTestId('mapper-target-mapper-1'),
).resolves.toBeInTheDocument();
mappers = [makeMapper({ id: 'mapper-2', name: 'gen_ai.response.model' })];
await user.click(screen.getByTestId('save-button'));
await expect(
screen.findByTestId('mapper-target-mapper-2'),
).resolves.toHaveTextContent('gen_ai.response.model');
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument();
});
it('warns and keeps the working copy when the post-save refresh fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
(toast.success as jest.Mock).mockClear();
(toast.warning as jest.Mock).mockClear();
let failRefresh = false;
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
failRefresh
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
rest.patch(`${GROUPS_ENDPOINT}/:groupId`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'ok' })),
),
);
render(<SaveableHarness />);
const toggle = await screen.findByTestId('group-enabled-group-1');
expect(toggle).toBeChecked();
await user.click(toggle);
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
failRefresh = true;
await user.click(screen.getByTestId('save-button'));
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
expect(toast.success).not.toHaveBeenCalled();
});
describe('mapper drawer', () => {
async function openGroupWithMapper(
user: ReturnType<typeof userEvent.setup>,
): Promise<void> {
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await screen.findByTestId('mapper-target-mapper-1');
}
it('opens the add-mapping drawer from a group\'s "Add mapping" button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
await openGroupWithMapper(user);
expect(screen.queryByTestId('mapper-form-drawer')).not.toBeInTheDocument();
await user.click(screen.getByTestId('add-mapper-group-1'));
await expect(
screen.findByTestId('mapper-form-drawer'),
).resolves.toBeInTheDocument();
expect(screen.getByText('New custom mapping')).toBeInTheDocument();
});
it('opens the edit drawer prefilled and locks the target attribute', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
await openGroupWithMapper(user);
await user.click(screen.getByRole('button', { name: 'Mapping actions' }));
await user.click(await screen.findByRole('menuitem', { name: 'Edit' }));
await expect(
screen.findByTestId('mapper-form-drawer'),
).resolves.toBeInTheDocument();
expect(screen.getByText('Edit mapping')).toBeInTheDocument();
const target = screen.getByTestId('mapper-form-target');
expect(target).toHaveValue('gen_ai.request.model');
expect(target).toBeDisabled();
});
it("toggles a mapper's enabled state through the store", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
await openGroupWithMapper(user);
const toggle = screen.getByTestId('mapper-enabled-mapper-1');
expect(toggle).toBeChecked();
await user.click(toggle);
await waitFor(() =>
expect(screen.getByTestId('mapper-enabled-mapper-1')).not.toBeChecked(),
);
});
it('removes a mapper via the row action menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
await openGroupWithMapper(user);
await user.click(screen.getByRole('button', { name: 'Mapping actions' }));
await user.click(await screen.findByRole('menuitem', { name: 'Delete' }));
await waitFor(() =>
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument(),
);
expect(screen.getByTestId('mappers-empty-group-1')).toBeInTheDocument();
});
it('creates a new mapping through the drawer and appends it to the group', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
await openGroupWithMapper(user);
await user.click(screen.getByTestId('add-mapper-group-1'));
await screen.findByTestId('mapper-form-drawer');
expect(screen.getByTestId('mapper-form-save')).toBeDisabled();
await user.type(
screen.getByTestId('mapper-form-target'),
'gen_ai.response.model',
);
await user.type(screen.getByTestId('mapper-form-source-0'), 'raw.model');
const create = screen.getByTestId('mapper-form-save');
await waitFor(() => expect(create).toBeEnabled());
await user.click(create);
await waitFor(() =>
expect(screen.queryByTestId('mapper-form-drawer')).not.toBeInTheDocument(),
);
await expect(
screen.findByText('gen_ai.response.model'),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
interface GroupActionsMenuProps {
group: DraftGroup;
onEdit: (group: DraftGroup) => void;
onRemove: (localId: string) => void;
}
function GroupActionsMenu({
group,
onEdit,
onRemove,
}: GroupActionsMenuProps): JSX.Element {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
icon: <Pencil size={14} />,
onClick: (): void => onEdit(group),
},
{
key: 'delete',
label: 'Delete',
danger: true,
icon: <Trash2 size={14} />,
onClick: (): void => onRemove(group.localId),
},
],
[onEdit, onRemove, group],
);
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Group actions"
data-testid={`group-actions-${group.localId}`}
>
<EllipsisVertical size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default GroupActionsMenu;

View File

@@ -0,0 +1,32 @@
.conditionsTooltip {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
max-width: 320px;
text-align: left;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.keyList {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.key {
max-width: 100%;
padding: 0 var(--spacing-1);
border-radius: var(--radius-1);
background: var(--bg-ink-300);
color: var(--l1-foreground);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}

View File

@@ -0,0 +1,61 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './ConditionsTooltip.module.scss';
interface ConditionsTooltipProps {
attributes: string[];
resource: string[];
}
function ConditionsTooltip({
attributes,
resource,
}: ConditionsTooltipProps): JSX.Element {
const hasConditions = attributes.length > 0 || resource.length > 0;
if (!hasConditions) {
return (
<Typography.Text as="span" size="small" color="muted">
No conditions set up
</Typography.Text>
);
}
return (
<div
className={styles.conditionsTooltip}
data-testid="group-conditions-tooltip"
>
{attributes.length > 0 && (
<div className={styles.section}>
<Typography.Text as="span" size="small" color="muted">
Runs when a span attribute key contains
</Typography.Text>
<div className={styles.keyList}>
{attributes.map((key) => (
<code key={key} className={styles.key}>
{key}
</code>
))}
</div>
</div>
)}
{resource.length > 0 && (
<div className={styles.section}>
<Typography.Text as="span" size="sm" color="muted">
{attributes.length > 0 ? 'or when' : 'Runs when'} a resource key contains
</Typography.Text>
<div className={styles.keyList}>
{resource.map((key) => (
<code key={key} className={styles.key}>
{key}
</code>
))}
</div>
</div>
)}
</div>
);
}
export default ConditionsTooltip;

View File

@@ -7,6 +7,7 @@
.groupName {
color: var(--l1-foreground);
font-weight: var(--font-weight-semibold);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -17,3 +18,15 @@
font-size: var(--font-size-xs);
white-space: nowrap;
}
.conditionCount {
composes: groupCount;
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
cursor: help;
}
.conditionInfoIcon {
color: var(--l3-foreground);
}

View File

@@ -1,10 +1,14 @@
import { Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
import ConditionsTooltip from './ConditionsTooltip';
import styles from './GroupHeader.module.scss';
interface GroupHeaderProps {
group: MappingGroup;
group: DraftGroup;
}
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
@@ -13,22 +17,33 @@ function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
return (
<div
className={styles.groupHeaderLabel}
data-testid={`group-expand-${group.id}`}
data-testid={`group-expand-${group.localId}`}
>
<Typography.Text
as="span"
className={styles.groupName}
testId={`group-name-${group.id}`}
testId={`group-name-${group.localId}`}
>
{group.name}
</Typography.Text>
<Typography.Text
as="span"
className={styles.groupCount}
testId={`group-condition-count-${group.id}`}
<TooltipSimple
title={
<ConditionsTooltip
attributes={group.attributes}
resource={group.resource}
/>
}
side="bottom"
align="start"
>
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
</Typography.Text>
<span
className={styles.conditionCount}
data-testid={`group-condition-count-${group.localId}`}
>
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
<Info size={12} className={styles.conditionInfoIcon} />
</span>
</TooltipSimple>
</div>
);
}

View File

@@ -1,13 +1,22 @@
import { Switch } from '@signozhq/ui/switch';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
import styles from './GroupHeaderActions.module.scss';
interface GroupHeaderActionsProps {
group: MappingGroup;
group: DraftGroup;
onToggle: (localId: string, enabled: boolean) => void;
onEdit: (group: DraftGroup) => void;
onRemove: (localId: string) => void;
}
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
function GroupHeaderActions({
group,
onToggle,
onEdit,
onRemove,
}: GroupHeaderActionsProps): JSX.Element {
return (
<div
className={styles.actions}
@@ -15,10 +24,10 @@ function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
>
<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}`}
onChange={(checked): void => onToggle(group.localId, checked)}
testId={`group-enabled-${group.localId}`}
/>
<GroupActionsMenu group={group} onEdit={onEdit} onRemove={onRemove} />
</div>
);
}

View File

@@ -8,7 +8,26 @@
color: var(--l3-foreground);
}
.stateContent {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
svg {
flex-shrink: 0;
}
}
.addMapperRow .stateCell {
padding-top: var(--spacing-2);
padding-bottom: var(--spacing-2);
}
.stateCell {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
font-size: var(--periscope-font-size-base);
}
.addMapperCell {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-6);
}

View File

@@ -1,14 +1,20 @@
import { useEffect } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { ArrowLeftRight, Plus, TriangleAlert } from '@signozhq/icons';
import { useListSpanMappers } from 'api/generated/services/spanmapper';
import { motion } from 'motion/react';
import {
MappingGroup,
Mapping,
DraftGroup,
DraftMapper,
Mapper,
} from 'container/LLMObservability/AttributeMapping/types';
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import { COLUMN_COUNT } from '../constants';
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
import MappingsColgroup from '../MappingsColgroup';
import MapperRow from '../MapperRow/MapperRow';
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
import MappingsColgroup from '../MappingsColgroup/MappingsColgroup';
import styles from './GroupMappers.module.scss';
const MAPPER_SKELETON_ROWS = 1;
@@ -19,72 +25,135 @@ const STATE_ROW_MOTION = {
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;
group: DraftGroup;
editor: AttributeMappingEditor;
onAddMapper: (groupLocalId: string) => void;
onEditMapper: (groupLocalId: string, mapper: DraftMapper) => void;
}
function GroupMappers({ group }: GroupMappersProps): JSX.Element {
const {
data: mappers = [],
isLoading,
isError,
} = useListSpanMappers<Mapping[]>(
{
groupId: group.id,
},
{
query: {
refetchOnMount: false,
select: buildMappingsFromListResponse,
},
},
function GroupMappers({
group,
editor,
onAddMapper,
onEditMapper,
}: GroupMappersProps): JSX.Element {
const { hydrateGroupMappers, removeMapper, toggleMapper } = editor;
const hasServerId = group.serverId !== null;
const { data, isLoading, isError } = useListSpanMappers(
{ groupId: group.serverId ?? '' },
// refetchOnMount: false — the Collapse destroys inactive panels, so a
// group's mappers would otherwise refetch on every expand. Cached data is
// reused instead; the save flow evicts these caches so post-edit expands
// still fetch fresh.
{ query: { enabled: hasServerId, refetchOnMount: false } },
);
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) => (
useEffect(() => {
const items = data?.data?.items;
if (group.serverId && items) {
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
}
}, [group.serverId, data, hydrateGroupMappers]);
const isLoadingMappers = hasServerId && isLoading;
const isErrorMappers = hasServerId && isError;
const mapperCount = group.mappers.length;
const skeletonRows = Array.from({ length: MAPPER_SKELETON_ROWS }).map(
(_, index) => (
// eslint-disable-next-line react/no-array-index-key
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
));
} else if (mappers.length === 0) {
rows = [<EmptyRow key="empty" groupId={group.id} />];
),
);
const errorRow = (
<motion.tr
key="error"
className={styles.mapperStateRow}
{...STATE_ROW_MOTION}
>
<td
colSpan={COLUMN_COUNT}
className={styles.stateCell}
data-testid={`mappers-error-${group.localId}`}
>
<span className={styles.stateContent}>
<TriangleAlert size={14} color="var(--danger-background)" />
<Typography.Text as="span" size="base">
Failed to load mappings. Please try again.
</Typography.Text>
</span>
</td>
</motion.tr>
);
const emptyRow = (
<motion.tr
key="empty"
className={styles.mapperStateRow}
{...STATE_ROW_MOTION}
>
<td
colSpan={COLUMN_COUNT}
className={styles.stateCell}
data-testid={`mappers-empty-${group.localId}`}
>
<span className={styles.stateContent}>
<ArrowLeftRight size={14} />
<Typography.Text as="span" size="base" color="muted">
No mappings in this group yet.
</Typography.Text>
</span>
</td>
</motion.tr>
);
const addMapperRow = (
<motion.tr
key="add-mapper"
className={styles.addMapperRow}
{...STATE_ROW_MOTION}
>
<td colSpan={COLUMN_COUNT} className={styles.addMapperCell}>
<Button
variant="link"
color="primary"
size="sm"
prefix={<Plus size={14} />}
onClick={(): void => onAddMapper(group.localId)}
testId={`add-mapper-${group.localId}`}
>
Add mapping
</Button>
</td>
</motion.tr>
);
const mapperRows = group.mappers.map((mapper, index) => (
<MapperRow
key={mapper.localId}
mapper={mapper}
index={index}
onEdit={(next): void => onEditMapper(group.localId, next)}
onRemove={(localId): void => removeMapper(group.localId, localId)}
onToggle={(localId, enabled): void =>
toggleMapper(group.localId, localId, enabled)
}
/>
));
// The add-mapping row trails every non-error state (including loading/empty).
let rows: JSX.Element[];
if (isErrorMappers) {
rows = [errorRow];
} else if (isLoadingMappers && mapperCount === 0) {
rows = [...skeletonRows, addMapperRow];
} else if (mapperCount === 0) {
rows = [emptyRow, addMapperRow];
} else {
rows = mappers.map((mapper, index) => (
<MapperRow key={mapper.id} mapper={mapper} index={index} />
));
rows = [...mapperRows, addMapperRow];
}
return (

View File

@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
interface MapperActionsMenuProps {
mapper: DraftMapper;
onEdit: (mapper: DraftMapper) => void;
onRemove: (localId: string) => void;
}
function MapperActionsMenu({
mapper,
onEdit,
onRemove,
}: MapperActionsMenuProps): JSX.Element {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
icon: <Pencil size={14} />,
onClick: (): void => onEdit(mapper),
},
{
key: 'delete',
label: 'Delete',
danger: true,
icon: <Trash2 size={14} />,
onClick: (): void => onRemove(mapper.localId),
},
],
[onEdit, onRemove, mapper],
);
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Mapping actions"
testId={`mapper-actions-${mapper.localId}`}
>
<EllipsisVertical size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default MapperActionsMenu;

View File

@@ -11,11 +11,22 @@
font-size: var(--periscope-font-size-base);
}
// Indent the first cell so mapper rows read as nested under their group.
.targetCell {
display: flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
padding-left: var(--spacing-12);
}
.targetName {
min-width: 0;
}
.targetContextBadge {
flex-shrink: 0;
}
// Shorter vertical padding so the loading state reads as a compact placeholder.
.skeletonCell {
composes: cell;
@@ -29,7 +40,7 @@
}
}
.statusCell {
.actionsCell {
text-align: right;
}
@@ -47,6 +58,10 @@
gap: var(--spacing-3);
}
.sourceChip {
font-family: var(--font-mono, monospace);
}
.sourceChipText {
display: block;
max-width: 220px;

View File

@@ -5,7 +5,8 @@ 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 { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
import styles from './MapperRow.module.scss';
const MAX_VISIBLE_SOURCES = 3;
@@ -15,11 +16,20 @@ const MAX_STAGGERED_ROWS = 6;
const STAGGER_STEP = 0.03;
interface MapperRowProps {
mapper: Mapping;
mapper: DraftMapper;
index: number;
onEdit: (mapper: DraftMapper) => void;
onRemove: (localId: string) => void;
onToggle: (localId: string, enabled: boolean) => void;
}
function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
function MapperRow({
mapper,
index,
onEdit,
onRemove,
onToggle,
}: MapperRowProps): JSX.Element {
const sources = mapper.sources ?? [];
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
const remainingSources = sources.length - visibleSources.length;
@@ -27,7 +37,7 @@ function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
return (
<motion.tr
className={styles.mapperRow}
data-testid={`mapper-row-${mapper.id}`}
data-testid={`mapper-row-${mapper.localId}`}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -37,22 +47,38 @@ function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
>
<td className={cx(styles.cell, styles.targetCell)}>
<Typography.Text
weight="semibold"
truncate={1}
title={mapper.name}
data-testid={`mapper-target-${mapper.id}`}
className={styles.targetName}
data-testid={`mapper-target-${mapper.localId}`}
>
{mapper.name}
</Typography.Text>
<Badge
color={
mapper.fieldContext === SpantypesFieldContextDTO.resource
? 'amber'
: 'robin'
}
variant="outline"
className={styles.targetContextBadge}
>
{mapper.fieldContext}
</Badge>
</td>
<td className={styles.cell}>
{sources.length === 0 ? (
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
<span
className={styles.muted}
data-testid={`mapper-sources-${mapper.localId}`}
>
</span>
) : (
<div
className={styles.sources}
data-testid={`mapper-sources-${mapper.id}`}
data-testid={`mapper-sources-${mapper.localId}`}
>
{visibleSources.map((source) => (
<Badge
@@ -74,25 +100,14 @@ function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
</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)}>
<td className={cx(styles.cell, styles.actionsCell)}>
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
disabled
testId={`mapper-enabled-${mapper.id}`}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
</div>
</td>
</motion.tr>

View File

@@ -8,6 +8,7 @@ function MapperRowSkeleton(): JSX.Element {
<tr className={styles.mapperRow}>
<td className={cx(styles.skeletonCell, styles.targetCell)}>
<Skeleton.Input active size="small" style={{ width: '55%' }} />
<Skeleton.Button active size="small" style={{ width: 72 }} />
</td>
<td className={styles.skeletonCell}>
<div className={styles.sources}>
@@ -15,10 +16,7 @@ function MapperRowSkeleton(): JSX.Element {
<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)}>
<td className={cx(styles.skeletonCell, styles.actionsCell)}>
<div className={styles.rowActions}>
<Skeleton.Button active size="small" shape="round" />
</div>

View File

@@ -2,10 +2,6 @@
width: 32%;
}
.colWritesTo {
width: 140px;
}
.colStatus {
width: 120px;
.colActions {
width: 176px;
}

View File

@@ -5,8 +5,7 @@ function MappingsColgroup(): JSX.Element {
<colgroup>
<col className={styles.colTarget} />
<col />
<col className={styles.colWritesTo} />
<col className={styles.colStatus} />
<col className={styles.colActions} />
</colgroup>
);
}

View File

@@ -1,3 +1,16 @@
.tableWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
padding: var(--spacing-4);
}
.table {
width: 100%;
border-collapse: collapse;
@@ -5,7 +18,7 @@
}
.headerRow {
border-bottom: 1px solid var(--l2-border);
border-top: 1px solid var(--l2-border);
}
.headerCell {
@@ -73,7 +86,12 @@
}
}
.tableEmpty {
.tableState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);

View File

@@ -1,30 +1,96 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { useCallback, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight, Layers, Plus } 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 MapperFormDrawer from 'container/LLMObservability/AttributeMapping/components/MapperFormDrawer/MapperFormDrawer';
import { useMapperFormDrawer } from 'container/LLMObservability/AttributeMapping/components/MapperFormDrawer/hooks/useMapperFormDrawer';
import {
DraftGroup,
DraftMapper,
} from 'container/LLMObservability/AttributeMapping/types';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import GroupHeader from './GroupHeader/GroupHeader';
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
import GroupMappers from './GroupMappers/GroupMappers';
import MappingsColgroup from './MappingsColgroup/MappingsColgroup';
import styles from './MappingsTable.module.scss';
const SKELETON_ROW_COUNT = 3;
interface MappingsTableProps {
store: AttributeMappingStore;
editor: AttributeMappingEditor;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
function MappingsTable({
editor,
onEditGroup,
onAddGroup,
}: MappingsTableProps): JSX.Element {
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
const drawer = useMapperFormDrawer();
const isEmpty = !store.isLoading && store.groups.length === 0;
const { upsertMapper, removeMapper } = editor;
const items: CollapseProps['items'] = store.groups.map((group) => ({
key: group.id,
const handleAddMapper = useCallback(
(groupLocalId: string): void => {
setTargetGroupId(groupLocalId);
// Keep the group open so the staged row is visible after save.
setExpandedGroups((prev) =>
prev.includes(groupLocalId) ? prev : [...prev, groupLocalId],
);
drawer.openForAdd();
},
[drawer],
);
const handleEditMapper = useCallback(
(groupLocalId: string, mapper: DraftMapper): void => {
setTargetGroupId(groupLocalId);
drawer.openForEdit(mapper);
},
[drawer],
);
const handleSaveMapper = useCallback((): void => {
if (targetGroupId) {
upsertMapper(targetGroupId, drawer.draft);
}
drawer.close();
}, [targetGroupId, upsertMapper, drawer]);
const handleDeleteMapper = useCallback((): void => {
if (targetGroupId && drawer.draft.id) {
removeMapper(targetGroupId, drawer.draft.id);
}
drawer.close();
}, [targetGroupId, removeMapper, drawer]);
const isEmpty = !editor.isLoading && editor.groups.length === 0;
const items: CollapseProps['items'] = editor.groups.map((group) => ({
key: group.localId,
label: <GroupHeader group={group} />,
extra: <GroupHeaderActions group={group} />,
children: <GroupMappers group={group} />,
extra: (
<GroupHeaderActions
group={group}
onToggle={editor.toggleGroup}
onEdit={onEditGroup}
onRemove={editor.removeGroup}
/>
),
children: (
<GroupMappers
group={group}
editor={editor}
onAddMapper={handleAddMapper}
onEditMapper={handleEditMapper}
/>
),
}));
const skeletonBanners = (
@@ -45,48 +111,79 @@ function MappingsTable({ store }: MappingsTableProps): JSX.Element {
</div>
<div className={styles.skeletonGroupRight}>
<Skeleton.Button active size="small" shape="round" />
<Skeleton.Avatar active size={16} shape="square" />
</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
<div className={styles.tableWrapper}>
<div className={styles.toolbar}>
<Button
variant="link"
color="primary"
prefix={<Plus size={14} />}
onClick={onAddGroup}
testId="add-group-row"
disabled={editor.isLoading}
>
Add a new group
</Button>
</div>
{isEmpty ? (
<div className={styles.tableState} data-testid="mapper-groups-empty">
<Layers size={24} />
<Typography.Text as="span" size="base" color="muted">
No mapping groups yet.
</Typography.Text>
</div>
) : (
<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 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}>Actions</th>
</tr>
</thead>
</table>
{editor.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>
)}
{drawer.isOpen && (
<MapperFormDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={handleSaveMapper}
onDelete={handleDeleteMapper}
isSaving={false}
isDeleting={false}
saveError={null}
/>
)}
</div>

View File

@@ -1 +1 @@
export const COLUMN_COUNT = 4;
export const COLUMN_COUNT = 3;

View File

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

@@ -1,6 +1,18 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-0);
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-2);
--tab-content-padding: 0;
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
}
.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

@@ -1,17 +1,45 @@
import { useCallback } from 'react';
import { Divider } from '@signozhq/ui/divider';
import { Tabs } from '@signozhq/ui/tabs';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import AttributeMappingHeader from './components/AttributeMappingHeader';
import AttributeMappingHeader from './components/AttributeMappingHeader/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
function LLMObservabilityAttributeMapping(): JSX.Element {
const editor = useAttributeMappingEditor();
const groupDrawer = useGroupFormDrawer();
const { discard } = editor;
// Discarding wipes the whole working copy, so gate it behind a confirm
// prompt rather than firing straight from the button.
const discardConfirm = useConfirmableAction(
useCallback(async (): Promise<void> => {
discard();
}, [discard]),
);
const handleGroupSave = useCallback((): void => {
editor.upsertGroup(groupDrawer.draft);
groupDrawer.close();
}, [editor, groupDrawer]);
const tabItems = [
{
key: 'attribute-mappings',
label: 'Attribute mappings',
children: <AttributeMappingsTab />,
label: 'Attribute Mappings',
children: (
<AttributeMappingsTab
editor={editor}
onEditGroup={groupDrawer.openForEdit}
onAddGroup={groupDrawer.openForAdd}
/>
),
},
{
key: 'test',
@@ -28,17 +56,39 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={noop}
onSave={noop}
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
{editor.saveError && (
<div className={styles.pageError} role="alert">
{editor.saveError}
</div>
)}
<Divider />
<Tabs
testId="attribute-mapping-tabs"
defaultValue="attribute-mappings"
items={tabItems}
/>
{groupDrawer.isOpen && (
<GroupFormDrawer
isOpen={groupDrawer.isOpen}
mode={groupDrawer.mode}
draft={groupDrawer.draft}
setDraft={groupDrawer.setDraft}
onClose={groupDrawer.close}
onSave={handleGroupSave}
/>
)}
<DiscardChangesDialog
open={discardConfirm.open}
onConfirm={discardConfirm.confirm}
onCancel={discardConfirm.cancel}
/>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
@@ -34,7 +34,7 @@ describe('LLMObservabilityAttributeMapping', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByRole('tab', { name: 'Attribute mappings' }),
screen.getByRole('tab', { name: 'Attribute Mappings' }),
).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
});
@@ -43,7 +43,7 @@ describe('LLMObservabilityAttributeMapping', () => {
render(<LLMObservabilityAttributeMapping />);
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute mappings',
name: 'Attribute Mappings',
});
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
await expect(
@@ -64,4 +64,44 @@ describe('LLMObservabilityAttributeMapping', () => {
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
});
it('prompts before discarding, then reverts staged changes on confirm', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservabilityAttributeMapping />);
const toggle = await screen.findByTestId('group-enabled-group-1');
expect(toggle).toBeChecked();
await user.click(toggle);
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
await user.click(screen.getByTestId('discard-changes-btn'));
const confirmBtn = await screen.findByTestId('discard-changes-confirm-btn');
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
await user.click(confirmBtn);
await waitFor(() =>
expect(screen.getByTestId('group-enabled-group-1')).toBeChecked(),
);
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
});
it('keeps staged changes when the discard prompt is dismissed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservabilityAttributeMapping />);
const toggle = await screen.findByTestId('group-enabled-group-1');
await user.click(toggle);
await user.click(screen.getByTestId('discard-changes-btn'));
await user.click(await screen.findByTestId('discard-changes-cancel-btn'));
await waitFor(() =>
expect(
screen.queryByTestId('discard-changes-confirm-btn'),
).not.toBeInTheDocument(),
);
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
expect(screen.getByTestId('discard-changes-btn')).toBeInTheDocument();
});
});

View File

@@ -2,7 +2,8 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
margin-left: var(--spacing-2);
margin-top: var(--spacing-4);
}
.pageHeaderActions {

View File

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

View File

@@ -0,0 +1,56 @@
import { AlertDialog } from '@signozhq/ui/alert-dialog';
import { Button } from '@signozhq/ui/button';
import { Trash2, X } from '@signozhq/icons';
interface DiscardChangesDialogProps {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DiscardChangesDialog({
open,
onConfirm,
onCancel,
}: DiscardChangesDialogProps): JSX.Element {
return (
<AlertDialog
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="narrow"
title="Discard unsaved changes?"
titleIcon={<Trash2 size={16} />}
footer={
<>
<Button
variant="solid"
color="secondary"
onClick={onCancel}
prefix={<X size={12} />}
testId="discard-changes-cancel-btn"
>
Keep editing
</Button>
<Button
variant="solid"
color="destructive"
onClick={onConfirm}
prefix={<Trash2 size={12} />}
testId="discard-changes-confirm-btn"
>
Discard changes
</Button>
</>
}
>
This reverts every unsaved group and mapping change back to the last saved
state. This action cannot be undone.
</AlertDialog>
);
}
export default DiscardChangesDialog;

View File

@@ -0,0 +1,39 @@
.groupForm {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-2) 0;
}
.groupFormField {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.groupFormFieldRow {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.groupFormLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.groupFormHint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.groupFormFooter {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,117 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import ConditionKeyList from './components/ConditionKeyList/ConditionKeyList';
import styles from './GroupFormDrawer.module.scss';
import { FieldContext, GroupDraft, MapperDraftMode } from '../../types';
import { isGroupDraftValid } from '../../utils';
interface GroupFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
onClose: () => void;
onSave: () => void;
}
function GroupFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
}: GroupFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isGroupDraftValid(draft);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit group' : 'New group'}
subTitle="A group gates which spans its mappings run on"
width="wide"
testId="group-form-drawer"
footer={
<div className={styles.groupFormFooter}>
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="group-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid}
testId="group-form-save"
>
{isEdit ? 'Save group' : 'Create group'}
</Button>
</div>
}
>
<div className={styles.groupForm}>
<div className={styles.groupFormField}>
<span className={styles.groupFormLabel}>Group Name</span>
<Input
placeholder="e.g. OpenAI gateway"
value={draft.name}
onChange={(event): void =>
setDraft({ ...draft, name: event.target.value })
}
testId="group-form-name"
/>
</div>
<div className={`${styles.groupFormField} ${styles.groupFormFieldRow}`}>
<span className={styles.groupFormLabel}>Enabled</span>
<Switch
value={draft.enabled}
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
testId="group-form-enabled"
/>
</div>
<ConditionKeyList
label="Condition · span attribute keys"
labelHint="·Runs when a span attribute key contains any of these"
keys={draft.attributes}
placeholder="e.g. gen_ai."
addLabel="Add attribute key"
testIdPrefix="group-form-attribute"
fieldContext={FieldContext.attribute}
onChange={(attributes): void => setDraft({ ...draft, attributes })}
/>
<ConditionKeyList
label="Condition · resource keys"
labelHint="·Or when a resource key contains any of these"
keys={draft.resource}
placeholder="e.g. service.name"
addLabel="Add resource key"
testIdPrefix="group-form-resource"
fieldContext={FieldContext.resource}
onChange={(resource): void => setDraft({ ...draft, resource })}
/>
<span className={styles.groupFormHint}>
Leave both empty to run this group on every span.
</span>
</div>
</DrawerWrapper>
);
}
export default GroupFormDrawer;

View File

@@ -0,0 +1,35 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.keys {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.keyRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.keyInput {
flex: 1;
}

View File

@@ -0,0 +1,89 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './ConditionKeyList.module.scss';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
}
function ConditionKeyList({
label,
labelHint,
keys,
placeholder,
addLabel,
testIdPrefix,
fieldContext,
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
};
const addKey = (): void => {
onChange([...keys, '']);
};
const removeKey = (index: number): void => {
onChange(keys.filter((_, i) => i !== index));
};
return (
<div className={styles.field}>
<span className={styles.label}>
{label}
{labelHint && <span className={styles.labelHint}> {labelHint}</span>}
</span>
{keys.length > 0 && (
<div className={styles.keys}>
{keys.map((key, index) => (
// eslint-disable-next-line react/no-array-index-key
<div className={styles.keyRow} key={index}>
<KeySearchInput
className={styles.keyInput}
placeholder={placeholder}
value={key}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove key"
onClick={(): void => removeKey(index)}
testId={`${testIdPrefix}-remove-${index}`}
>
<X size={14} />
</Button>
</div>
))}
</div>
)}
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addKey}
testId={`${testIdPrefix}-add`}
>
{addLabel}
</Button>
</div>
);
}
export default ConditionKeyList;

View File

@@ -0,0 +1,38 @@
import { useCallback, useState } from 'react';
import { DraftGroup, GroupDraft, MapperDraftMode } from '../../../types';
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from '../../../utils';
interface UseGroupFormDrawer {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
openForAdd: () => void;
openForEdit: (group: DraftGroup) => void;
close: () => void;
}
export function useGroupFormDrawer(): UseGroupFormDrawer {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_GROUP_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((group: DraftGroup): void => {
setMode('edit');
setDraft(groupDraftFromNode(group));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -0,0 +1,49 @@
.keySearch {
position: relative;
flex: 1;
min-width: 0;
}
.keySearchDropdown {
position: absolute;
top: calc(100% + var(--spacing-2));
left: 0;
z-index: 20;
width: 100%;
max-height: 240px;
overflow-x: hidden;
overflow-y: auto;
padding: var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background);
box-shadow: 0 8px 24px
color-mix(in srgb, var(--bg-base-black) 40%, transparent);
}
.keySearchDropdownEmpty {
padding: var(--spacing-4) var(--spacing-5);
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.keySearchOption {
display: block;
width: 100%;
max-width: 100%;
padding: var(--spacing-3) var(--spacing-5);
border: none;
border-radius: 3px;
background: transparent;
color: var(--l1-foreground);
font-size: var(--font-size-xs);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
&:hover {
background: var(--l2-background-hover);
}
}

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import useDebounce from 'hooks/useDebounce';
import {
FieldContext,
FieldContextValue,
} from 'container/LLMObservability/AttributeMapping/types';
import styles from './KeySearchInput.module.scss';
const SUGGESTION_LIMIT = 50;
const DEBOUNCE_MS = 300;
interface KeySearchInputProps {
value: string;
fieldContext: FieldContextValue;
placeholder?: string;
className?: string;
disabled?: boolean;
testId?: string;
onChange: (value: string) => void;
}
// Maps the mapper's attribute/resource context to the fields-endpoint context.
function toFieldsContext(
context: FieldContextValue,
): TelemetrytypesFieldContextDTO {
return context === FieldContext.resource
? TelemetrytypesFieldContextDTO.resource
: TelemetrytypesFieldContextDTO.attribute;
}
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
function KeySearchInput({
value,
fieldContext,
placeholder,
className,
disabled,
testId,
onChange,
}: KeySearchInputProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
const { data, isFetching } = useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.traces,
fieldContext: toFieldsContext(fieldContext),
searchText: debouncedSearch,
limit: SUGGESTION_LIMIT,
},
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
);
const suggestions = useMemo(() => {
const keys = data?.data?.keys ?? {};
return Object.keys(keys)
.filter((key) => key !== value)
.slice(0, SUGGESTION_LIMIT);
}, [data, value]);
return (
<div className={cx(styles.keySearch, className)}>
<Input
placeholder={placeholder}
value={value}
disabled={disabled}
autoComplete="off"
onChange={(event): void => {
onChange(event.target.value);
setIsOpen(true);
}}
onFocus={(): void => setIsOpen(true)}
onBlur={(): void => setIsOpen(false)}
testId={testId}
/>
{isOpen && suggestions.length > 0 && (
<div
className={styles.keySearchDropdown}
data-testid={`${testId}-dropdown`}
>
{suggestions.map((name) => (
<button
type="button"
key={name}
className={styles.keySearchOption}
title={name}
// onMouseDown (not onClick) so selection runs before the input blur.
onMouseDown={(event): void => {
event.preventDefault();
onChange(name);
setIsOpen(false);
}}
data-testid={`${testId}-option-${name}`}
>
{name}
</button>
))}
</div>
)}
{isOpen && isFetching && suggestions.length === 0 && (
<div
className={cx(styles.keySearchDropdown, styles.keySearchDropdownEmpty)}
>
<Spinner size="small" height="auto" />
</div>
)}
</div>
);
}
export default KeySearchInput;

View File

@@ -0,0 +1,63 @@
.form {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-2) 0;
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.hint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.fieldContext {
width: 200px;
}
.sources {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.error {
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--spacing-4);
}
.footerActions {
display: flex;
gap: var(--spacing-4);
margin-left: auto;
}

View File

@@ -0,0 +1,252 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { SelectSimple } from '@signozhq/ui/select';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Plus, Trash2 } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
import KeySearchInput from '../KeySearchInput/KeySearchInput';
import styles from './MapperFormDrawer.module.scss';
import SourceAttributeRow from './components/SourceAttributeRow/SourceAttributeRow';
import {
FieldContext,
FieldContextValue,
MapperDraft,
MapperDraftMode,
SourceConfig,
} from '../../types';
import { createEmptySource, isMapperDraftValid } from '../../utils';
const FIELD_CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Span attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
interface MapperFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function MapperFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: MapperFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isMapperDraftValid(draft);
const [rowIds, setRowIds] = useState<string[]>(() =>
draft.sources.map(() => uuid()),
);
const sourceIds = draft.sources.map(
(_, index) => rowIds[index] ?? `pending-${index}`,
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
const sources = draft.sources.map((source, i) =>
i === index ? { ...source, ...patch } : source,
);
setDraft({ ...draft, sources });
};
const addSource = (): void => {
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
setRowIds((prev) => [...prev, uuid()]);
};
const removeSource = (index: number): void => {
const sources = draft.sources.filter((_, i) => i !== index);
if (sources.length === 0) {
setDraft({ ...draft, sources: [createEmptySource()] });
setRowIds([uuid()]);
return;
}
setDraft({ ...draft, sources });
setRowIds((prev) => prev.filter((_, i) => i !== index));
};
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const from = sourceIds.indexOf(String(active.id));
const to = sourceIds.indexOf(String(over.id));
if (from === -1 || to === -1) {
return;
}
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
setRowIds((prev) => arrayMove(prev, from, to));
};
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
subTitle="Map source attributes onto a canonical target attribute"
width="wide"
testId="mapper-form-drawer"
footer={
<div className={styles.footer}>
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="mapper-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className={styles.footerActions}>
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="mapper-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="mapper-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
</Button>
</div>
</div>
}
>
<div className={styles.form}>
<div className={styles.field}>
<span className={styles.label}>Target attribute</span>
<KeySearchInput
placeholder="e.g. gen_ai.content.prompt"
value={draft.name}
fieldContext={draft.fieldContext}
disabled={isEdit}
onChange={(name): void => setDraft({ ...draft, name })}
testId="mapper-form-target"
/>
{isEdit && (
<span className={styles.hint}>
The target attribute can&apos;t be changed after creation.
</span>
)}
</div>
<div className={styles.field}>
<span className={styles.label}>Write target to</span>
<SelectSimple
className={styles.fieldContext}
items={FIELD_CONTEXT_OPTIONS}
value={draft.fieldContext}
withPortal={false}
onChange={(next): void =>
setDraft({ ...draft, fieldContext: next as FieldContextValue })
}
testId="mapper-form-field-context"
/>
<span className={styles.hint}>
Where the standardized attribute is written.
</span>
</div>
<div className={styles.field}>
<span className={styles.label}>
Source attributes
<span className={styles.labelHint}>
{' '}
· priority: top bottom · drag to reorder
</span>
</span>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
<div className={styles.sources}>
{draft.sources.map((source, index) => (
<SourceAttributeRow
key={sourceIds[index]}
id={sourceIds[index]}
index={index}
value={source}
canRemove={draft.sources.length > 1}
onChange={updateSource}
onRemove={removeSource}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addSource}
testId="mapper-form-add-source"
>
Add another source
</Button>
</div>
{saveError && (
<div className={styles.error} role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default MapperFormDrawer;

View File

@@ -0,0 +1,40 @@
.source {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.sourceHandle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-2);
border: none;
background: transparent;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
user-select: none;
&:active {
cursor: grabbing;
}
}
.sourceIndex {
width: 20px;
text-align: center;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.sourceInput {
flex: 1;
min-width: 0;
font-family: 'Geist Mono', monospace;
}
.sourceSelect {
flex: 0 0 auto;
width: 120px;
}

View File

@@ -0,0 +1,112 @@
import { Button } from '@signozhq/ui/button';
import { SelectSimple } from '@signozhq/ui/select';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, X } from '@signozhq/icons';
import {
FieldContext,
FieldContextValue,
MapperOperation,
MapperOperationValue,
SourceConfig,
} from 'container/LLMObservability/AttributeMapping/types';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './SourceAttributeRow.module.scss';
const CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
const OPERATION_OPTIONS = [
{ value: MapperOperation.move, label: 'Move' },
{ value: MapperOperation.copy, label: 'Copy' },
];
interface SourceAttributeRowProps {
id: string;
index: number;
value: SourceConfig;
canRemove: boolean;
onChange: (index: number, patch: Partial<SourceConfig>) => void;
onRemove: (index: number) => void;
}
function SourceAttributeRow({
id,
index,
value,
canRemove,
onChange,
onRemove,
}: SourceAttributeRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
return (
<div className={styles.source} ref={setNodeRef} style={style}>
<div
className={styles.sourceHandle}
data-testid={`mapper-form-source-handle-${index}`}
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</div>
<span className={styles.sourceIndex}>{index + 1}</span>
<KeySearchInput
className={styles.sourceInput}
placeholder="Source attribute key"
value={value.key}
fieldContext={value.context}
onChange={(key): void => onChange(index, { key })}
testId={`mapper-form-source-${index}`}
/>
<SelectSimple
className={styles.sourceSelect}
items={CONTEXT_OPTIONS}
value={value.context}
withPortal={false}
onChange={(next): void =>
onChange(index, { context: next as FieldContextValue })
}
testId={`mapper-form-source-context-${index}`}
/>
<SelectSimple
className={styles.sourceSelect}
items={OPERATION_OPTIONS}
value={value.operation}
withPortal={false}
onChange={(next): void =>
onChange(index, { operation: next as MapperOperationValue })
}
testId={`mapper-form-source-operation-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove source"
disabled={!canRemove}
onClick={(): void => onRemove(index)}
testId={`mapper-form-source-remove-${index}`}
>
<X size={14} />
</Button>
</div>
);
}
export default SourceAttributeRow;

View File

@@ -0,0 +1,38 @@
import { useCallback, useState } from 'react';
import { DraftMapper, MapperDraft, MapperDraftMode } from '../../../types';
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from '../../../utils';
interface UseMapperFormDrawerResult {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
openForAdd: () => void;
openForEdit: (mapper: DraftMapper) => void;
close: () => void;
}
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_MAPPER_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((mapper: DraftMapper): void => {
setMode('edit');
setDraft(mapperDraftFromNode(mapper));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -0,0 +1,322 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cloneDeep, isEqual } from 'lodash-es';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
useCreateSpanMapper,
useCreateSpanMapperGroup,
useDeleteSpanMapper,
useDeleteSpanMapperGroup,
useListSpanMapperGroups,
useUpdateSpanMapper,
useUpdateSpanMapperGroup,
} from 'api/generated/services/spanmapper';
import { persistDraft, SaveMutations } from '../saveDraft';
import {
DraftGroup,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
} from '../types';
import {
buildDraftGroup,
buildDraftMapper,
nodeFromGroupDraft,
nodeFromMapperDraft,
} from '../utils';
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
function clone(groups: DraftGroup[]): DraftGroup[] {
return cloneDeep(groups);
}
export interface AttributeMappingEditor {
groups: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
isSaving: boolean;
saveError: string | null;
upsertGroup: (draft: GroupDraft) => void;
removeGroup: (localId: string) => void;
toggleGroup: (localId: string, enabled: boolean) => void;
hydrateGroupMappers: (groupServerId: string, mappers: Mapper[]) => void;
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
toggleMapper: (
groupLocalId: string,
mapperLocalId: string,
enabled: boolean,
) => void;
save: () => Promise<void>;
discard: () => void;
}
export function useAttributeMappingEditor(): AttributeMappingEditor {
const queryClient = useQueryClient();
const groupsQuery = useListSpanMapperGroups();
const { refetch: refetchGroups } = groupsQuery;
const serverGroups: MapperGroup[] = useMemo(
() => groupsQuery.data?.data?.items ?? [],
[groupsQuery.data],
);
const ready = !groupsQuery.isLoading;
const [loadedMappers, setLoadedMappers] = useState<Record<string, Mapper[]>>(
{},
);
const loadedRef = useRef<Set<string>>(new Set());
const snapshot = useMemo<DraftGroup[]>(() => {
if (!ready) {
return [];
}
return serverGroups.map((group) =>
buildDraftGroup(group, loadedMappers[group.id] ?? []),
);
}, [ready, serverGroups, loadedMappers]);
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
// Initialise the working copy once data is ready (and re-init after a save
// clears it). Never clobbers in-flight edits — only runs when draft is null.
useEffect(() => {
if (ready && draft === null) {
setDraft(clone(snapshot));
}
}, [ready, draft, snapshot]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
const { mutateAsync: createMapper } = useCreateSpanMapper();
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
const mutations: SaveMutations = useMemo(
() => ({
createGroup: async (data): Promise<string> => {
const result = await createGroup({ data });
return result.data.id;
},
updateGroup: async (groupId, data): Promise<void> => {
await updateGroup({ pathParams: { groupId }, data });
},
deleteGroup: async (groupId): Promise<void> => {
await deleteGroup({ pathParams: { groupId } });
},
createMapper: async (groupId, data): Promise<void> => {
await createMapper({ pathParams: { groupId }, data });
},
updateMapper: async (groupId, mapperId, data): Promise<void> => {
await updateMapper({ pathParams: { groupId, mapperId }, data });
},
deleteMapper: async (groupId, mapperId): Promise<void> => {
await deleteMapper({ pathParams: { groupId, mapperId } });
},
}),
[
createGroup,
updateGroup,
deleteGroup,
createMapper,
updateMapper,
deleteMapper,
],
);
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
setDraft((prev) => {
const groups = prev ?? [];
if (groupDraft.id) {
return groups.map((group) =>
group.localId === groupDraft.id
? nodeFromGroupDraft(groupDraft, group)
: group,
);
}
return [...groups, nodeFromGroupDraft(groupDraft)];
});
}, []);
const removeGroup = useCallback((localId: string): void => {
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
}, []);
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === localId ? { ...group, enabled } : group,
),
);
}, []);
const hydrateGroupMappers = useCallback(
(groupServerId: string, mappers: Mapper[]): void => {
if (loadedRef.current.has(groupServerId)) {
return;
}
loadedRef.current.add(groupServerId);
setLoadedMappers((prev) => ({ ...prev, [groupServerId]: mappers }));
setDraft((prev) =>
prev === null
? prev
: prev.map((group) =>
group.serverId === groupServerId
? {
...group,
mappers: [
...mappers.map(buildDraftMapper),
...group.mappers.filter((mapper) => mapper.serverId === null),
],
}
: group,
),
);
},
[],
);
const upsertMapper = useCallback(
(groupLocalId: string, mapperDraft: MapperDraft): void => {
setDraft((prev) =>
(prev ?? []).map((group) => {
if (group.localId !== groupLocalId) {
return group;
}
if (mapperDraft.id) {
return {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperDraft.id
? nodeFromMapperDraft(mapperDraft, mapper)
: mapper,
),
};
}
return {
...group,
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
};
}),
);
},
[],
);
const removeMapper = useCallback(
(groupLocalId: string, mapperLocalId: string): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.filter(
(mapper) => mapper.localId !== mapperLocalId,
),
}
: group,
),
);
},
[],
);
const toggleMapper = useCallback(
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
),
}
: group,
),
);
},
[],
);
const discard = useCallback((): void => {
setSaveError(null);
setDraft(clone(snapshot));
}, [snapshot]);
const save = useCallback(async (): Promise<void> => {
if (!draft) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
await persistDraft(snapshot, draft, mutations);
// Refresh the groups list in place — it stays mounted, so this just
// swaps in fresh data without a loading flash. Using the query's own
// refetch keeps it scoped to the groups list; the per-group mapper
// caches are handled separately below (mappers can change here, so
// their cached lists must be dropped rather than left stale).
const { isError: refreshFailed } = await refetchGroups();
if (refreshFailed) {
toast.warning(
'Changes saved, but the list failed to refresh. Reload to see the latest.',
);
return;
}
// Reset the mapper-load tracking so expanded groups re-hydrate from
// the fresh server data.
loadedRef.current = new Set();
setLoadedMappers({});
// Reset the working copy so the effect above re-seeds it from the
// fresh snapshot (new server ids included).
setDraft(null);
// Evict the cached per-group mapper lists so an expanded group
// refetches its mappers against the fresh server state.
queryClient.removeQueries({
predicate: (query) =>
typeof query.queryKey?.[0] === 'string' &&
(query.queryKey[0] as string).startsWith(`${GROUPS_KEY_PREFIX}/`),
});
toast.success('Attribute mapping changes saved');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
toast.error(`Failed to save changes: ${message}`);
} finally {
setIsSaving(false);
}
}, [draft, snapshot, mutations, refetchGroups, queryClient]);
const isDirty = useMemo(
() => draft !== null && !isEqual(draft, snapshot),
[draft, snapshot],
);
return {
groups: draft ?? [],
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,
isSaving,
saveError,
upsertGroup,
removeGroup,
toggleGroup,
hydrateGroupMappers,
upsertMapper,
removeMapper,
toggleMapper,
save,
discard,
};
}

View File

@@ -0,0 +1,190 @@
import { isEqual } from 'lodash-es';
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DraftGroup, DraftMapper, SourceConfig } from './types';
import {
buildPostableGroup,
buildPostableMapper,
buildUpdatableGroup,
buildUpdatableMapper,
} from './utils';
// Thin persistence surface the editor wires to the generated mutations.
// createGroup returns the new server id so its mappers can be created under it.
export interface SaveMutations {
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
updateGroup: (
groupId: string,
data: SpantypesUpdatableSpanMapperGroupDTO,
) => Promise<void>;
deleteGroup: (groupId: string) => Promise<void>;
createMapper: (
groupId: string,
data: SpantypesPostableSpanMapperDTO,
) => Promise<void>;
updateMapper: (
groupId: string,
mapperId: string,
data: SpantypesUpdatableSpanMapperDTO,
) => Promise<void>;
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
}
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
return (
a.length === b.length &&
a.every(
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation,
)
);
}
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
return (
snapshot.name !== draft.name ||
snapshot.enabled !== draft.enabled ||
!isEqual(snapshot.attributes, draft.attributes) ||
!isEqual(snapshot.resource, draft.resource)
);
}
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
return (
snapshot.enabled !== draft.enabled ||
snapshot.fieldContext !== draft.fieldContext ||
!sourcesEqual(snapshot.sources, draft.sources)
);
}
function groupDraftOf(
node: DraftGroup,
): Parameters<typeof buildPostableGroup>[0] {
return {
id: node.serverId,
name: node.name,
attributes: node.attributes,
resource: node.resource,
enabled: node.enabled,
};
}
function mapperDraftOf(
node: DraftMapper,
): Parameters<typeof buildPostableMapper>[0] {
return {
id: node.serverId,
name: node.name,
fieldContext: node.fieldContext,
sources: node.sources,
enabled: node.enabled,
};
}
async function persistMappers(
groupServerId: string,
snapshotMappers: DraftMapper[],
draftMappers: DraftMapper[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshotMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => [mapper.serverId as string, mapper]),
);
const draftServerIds = new Set(
draftMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => mapper.serverId as string),
);
// Creates/updates before deletes (see persistDraft). Sequential for
// deterministic ordering.
for (const mapper of draftMappers) {
if (!mapper.serverId) {
// eslint-disable-next-line no-await-in-loop
await m.createMapper(
groupServerId,
buildPostableMapper(mapperDraftOf(mapper)),
);
} else {
const snap = snapById.get(mapper.serverId);
if (!snap || mapperChanged(snap, mapper)) {
// eslint-disable-next-line no-await-in-loop
await m.updateMapper(
groupServerId,
mapper.serverId,
buildUpdatableMapper(mapperDraftOf(mapper)),
);
}
}
}
// Deletes last.
await Promise.all(
snapshotMappers
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
);
}
// Diffs the staged tree against the server snapshot and issues the minimal set
// of create/update/delete calls to reconcile them.
export async function persistDraft(
snapshot: DraftGroup[],
draft: DraftGroup[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
const draftServerIds = new Set(
draft
.filter((group) => group.serverId)
.map((group) => group.serverId as string),
);
// Creates/updates before deletes: a mid-save failure then leaves incomplete
// additions rather than groups deleted with no replacement. Deletes are
// irreversible (cascade mappers server-side), so they run last.
//
// Names are unique per scope, so deleting and recreating the same name in
// one save collides and errors — recoverable by splitting across two saves,
// which we favour over the data loss delete-first would risk.
for (const group of draft) {
if (!group.serverId) {
// eslint-disable-next-line no-await-in-loop
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
// eslint-disable-next-line no-await-in-loop
await persistMappers(newId, [], group.mappers, m);
continue;
}
const snap = snapById.get(group.serverId);
if (!snap || groupChanged(snap, group)) {
// eslint-disable-next-line no-await-in-loop
await m.updateGroup(
group.serverId,
buildUpdatableGroup(groupDraftOf(group)),
);
}
// eslint-disable-next-line no-await-in-loop
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
}
// Deleted groups (cascades mappers server-side).
await Promise.all(
snapshot
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
.map((group) => m.deleteGroup(group.serverId as string)),
);
}

View File

@@ -1,26 +1,58 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
export type Mapper = SpantypesSpanMapperDTO;
export const FieldContext = SpantypesFieldContextDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
export type MapperDraftMode = 'add' | 'edit';
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
export interface Mapping {
id: string;
// Editable form state for a mapper. `sources` is ordered highest priority
// first; `fieldContext` is where the standardized target is written.
export interface MapperDraft {
id: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
export interface MappingGroup {
id: string;
export interface GroupDraft {
id: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}
export interface DraftMapper {
localId: string;
serverId: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
export interface DraftGroup {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
}

View File

@@ -1,15 +1,49 @@
import {
ListSpanMappers200,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import { MappingGroup, Mapping, SourceConfig } from './types';
import {
DraftGroup,
DraftMapper,
FieldContext,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from './types';
function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
// with a server UUID and is easy to spot in logs.
function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
function cleanKeys(keys: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
function getMapperSources(mapper: Mapper): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => a.priority - b.priority)
.sort((a, b) => b.priority - a.priority)
.map((source) => ({
key: source.key,
context: source.context,
@@ -17,9 +51,110 @@ function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
}));
}
export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
export function createEmptySource(): SourceConfig {
return {
id: mapper.id,
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
};
}
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
id: null,
name: '',
fieldContext: FieldContext.attribute,
sources: [createEmptySource()],
enabled: true,
};
function getCleanSources(draft: MapperDraft): SourceConfig[] {
const seen = new Set<string>();
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
const dedupeKey = `${source.context}:${key}`;
if (key && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...source, key });
}
});
return result;
}
export function isMapperDraftValid(draft: MapperDraft): boolean {
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
}
// Priority is derived from list order so the first row wins.
function buildSources(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO['config']['sources'] {
const sources = getCleanSources(draft);
return sources.map((source, index) => ({
key: source.key,
context: source.context,
operation: source.operation,
priority: sources.length - index,
}));
}
export function buildPostableMapper(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO {
return {
name: draft.name.trim(),
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
// The target name is immutable on update (UpdatableSpanMapper has no name).
export function buildUpdatableMapper(
draft: MapperDraft,
): SpantypesUpdatableSpanMapperDTO {
return {
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
resource: [],
enabled: true,
};
export function isGroupDraftValid(draft: GroupDraft): boolean {
return draft.name.trim().length > 0;
}
export function buildPostableGroup(
draft: GroupDraft,
): SpantypesPostableSpanMapperGroupDTO {
return {
name: draft.name.trim(),
enabled: draft.enabled,
condition: {
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
},
};
}
export function buildUpdatableGroup(
draft: GroupDraft,
): SpantypesUpdatableSpanMapperGroupDTO {
return buildPostableGroup(draft);
}
export function buildDraftMapper(mapper: Mapper): DraftMapper {
return {
localId: mapper.id,
serverId: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
@@ -27,22 +162,71 @@ export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
};
}
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 {
export function buildDraftGroup(
group: MapperGroup,
mappers: Mapper[],
): DraftGroup {
return {
id: group.id,
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
}
// DraftGroup -> editable form state (id carries the localId).
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
resource: group.resource,
enabled: group.enabled,
};
}
// DraftMapper -> editable form state (id carries the localId).
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
return {
id: mapper.localId,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources:
mapper.sources.length > 0
? mapper.sources.map((source) => ({ ...source }))
: [createEmptySource()],
enabled: mapper.enabled,
};
}
export function nodeFromGroupDraft(
draft: GroupDraft,
existing?: DraftGroup,
): DraftGroup {
return {
localId: existing?.localId ?? genLocalId('group'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
enabled: draft.enabled,
mappers: existing?.mappers ?? [],
};
}
export function nodeFromMapperDraft(
draft: MapperDraft,
existing?: DraftMapper,
): DraftMapper {
return {
localId: existing?.localId ?? genLocalId('mapper'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
fieldContext: draft.fieldContext,
sources: getCleanSources(draft),
enabled: draft.enabled,
};
}

View File

@@ -4,4 +4,10 @@
height: 100%;
margin-top: var(--spacing-2);
margin-left: var(--spacing-2);
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
[role='tabpanel'] {
margin: 0;
padding: var(--spacing-0) var(--spacing-4);
}
}

View File

@@ -2,26 +2,13 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
padding: var(--spacing-0) var(--spacing-0);
//TODO: remove this once we have a proper dashboard page.
// The embedded V2 DashboardContainer renders its own page header (dashboard
// title + share/feedback chrome) removed it for now
:global([class*='dashboardPageHeader']) {
display: none;
}
// remove margin added by the hidden header to avoid extra whitespace at the top of the page
margin-top: calc(var(--spacing-8) * -1);
}

View File

@@ -1,18 +1,15 @@
import DashboardContainer from 'pages/DashboardPageV2/DashboardContainer';
import { useSeededDashboardV2 } from './hooks/useSeededDashboardV2';
import styles from './Overview.module.scss';
// Overview tab content for LLM Observability. Currently the feature's landing
// surface; usage/cost/performance widgets land in later PRs.
function Overview(): JSX.Element {
//TODO: this is a temporary solution to get the seeded dashboard. We should fetch this json from the backend.
const { dashboard, refetch } = useSeededDashboardV2();
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
<DashboardContainer dashboard={dashboard} refetch={refetch} />
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useQueryClient } from 'react-query';
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import dashboardV2Json from '../json/dashboard.json';
const dashboard =
dashboardV2Json as unknown as DashboardtypesGettableDashboardV2DTO;
export interface UseSeededDashboardV2Result {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
}
const noop = (): void => {};
export function useSeededDashboardV2(): UseSeededDashboardV2Result {
const queryClient = useQueryClient();
const key = getGetDashboardV2QueryKey({ id: dashboard.id });
if (queryClient.getQueryData<GetDashboardV2200>(key) === undefined) {
queryClient.setQueryData<GetDashboardV2200>(key, {
data: dashboard,
status: 'success',
});
}
return { dashboard, refetch: noop };
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
--tab-content-padding: 0;
margin-top: var(--spacing-3);
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
}
.tabLabel {

View File

@@ -33,7 +33,7 @@ function LLMObservabilityModelPricing(): JSX.Element {
items={[
{
key: MODEL_COSTS_TAB,
label: 'Model costs',
label: 'Model Costs',
children: <ModelCostTabPanel />,
},
{

View File

@@ -3,6 +3,7 @@
gap: var(--spacing-6);
align-items: center;
justify-content: space-between;
margin-top: var(--spacing-8);
}
.filtersBarLeft {

View File

@@ -110,7 +110,7 @@ function ModelCostDrawer({
>
<div className={styles.drawerSection}>
<label htmlFor="billing-model-id">
Billing model ID{' '}
Billing Model ID{' '}
<span className={styles.required} aria-hidden="true">
*
</span>

View File

@@ -68,10 +68,10 @@ function ExtraPricingBuckets({
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
<div className={styles.extraBucketsSectionHead}>
<Typography.Text as="span" size="small" color="muted">
Extra pricing buckets
Extra Pricing Buckets
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
optional
Optional
</Typography.Text>
</div>

View File

@@ -39,7 +39,7 @@ function PricingFields({
<div className={styles.pricingGrid}>
<div className={styles.pricingField}>
<label htmlFor="input-cost">
Input cost{' '}
Input Cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
@@ -59,7 +59,7 @@ function PricingFields({
</div>
<div className={styles.pricingField}>
<label htmlFor="output-cost">
Output cost{' '}
Output Cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>

View File

@@ -7,6 +7,10 @@
:global(table) tbody tr {
cursor: default;
}
:global(.tanstack-header-title) {
color: var(--l2-foreground);
}
}
.modelCostsEmpty {

View File

@@ -42,9 +42,9 @@ export function getModelCostsColumns({
cell: ({ row }): JSX.Element => (
<div className={styles.modelCell}>
<Typography.Text
weight="semibold"
truncate={1}
testId={`model-cell-name-${row.id}`}
className={styles.tableCellStyles}
>
{row.modelName}
</Typography.Text>
@@ -59,7 +59,11 @@ export function getModelCostsColumns({
accessorKey: 'provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider ?? '',
cell: ({ row }): JSX.Element => (
<Typography.Text className={styles.tableCellStyles}>
{row.provider ?? ''}
</Typography.Text>
),
},
{
id: 'input',
@@ -67,7 +71,7 @@ export function getModelCostsColumns({
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
<Typography.Text className={styles.tableCellStyles}>
{formatPricePerMillion(row.pricing?.input)}
</Typography.Text>
),
@@ -78,7 +82,7 @@ export function getModelCostsColumns({
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
<Typography.Text className={styles.tableCellStyles}>
{formatPricePerMillion(row.pricing?.output)}
</Typography.Text>
),
@@ -92,7 +96,7 @@ export function getModelCostsColumns({
const buckets = getExtraBuckets(row);
if (buckets.length === 0) {
return (
<Typography.Text color="muted" as="span">
<Typography.Text as="span" className={styles.tableCellStyles}>
</Typography.Text>
);
@@ -106,10 +110,19 @@ export function getModelCostsColumns({
variant="outline"
className={styles.extraBucketsChip}
>
<Typography.Text as="span" size="small">
<Typography.Text
as="span"
size="small"
className={styles.tableCellStyles}
>
{startCase(bucket.key)}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
<Typography.Text
as="span"
size="small"
weight="semibold"
className={styles.tableCellStyles}
>
{formatPricePerMillion(bucket.pricePerMillion)}
</Typography.Text>
</Badge>
@@ -139,7 +152,11 @@ export function getModelCostsColumns({
header: 'Last seen',
width: { min: 120 },
enableMove: false,
cell: ({ row }): string => getRelativeLastSeen(row),
cell: ({ row }): JSX.Element => (
<Typography.Text className={styles.tableCellStyles}>
{getRelativeLastSeen(row)}
</Typography.Text>
),
},
{
id: 'actions',

View File

@@ -24,3 +24,7 @@
.sourceBadge {
margin: 0;
}
.tableCellStyles {
color: var(--l1-foreground);
}

View File

@@ -10,6 +10,14 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import LLMObservability from '../LLMObservability';
// The Overview tab renders the full V2 DashboardContainer (toolbar + date picker
// call useNavigationType, which needs a data router this integration test doesn't
// set up). These cases assert tab routing, not dashboard rendering, so stub it.
jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
}));
function setupList(items = mockRules): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
@@ -34,7 +42,7 @@ describe('LLMObservability (integration)', () => {
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
expect(screen.getByText('LLM Observability')).toBeInTheDocument();
expect(screen.getByTestId('llm-overview-dashboard')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: 'Model pricing' }),

View File

@@ -33,6 +33,8 @@ import {
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -43,7 +45,6 @@ import { isEmpty, isUndefined } from 'lodash-es';
import LiveLogs from 'pages/LiveLogs';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -51,7 +52,6 @@ import { Filter } from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { v4 } from 'uuid';
import LogsActionsContainer from './LogsActionsContainer';
@@ -75,6 +75,7 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -262,7 +263,7 @@ function LogsExplorerViewsContainer({
}, [data?.payload]);
const handleExport = useCallback(
(dashboard: Dashboard | null, isNewDashboard?: boolean): void => {
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !selectedPanelType) {
return;
}
@@ -282,19 +283,26 @@ function LogsExplorerViewsContainer({
logEvent('Logs Explorer: Add to dashboard successful', {
panelType: selectedPanelType,
isNewDashboard,
dashboardName: dashboard?.data?.title,
dashboardName: dashboard?.title,
});
const dashboardEditView = generateExportToDashboardLink({
const dashboardEditView = getExportToDashboardLink({
query: exportDefaultQuery,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
safeNavigate(dashboardEditView);
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[safeNavigate, exportDefaultQuery, selectedPanelType],
[
safeNavigate,
exportDefaultQuery,
selectedPanelType,
getExportToDashboardLink,
],
);
useEffect(() => {

View File

@@ -13,12 +13,12 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
@@ -81,7 +81,7 @@ function Explorer(): JSX.Element {
const handleExport = useCallback(
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
_isNewDashboard?: boolean,
queryToExport?: Query,
): void => {

View File

@@ -14,6 +14,8 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
@@ -31,11 +33,9 @@ import {
} from 'pages/MetricsExplorer/aiActions';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { explorerViewToPanelType } from 'utils/explorerUtils';
import { v4 as uuid } from 'uuid';
@@ -63,6 +63,7 @@ function Explorer(): JSX.Element {
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
@@ -260,7 +261,7 @@ function Explorer(): JSX.Element {
const handleExport = useCallback(
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
_isNewDashboard?: boolean,
queryToExport?: Query,
): void => {
@@ -278,16 +279,18 @@ function Explorer(): JSX.Element {
};
}
const dashboardEditView = generateExportToDashboardLink({
const dashboardEditView = getExportToDashboardLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: dashboard.id,
widgetId,
});
safeNavigate(dashboardEditView);
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[exportDefaultQuery, safeNavigate, yAxisUnit],
[exportDefaultQuery, safeNavigate, yAxisUnit, getExportToDashboardLink],
);
const splitedQueries = useMemo(

View File

@@ -0,0 +1,93 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useCreateExportDashboard } from '../useCreateExportDashboard';
jest.mock('hooks/useIsDashboardV2');
jest.mock('api/v1/dashboards/create');
jest.mock('api/generated/services/dashboard', () => ({
createDashboardV2: jest.fn(),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: jest.fn(),
}),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockCreateV1 = createDashboardV1 as jest.Mock;
const mockCreateV2 = createDashboardV2 as jest.Mock;
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
const TITLE = 'My dashboard';
beforeEach(() => {
jest.clearAllMocks();
});
describe('useCreateExportDashboard', () => {
it('creates via the V1 endpoint and returns the created dashboard when the flag is off', async () => {
mockUseIsDashboardV2.mockReturnValue(false);
const v1Dashboard = {
id: 'v1-new',
data: { title: TITLE },
} as unknown as Dashboard;
mockCreateV1.mockResolvedValue({ httpStatusCode: 200, data: v1Dashboard });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() =>
expect(onCreated).toHaveBeenCalledWith({ id: 'v1-new', title: TITLE }),
);
expect(mockCreateV1).toHaveBeenCalledWith({
title: TITLE,
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
expect(mockCreateV2).not.toHaveBeenCalled();
});
it('creates via the V2 Perses endpoint and normalizes the response when the flag is on', async () => {
mockUseIsDashboardV2.mockReturnValue(true);
mockCreateV2.mockResolvedValue({ data: { id: 'v2-new' } });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() =>
expect(onCreated).toHaveBeenCalledWith({ id: 'v2-new', title: TITLE }),
);
expect(mockCreateV2).toHaveBeenCalledWith(
expect.objectContaining({
schemaVersion: 'v6',
spec: expect.objectContaining({ display: { name: TITLE } }),
}),
);
expect(mockCreateV1).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,125 @@
import { renderHook } from '@testing-library/react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useExportDashboards } from '../useExportDashboards';
jest.mock('hooks/useIsDashboardV2');
jest.mock('hooks/dashboard/useGetAllDashboard');
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: jest.fn(),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockUseGetAllDashboard = useGetAllDashboard as jest.Mock;
const mockUseListV2 = useListDashboardsForUserV2 as jest.Mock;
const v1Refetch = jest.fn();
const v2Refetch = jest.fn();
const v1Dashboard = {
id: 'v1-1',
data: { title: 'V1 Dashboard' },
} as unknown as Dashboard;
const v1Other = {
id: 'v1-2',
data: { title: 'Other board' },
} as unknown as Dashboard;
beforeEach(() => {
jest.clearAllMocks();
mockUseGetAllDashboard.mockReturnValue({
data: { data: [v1Dashboard, v1Other] },
isLoading: false,
isFetching: false,
refetch: v1Refetch,
});
mockUseListV2.mockReturnValue({
data: {
data: {
dashboards: [
{
id: 'v2-1',
name: 'V2 Dashboard',
spec: { display: { name: 'V2 Dashboard' } },
},
],
},
},
isLoading: false,
isFetching: false,
refetch: v2Refetch,
});
});
describe('useExportDashboards', () => {
it('returns the V1 list and disables the V2 query when the flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
{ id: 'v1-1', title: 'V1 Dashboard' },
{ id: 'v1-2', title: 'Other board' },
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: true });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: { enabled: false, keepPreviousData: true },
}),
);
});
it('filters the V1 list in memory by title (case-insensitive)', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards('v1 dash'));
expect(result.current.dashboards).toStrictEqual([
{ id: 'v1-1', title: 'V1 Dashboard' },
]);
});
it('returns the V2 list normalized to the export shape when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
{ id: 'v2-1', title: 'V2 Dashboard' },
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: false });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: undefined }),
expect.objectContaining({
query: { enabled: true, keepPreviousData: true },
}),
);
});
it('passes the search term as a name-contains filter clause to the V2 query param', () => {
mockUseIsDashboardV2.mockReturnValue(true);
renderHook(() => useExportDashboards('payments'));
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: "name CONTAINS 'payments'" }),
expect.anything(),
);
});
it('refetches the active source', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
result.current.refetch();
expect(v2Refetch).toHaveBeenCalledTimes(1);
expect(v1Refetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,45 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetExportToDashboardLink } from '../useGetExportToDashboardLink';
jest.mock('hooks/useIsDashboardV2');
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const params = {
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
widgetId: 'w1',
};
describe('useGetExportToDashboardLink', () => {
it('builds a V1 new-widget link when the dashboard-v2 flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link?.startsWith('/dashboard/dash-1/new?')).toBe(true);
expect(link).toContain('graphType=');
expect(link).toContain('widgetId=w1');
expect(link).toContain('compositeQuery=');
});
it('builds a V2 panel/new link (ignoring widgetId) when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link?.startsWith('/dashboard/dash-1/panel/new?')).toBe(true);
expect(link).toContain('panelKind=signoz%2FTimeSeriesPanel');
expect(link).not.toContain('widgetId');
expect(link).toContain('compositeQuery=');
});
});

View File

@@ -0,0 +1,80 @@
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import type { ExportDashboard } from './useExportDashboards';
interface UseCreateExportDashboardParams {
title: string;
onCreated: (dashboard: ExportDashboard) => void;
}
interface UseCreateExportDashboardResult {
create: () => void;
isLoading: boolean;
}
/**
* Flag-aware "create a new dashboard to export into". V2 uses the Perses-spec
* `createDashboardV2`; V1 uses the legacy create. Both normalize to an `ExportDashboard`.
*/
export function useCreateExportDashboard({
title,
onCreated,
}: UseCreateExportDashboardParams): UseCreateExportDashboardResult {
const isDashboardV2 = useIsDashboardV2();
const { showErrorModal } = useErrorModal();
const onError = useCallback(
(error: unknown): void => showErrorModal(error as APIError),
[showErrorModal],
);
const v1 = useMutation(createDashboardV1, {
onSuccess: (data) => {
if (data.data) {
onCreated({ id: data.data.id, title: data.data.data.title ?? '' });
}
},
onError,
});
const v2 = useMutation(
() =>
createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: title },
layouts: [],
panels: {},
variables: [],
},
}),
{
onSuccess: (created) => {
onCreated({ id: created.data.id, title });
},
onError,
},
);
const create = useCallback((): void => {
if (isDashboardV2) {
v2.mutate();
} else {
v1.mutate({ title, uploadedGrafana: false, version: ENTITY_VERSION_V5 });
}
}, [isDashboardV2, v1, v2, title]);
return {
create,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
};
}

View File

@@ -0,0 +1,90 @@
import { useCallback, useMemo } from 'react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { DashboardtypesListedDashboardForUserV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useDebounce from 'hooks/useDebounce';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
const V2_LIST_LIMIT = 1000;
const SEARCH_DEBOUNCE_MS = 300;
/** Neutral id+title the picker uses in place of the V1/V2 dashboard entity. */
export interface ExportDashboard {
id: string;
title: string;
}
export interface UseExportDashboardsResult {
dashboards: ExportDashboard[];
/** First load only — disables the picker until there are options. */
isLoading: boolean;
/** Any fetch incl. a search refetch — drives the picker spinner. */
isFetching: boolean;
refetch: () => void;
}
function fromV2(
item: DashboardtypesListedDashboardForUserV2DTO,
): ExportDashboard {
return { id: item.id, title: item.spec.display?.name || item.name };
}
function fromV1(dashboard: Dashboard): ExportDashboard {
return { id: dashboard.id, title: dashboard.data.title ?? '' };
}
function filterByTitle(
dashboards: ExportDashboard[],
search: string,
): ExportDashboard[] {
const term = search.trim().toLowerCase();
if (!term) {
return dashboards;
}
return dashboards.filter((dashboard) =>
dashboard.title.toLowerCase().includes(term),
);
}
// The V2 list `query` is a filter DSL (`key OP value`), not free text — wrap a typed term
// as a name-contains clause (single quotes escaped).
function toNameQuery(search: string): string | undefined {
const term = search.trim();
return term ? `name CONTAINS '${term.replace(/'/g, "\\'")}'` : undefined;
}
/** Flag-aware picker source: V2 searches server-side (debounced), V1 filters in memory. */
export function useExportDashboards(search = ''): UseExportDashboardsResult {
const isDashboardV2 = useIsDashboardV2();
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const v1 = useGetAllDashboard({ enabled: !isDashboardV2 });
const v2 = useListDashboardsForUserV2(
{ limit: V2_LIST_LIMIT, query: toNameQuery(debouncedSearch) },
{ query: { enabled: isDashboardV2, keepPreviousData: true } },
);
const dashboards = useMemo<ExportDashboard[]>(
() =>
isDashboardV2
? (v2.data?.data?.dashboards ?? []).map(fromV2)
: filterByTitle((v1.data?.data ?? []).map(fromV1), search),
[isDashboardV2, v1.data, v2.data, search],
);
const refetch = useCallback((): void => {
if (isDashboardV2) {
void v2.refetch();
} else {
void v1.refetch();
}
}, [isDashboardV2, v1, v2]);
return {
dashboards,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
isFetching: isDashboardV2 ? v2.isFetching : v1.isFetching,
refetch,
};
}

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
interface ExportToDashboardLinkParams {
dashboardId: string;
panelType: PANEL_TYPES;
query: Query;
widgetId: string;
}
/**
* Flag-aware "Add to Dashboard" link builder for the explorers. V2 targets the panel
* editor; V1 uses the legacy new-widget link. `null` (V2 only) when the panel type has no
* V2 kind, so callers skip navigation.
*/
export function useGetExportToDashboardLink(): (
params: ExportToDashboardLinkParams,
) => string | null {
const isDashboardV2 = useIsDashboardV2();
return useCallback(
({ dashboardId, panelType, query, widgetId }: ExportToDashboardLinkParams) =>
isDashboardV2
? buildExportPanelLink({ dashboardId, panelType, query })
: generateExportToDashboardLink({
query,
panelType,
dashboardId,
widgetId,
}),
[isDashboardV2],
);
}

View File

@@ -3,6 +3,7 @@ import { EQueryType } from 'types/common/dashboard';
import {
buildVariableReferencePattern,
containsAnyVariableReference,
extractQueryTextStrings,
getVariableReferencesInQuery,
textContainsVariableReference,
@@ -448,3 +449,25 @@ describe('getVariableReferencesInQuery', () => {
expect(getVariableReferencesInQuery(query, [])).toStrictEqual([]);
});
});
describe('containsAnyVariableReference', () => {
it.each([
['SELECT count() FROM t WHERE service = $service.name', true],
['up{env="$deployment_environment"}', true],
['{{.service_name}}', true],
['{{ service_name }}', true],
['[[service_name]]', true],
['$_private', true],
])('detects a reference in %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
it.each([
['SELECT count() FROM t WHERE x = 1', false],
['rate(http_requests[$__interval])', false],
['SELECT $1 FROM t', false],
['', false],
])('does not falsely match %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
});

View File

@@ -1,4 +1,4 @@
import { isArray } from 'lodash-es';
import { escapeRegExp, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -34,18 +34,27 @@ export function textContainsVariableReference(
}
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
* - QUERY_BUILDER: filter items' string values + filter.expression
* - PROM: each promql[].query
* - CLICKHOUSE: each clickhouse_sql[].query
* Matches *any* variable reference in a recognized syntax without knowing the
* name: `{{name}}`, `{{.name}}`, `[[name]]`, or `$name`. The `$` form excludes
* `$__…` macros and positional `$1` so built-ins don't read as variables.
*/
const ANY_VARIABLE_REFERENCE =
/\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$(?!__)[a-zA-Z_][\w.]*/;
/**
* Use when the set of variable names isn't known yet (e.g. before the fetch
* context initializes), so a name-based {@link textContainsVariableReference}
* check can't run.
*/
export function containsAnyVariableReference(text: string): boolean {
return !!text && ANY_VARIABLE_REFERENCE.test(text);
}
function extractQueryBuilderTexts(query: Query): string[] {
const texts: string[] = [];
const queryDataList = query.builder?.queryData;
if (isArray(queryDataList)) {
queryDataList.forEach((queryData) => {
// Collect string values from filter items
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
if (isArray(filter.value)) {
filter.value.forEach((v) => {
@@ -58,7 +67,6 @@ function extractQueryBuilderTexts(query: Query): string[] {
}
});
// Collect filter expression
if (queryData.filter?.expression) {
texts.push(queryData.filter.expression);
}
@@ -115,11 +123,8 @@ export function extractQueryTextStrings(query: Query): string[] {
}
/**
* Given a widget Query and an array of variable names, returns the subset of
* variable names that are referenced in the query text.
*
* This performs text-based detection only. Structural checks (like
* filter.key.key matching a variable attribute) are NOT included.
* Text-based detection only. Structural checks (like filter.key.key matching a
* variable attribute) are NOT included.
*/
export function getVariableReferencesInQuery(
query: Query,
@@ -134,3 +139,30 @@ export function getVariableReferencesInQuery(
texts.some((text) => textContainsVariableReference(text, name)),
);
}
/**
* Rewrites every reference to `oldName` in `text` to `newName`, preserving the
* surrounding syntax for each recognized form ({{.x}}, {{x}}, $x, [[x]]). Used
* when a variable is renamed so its usages across queries stay valid.
*/
export function rewriteVariableReferences(
text: string,
oldName: string,
newName: string,
): string {
if (!text || !oldName || oldName === newName) {
return text;
}
const name = escapeRegExp(oldName);
return text
.replace(
new RegExp(`(\\{\\{\\s*?\\.)${name}(\\s*?\\}\\})`, 'g'),
`$1${newName}$2`,
)
.replace(new RegExp(`(\\{\\{\\s*)${name}(\\s*\\}\\})`, 'g'), `$1${newName}$2`)
.replace(new RegExp(`\\$${name}\\b`, 'g'), `$${newName}`)
.replace(
new RegExp(`(\\[\\[\\s*)${name}(\\s*\\]\\])`, 'g'),
`$1${newName}$2`,
);
}

View File

@@ -22,9 +22,9 @@
width: 100%;
}
.toolbarRow2 {
.toolbarRow {
width: 100%;
margin-top: 12px;
margin-top: 10px;
&::after {
display: block;
@@ -35,5 +35,5 @@
.timeCluster {
float: right;
margin: 0 0 0 16px;
margin: 8px 0 0 16px;
}

View File

@@ -190,7 +190,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
{/* Row 2: the time selector floats top-right (declared first so the
variables bar's content wraps around it); the variables bar
collapses to one line and, when expanded, wraps full-width under it. */}
<div className={styles.toolbarRow2}>
<div className={styles.toolbarRow}>
<div className={styles.timeCluster}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>

View File

@@ -4,7 +4,7 @@ import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { detectVariableCycle } from '../variableDependencies';
import { detectVariableCycle } from '../utils/variableCycleDetection';
import {
sortValuesByOrder,
type VariableFormModel,

View File

@@ -0,0 +1,81 @@
.body {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.intro {
color: var(--l2-foreground);
font-size: 13px;
line-height: 1.5;
}
.rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l1-background);
}
.rowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sourceLabel {
color: var(--l1-foreground);
font-weight: 600;
font-size: 13px;
}
.kindTag {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
white-space: nowrap;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.textArea {
font-family: var(--font-mono, monospace);
font-size: 12px;
}
.disabled {
opacity: 0.6;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,163 @@
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea + Checkbox have no @signozhq/ui equivalent yet
import { Checkbox, Input as AntdInput } from 'antd';
import cx from 'classnames';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type {
VariableImpactMode,
VariableUsage,
} from '../utils/variableUsages';
import { useVariableImpactState } from './useVariableImpactState';
import styles from './VariableImpactDialog.module.scss';
const KIND_LABEL: Record<VariableUsage['kind'], string> = {
builder: 'Query builder',
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
};
interface VariableImpactDialogProps {
open: boolean;
mode: VariableImpactMode;
/** The variable being renamed/deleted (its current name). */
variableName: string;
/** The new name (rename mode only). */
newName?: string;
usages: VariableUsage[];
isLoading: boolean;
onConfirm: (resolvedUsages: VariableUsage[]) => void;
onClose: () => void;
}
/**
* Blocks a rename/delete of a referenced variable behind a review step: lists
* every usage across panel queries (builder / PromQL / ClickHouse) and other
* variables, shows the current vs resulting query, and lets the user edit each
* result or exclude it before applying.
*/
function VariableImpactDialog({
open,
mode,
variableName,
newName,
usages,
isLoading,
onConfirm,
onClose,
}: VariableImpactDialogProps): JSX.Element {
const { rows, setResultingText, toggleIncluded, resolvedUsages } =
useVariableImpactState(usages, open);
const isRename = mode === 'rename';
const count = usages.length;
const plural = count === 1 ? '' : 's';
const intro = isRename
? `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`
: `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
const footer = (
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
onClick={onClose}
testId="variable-impact-cancel"
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color={isRename ? 'primary' : 'destructive'}
loading={isLoading}
onClick={(): void => onConfirm(resolvedUsages)}
testId="variable-impact-confirm"
>
<Check size={12} />
{isRename ? 'Rename' : 'Delete'}
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={isRename ? `Rename $${variableName}` : `Delete $${variableName}`}
width="wide"
showCloseButton={false}
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
style={{ zIndex: 1100 }}
showOverlay={false}
footer={footer}
>
<div className={styles.body}>
<Typography.Text className={styles.intro}>{intro}</Typography.Text>
<div className={styles.rows}>
{rows.map((row) => {
const stillReferences =
row.included &&
textContainsVariableReference(row.resultingText, variableName);
return (
<div
key={row.id}
className={styles.row}
data-testid={`variable-impact-row-${row.id}`}
>
<div className={styles.rowHeader}>
<Checkbox
checked={row.included}
onChange={(): void => toggleIncluded(row.id)}
data-testid={`variable-impact-include-${row.id}`}
>
<span className={styles.sourceLabel}>{row.sourceLabel}</span>
</Checkbox>
<span className={styles.kindTag}>{KIND_LABEL[row.kind]}</span>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>
Current
</Typography.Text>
<AntdInput.TextArea
className={styles.textArea}
value={row.currentText}
disabled
readOnly
autoSize={{ minRows: 1, maxRows: 4 }}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Result</Typography.Text>
<AntdInput.TextArea
className={cx(styles.textArea, !row.included && styles.disabled)}
value={row.resultingText}
disabled={!row.included}
autoSize={{ minRows: 1, maxRows: 4 }}
onChange={(e): void => setResultingText(row.id, e.target.value)}
data-testid={`variable-impact-result-${row.id}`}
/>
{stillReferences ? (
<Typography.Text size={'small'} color="warning">
Still references ${variableName}
</Typography.Text>
) : null}
</div>
</div>
);
})}
</div>
</div>
</DialogWrapper>
);
}
export default VariableImpactDialog;

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react';
import type { VariableUsage } from '../utils/variableUsages';
/** A usage row plus whether its edit will be applied on confirm. */
export interface EditableVariableUsage extends VariableUsage {
included: boolean;
}
interface UseVariableImpactState {
rows: EditableVariableUsage[];
setResultingText: (id: string, text: string) => void;
toggleIncluded: (id: string) => void;
/** The included rows, as plain usages, to build the patch from. */
resolvedUsages: VariableUsage[];
}
/**
* Editable state for the impact dialog: a per-usage copy the user can edit
* (`resultingText`) and include/exclude before applying. Resets whenever the
* dialog (re)opens for a fresh usage set.
*/
export function useVariableImpactState(
usages: VariableUsage[],
open: boolean,
): UseVariableImpactState {
const [rows, setRows] = useState<EditableVariableUsage[]>([]);
useEffect(() => {
if (open) {
setRows(usages.map((usage) => ({ ...usage, included: true })));
}
}, [open, usages]);
const setResultingText = useCallback((id: string, text: string): void => {
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, resultingText: text } : row)),
);
}, []);
const toggleIncluded = useCallback((id: string): void => {
setRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, included: !row.included } : row,
),
);
}, []);
const resolvedUsages: VariableUsage[] = rows.filter((row) => row.included);
return { rows, setResultingText, toggleIncluded, resolvedUsages };
}

View File

@@ -9,153 +9,3 @@
justify-content: flex-end;
gap: 16px;
}
.titleRow {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.title {
font-size: 14px;
font-weight: 500;
color: var(--l1-foreground);
}
.subtitle {
font-size: 12px;
color: var(--l2-foreground);
}
// Two-column (Variable / Description) table matching the V1 variables listing.
$grid-columns: minmax(0, 2fr) minmax(0, 3fr);
.table {
display: flex;
flex-direction: column;
border: 1px solid var(--l2-border);
border-radius: 6px;
overflow: hidden;
}
.headerRow {
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
border-bottom: 1px solid var(--l2-border);
}
.headerCell {
padding: 12px 0;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
&:first-child {
padding-right: 16px;
border-right: 1px solid var(--l2-border);
}
&:last-child {
padding-left: 16px;
}
}
.list {
display: flex;
flex-direction: column;
}
.row {
position: relative;
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
background: var(--l1-background);
&:not(:last-child) {
border-bottom: 1px solid var(--l2-border);
}
&:hover {
background: var(--l2-background);
}
}
.varCell {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 12px 16px 12px 0;
border-right: 1px solid var(--l2-border);
}
.dragHandle {
display: flex;
flex-shrink: 0;
align-items: center;
margin-top: 1px;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
}
.varName {
min-width: 0;
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-robin-400);
word-break: break-all;
}
.descCell {
position: relative;
min-width: 0;
padding: 12px 0 12px 16px;
}
.varDesc {
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-sienna-400);
white-space: pre-wrap;
word-break: break-word;
}
.varDescEmpty {
color: var(--l3-foreground);
}
.rowActions {
position: absolute;
top: 8px;
right: 0;
display: flex;
align-items: center;
gap: 2px;
padding-left: 16px;
background: var(--l2-background);
opacity: 0;
transition: opacity 120ms ease;
}
.row:hover .rowActions {
opacity: 1;
}
.rowActionsVisible {
opacity: 1;
}
.confirmText {
margin-right: 4px;
font-size: 12px;
color: var(--l2-foreground);
}
.applyAllButton {
font-size: 12px;
white-space: nowrap;
}

View File

@@ -7,7 +7,7 @@ import {
buildApplyVariableToPanelsPatch,
buildSyncVariableToPanelsPatch,
getPanelIdsReferencingVariable,
} from '../applyVariableToPanelsPatch';
} from '../utils/applyVariableToPanelsPatch';
/** Minimal builder-query panel wrapped in a CompositeQuery, with a given filter. */
function compositePanel(filterExpression: string): unknown {

View File

@@ -0,0 +1,98 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { findVariableUsages } from '../utils/variableUsages';
// Identity adapter so `spec.variables` can be plain form models in the test.
jest.mock('../variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function variable(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function builderPanel(name: string, expression: string): unknown {
return {
spec: {
display: { name },
queries: [
{
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: { filter: { expression } } },
},
},
],
},
};
}
function promqlPanel(name: string, query: string): unknown {
return {
spec: {
display: { name },
queries: [
{ spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { query } } } },
],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
): DashboardtypesGettableDashboardV2DTO {
return {
spec: { panels, variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
describe('findVariableUsages', () => {
const dash = dashboard(
{
p1: builderPanel('Panel One', "service IN $svc AND env = 'prod'"),
p2: promqlPanel('Panel Two', 'up{s="$svc"}'),
p3: builderPanel('Unrelated', "env = 'prod'"),
},
[
variable({ name: 'svc', type: 'QUERY' }),
variable({
name: 'other',
type: 'QUERY',
queryValue: 'SELECT x WHERE s = $svc',
}),
variable({ name: 'plain', type: 'QUERY', queryValue: 'SELECT y' }),
],
);
it('finds panel (builder + promql) and variable usages, skipping unrelated ones', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const ids = usages.map((u) => u.id).sort();
expect(ids).toStrictEqual(['panel:p1:0', 'panel:p2:0', 'variable:other:0']);
});
it('rewrites references for a rename across all kinds', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
expect(byId['panel:p1:0']).toBe("service IN $zone AND env = 'prod'");
expect(byId['panel:p2:0']).toBe('up{s="$zone"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $zone');
});
it('strips builder clauses on delete but leaves raw/variable queries for review', () => {
const usages = findVariableUsages(dash, 'svc', 'delete');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
// Builder: the clause referencing $svc is dropped.
expect(byId['panel:p1:0']).toBe("env = 'prod'");
// Raw PromQL + variable query: unchanged (user edits).
expect(byId['panel:p2:0']).toBe('up{s="$svc"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $svc');
});
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,95 @@
// Keep $grid-columns in sync with VariablesList.module.scss so header and rows align.
$grid-columns: minmax(0, 2fr) minmax(0, 3fr);
.row {
position: relative;
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
background: var(--l1-background);
&:not(:last-child) {
border-bottom: 1px solid var(--l2-border);
}
&:hover {
background: var(--l2-background);
}
}
.varCell {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 12px 16px 12px 0;
border-right: 1px solid var(--l2-border);
}
.dragHandle {
display: flex;
flex-shrink: 0;
align-items: center;
margin-top: 1px;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
}
.varName {
min-width: 0;
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-robin-400);
word-break: break-all;
}
.descCell {
position: relative;
min-width: 0;
padding: 12px 0 12px 16px;
}
.varDesc {
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-sienna-400);
white-space: pre-wrap;
word-break: break-word;
}
.varDescEmpty {
color: var(--l3-foreground);
}
.rowActions {
position: absolute;
top: 8px;
right: 0;
display: flex;
align-items: center;
gap: 2px;
padding-left: 16px;
background: var(--l2-background);
opacity: 0;
transition: opacity 120ms ease;
}
.row:hover .rowActions {
opacity: 1;
}
.rowActionsVisible {
opacity: 1;
}
.confirmText {
margin-right: 4px;
font-size: 12px;
color: var(--l2-foreground);
}
.applyAllButton {
font-size: 12px;
white-space: nowrap;
}

View File

@@ -7,8 +7,8 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
import type { VariableFormModel } from '../../variableFormModel';
import styles from './VariableRow.module.scss';
interface VariableRowProps {
variable: VariableFormModel;

View File

@@ -0,0 +1,39 @@
// Two-column (Variable / Description) table matching the V1 variables listing.
// Keep $grid-columns in sync with VariableRow.module.scss so header and rows align.
$grid-columns: minmax(0, 2fr) minmax(0, 3fr);
.table {
display: flex;
flex-direction: column;
border: 1px solid var(--l2-border);
border-radius: 6px;
overflow: hidden;
}
.headerRow {
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
border-bottom: 1px solid var(--l2-border);
}
.headerCell {
padding: 12px 0;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
&:first-child {
padding-right: 16px;
border-right: 1px solid var(--l2-border);
}
&:last-child {
padding-left: 16px;
}
}
.list {
display: flex;
flex-direction: column;
}

View File

@@ -11,9 +11,9 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import VariableRow from './VariableRow';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
import VariableRow from '../VariableRow/VariableRow';
import type { VariableFormModel } from '../../variableFormModel';
import styles from './VariablesList.module.scss';
interface VariablesListProps {
variables: VariableFormModel[];

View File

@@ -3,11 +3,11 @@ import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
import { buildVariablesPatch } from './variablePatchOps';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { formModelToDto } from '../variableAdapters';
import type { VariableFormModel } from '../variableFormModel';
import { buildVariablesPatch } from '../utils/variablePatchOps';
interface UseSaveVariables {
save: (variables: VariableFormModel[]) => Promise<boolean>;

View File

@@ -0,0 +1,250 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useState,
} from 'react';
import { toast } from '@signozhq/ui/sonner';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { buildSyncVariableToPanelsPatch } from '../utils/applyVariableToPanelsPatch';
import type { VariableFormModel } from '../variableFormModel';
import {
applyVariableQueryEdits,
buildVariableImpactPatch,
} from '../utils/variableImpactPatch';
import {
findVariableUsages,
type VariableImpactMode,
type VariableUsage,
} from '../utils/variableUsages';
import type { EditingState } from '../types';
/**
* A pending rename/delete that touches other queries — resolved via the impact
* dialog before it is applied. `nextVariables` is the array to persist (with the
* rename/delete already applied), before any variable-query edits.
*/
export interface VariableImpact {
mode: VariableImpactMode;
variableName: string;
newName?: string;
usages: VariableUsage[];
nextVariables: VariableFormModel[];
}
interface UseVariableListActionsParams {
dashboard: DashboardtypesGettableDashboardV2DTO;
variables: VariableFormModel[];
setVariables: Dispatch<SetStateAction<VariableFormModel[]>>;
isEditing: EditingState;
setIsEditing: Dispatch<SetStateAction<EditingState>>;
save: (variables: VariableFormModel[]) => Promise<boolean>;
patchAsync: (ops: DashboardtypesJSONPatchOperationDTO[]) => Promise<unknown>;
}
interface UseVariableListActions {
confirmDeleteIndex: number | null;
setConfirmDeleteIndex: Dispatch<SetStateAction<number | null>>;
impact: VariableImpact | null;
setImpact: Dispatch<SetStateAction<VariableImpact | null>>;
handleFormSave: (
formModel: VariableFormModel,
selectedPanelIds: string[],
) => void;
handleMove: (from: number, to: number) => void;
requestDelete: (index: number) => void;
handleConfirmDelete: (index: number) => void;
handleImpactConfirm: (resolvedUsages: VariableUsage[]) => Promise<void>;
}
/**
* Variables-list mutation and impact-flow actions: reorder, save/rename, and the
* referenced-variable delete/rename flows resolved through the impact dialog.
* Owns the delete-confirm and pending-impact state the list renders against.
*/
export function useVariableListActions({
dashboard,
variables,
setVariables,
isEditing,
setIsEditing,
save,
patchAsync,
}: UseVariableListActionsParams): UseVariableListActions {
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const [impact, setImpact] = useState<VariableImpact | null>(null);
const persist = useCallback(
(next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
},
[save, setVariables],
);
const handleFormSave = useCallback(
(formModel: VariableFormModel, selectedPanelIds: string[]): void => {
const editingIndex = isEditing?.type === 'edit' ? isEditing.index : null;
const oldName = editingIndex !== null ? variables[editingIndex].name : null;
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(formModel);
} else if (editingIndex !== null) {
next[editingIndex] = formModel;
}
// A rename that other queries/variables reference must be reviewed first, so
// the references are rewritten alongside the rename (never left dangling).
if (oldName && oldName !== formModel.name) {
const usages = findVariableUsages(
dashboard,
oldName,
'rename',
formModel.name,
);
if (usages.length > 0) {
setIsEditing(null);
setImpact({
mode: 'rename',
variableName: oldName,
newName: formModel.name,
usages,
nextVariables: next,
});
return;
}
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
const saved = await save(next);
if (!saved || formModel.type !== 'DYNAMIC') {
return;
}
const ops = buildSyncVariableToPanelsPatch(
dashboard.spec.panels,
formModel.dynamicAttribute,
formModel.name,
selectedPanelIds,
);
if (ops.length === 0) {
return;
}
try {
await patchAsync(ops);
} catch {
toast.error('Could not update panels');
}
})();
},
[
dashboard,
isEditing,
patchAsync,
save,
setIsEditing,
setVariables,
variables,
],
);
const handleMove = useCallback(
(from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
},
[persist, variables],
);
const handleConfirmDelete = useCallback(
(index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
},
[persist, variables],
);
// Delete requested from the list: if the variable is referenced anywhere, block
// and open the impact dialog; otherwise fall through to the simple confirm.
const requestDelete = useCallback(
(index: number): void => {
const usages = findVariableUsages(
dashboard,
variables[index].name,
'delete',
);
if (usages.length > 0) {
setImpact({
mode: 'delete',
variableName: variables[index].name,
usages,
nextVariables: variables.filter((_, i) => i !== index),
});
return;
}
setConfirmDeleteIndex(index);
},
[dashboard, variables],
);
// Applies a resolved rename/delete: the variables array (rename/delete + edited
// variable queries) and each touched panel's queries, in one atomic patch.
const handleImpactConfirm = useCallback(
async (resolvedUsages: VariableUsage[]): Promise<void> => {
if (!impact) {
return;
}
const nextVariables = applyVariableQueryEdits(
impact.nextVariables,
resolvedUsages,
);
const ops = buildVariableImpactPatch(
dashboard,
nextVariables,
resolvedUsages,
);
setVariables(nextVariables);
try {
await patchAsync(ops);
toast.success(
impact.mode === 'rename'
? `Renamed to $${impact.newName}`
: `Deleted $${impact.variableName}`,
);
} catch {
toast.error(
impact.mode === 'rename'
? 'Could not rename the variable'
: 'Could not delete the variable',
);
}
setImpact(null);
},
[dashboard, impact, patchAsync, setVariables],
);
return {
confirmDeleteIndex,
setConfirmDeleteIndex,
impact,
setImpact,
handleFormSave,
handleMove,
requestDelete,
handleConfirmDelete,
handleImpactConfirm,
};
}

View File

@@ -8,17 +8,18 @@ import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import {
buildApplyVariableToPanelsPatch,
buildSyncVariableToPanelsPatch,
getPanelIdsReferencingVariable,
} from './applyVariableToPanelsPatch';
import { useSaveVariables } from './useSaveVariables';
} from './utils/applyVariableToPanelsPatch';
import { useSaveVariables } from './hooks/useSaveVariables';
import { useVariableListActions } from './hooks/useVariableListActions';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableFormModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import VariableImpactDialog from './VariableImpactDialog/VariableImpactDialog';
import VariablesList from './components/VariablesList/VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
import ApplyToAllDialog from './components/ApplyToAllDialog/ApplyToAllDialog';
@@ -55,11 +56,28 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const [isEditing, setIsEditing] = useState<EditingState>(
openAddOnMount && isEditable ? { type: 'new' } : null,
);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
const {
confirmDeleteIndex,
setConfirmDeleteIndex,
impact,
setImpact,
handleFormSave,
handleMove,
requestDelete,
handleConfirmDelete,
handleImpactConfirm,
} = useVariableListActions({
dashboard,
variables,
setVariables,
isEditing,
setIsEditing,
save,
patchAsync,
});
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
return null;
@@ -95,60 +113,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
);
}, [editingFormModel, dashboard.spec.panels]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (
formModel: VariableFormModel,
selectedPanelIds: string[],
): void => {
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(formModel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = formModel;
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
const saved = await save(next);
if (!saved || formModel.type !== 'DYNAMIC') {
return;
}
const ops = buildSyncVariableToPanelsPatch(
dashboard.spec.panels,
formModel.dynamicAttribute,
formModel.name,
selectedPanelIds,
);
if (ops.length === 0) {
return;
}
try {
await patchAsync(ops);
} catch {
toast.error('Could not update panels');
}
})();
};
const handleMove = (from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
};
const handleConfirmDelete = (index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
};
const applyToAllVariable =
applyToAllIndex === null ? null : variables[applyToAllIndex];
@@ -190,7 +154,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
);
}
// Master view — the variables list.
return (
<div className={cx(styles.container, settingsStyles.settingsCard)}>
{variables.length === 0 ? (
@@ -202,7 +165,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onRequestDelete={requestDelete}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
@@ -220,6 +183,16 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
onConfirm={(): void => void handleConfirmApplyToAll()}
onClose={(): void => setApplyToAllIndex(null)}
/>
<VariableImpactDialog
open={impact !== null}
mode={impact?.mode ?? 'delete'}
variableName={impact?.variableName ?? ''}
newName={impact?.newName}
usages={impact?.usages ?? []}
isLoading={isPatching}
onConfirm={(resolved): void => void handleImpactConfirm(resolved)}
onClose={(): void => setImpact(null)}
/>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import {
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
import type { IDashboardVariable } from 'types/api/dashboard/getAll';
import type { VariableFormModel } from './variableFormModel';
import type { VariableFormModel } from '../variableFormModel';
/**
* Detects a circular reference among QUERY variables (a query referencing

View File

@@ -0,0 +1,123 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
import { formModelToDto } from '../variableAdapters';
import type { VariableFormModel } from '../variableFormModel';
import { buildVariablesPatch } from './variablePatchOps';
import type { VariableUsage, VariableUsageKind } from './variableUsages';
/** Minimal writable view of an envelope spec's reference-bearing fields. */
interface WritableSpec {
query?: string;
filter?: { expression?: string };
}
/** Writes the resolved text into the spec's builder filter or raw query field. */
function writeSpecText(
spec: WritableSpec,
kind: VariableUsageKind,
text: string,
): void {
if (kind === 'builder') {
spec.filter = { ...(spec.filter ?? {}), expression: text };
} else {
spec.query = text;
}
}
/** Applies one panel usage's edited text into a (cloned) queries array in place. */
function applyPanelUsage(
queries: DashboardtypesQueryDTO[],
usage: VariableUsage,
): void {
const plugin = queries[0]?.spec?.plugin;
if (!plugin?.spec) {
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
const envelope = (composite.queries ?? [])[usage.envelopeIndex];
if (envelope?.spec) {
writeSpecText(
envelope.spec as WritableSpec,
usage.kind,
usage.resultingText,
);
}
} else {
// Bare BuilderQuery / PromQLQuery / ClickHouseSQL — the plugin spec is the
// single envelope (index 0).
writeSpecText(plugin.spec as WritableSpec, usage.kind, usage.resultingText);
}
}
/**
* Applies the variable-definition usages' edited text back into the matching
* variable's `queryValue`, so a renamed/deleted variable's references inside
* another query variable are updated alongside the panels.
*/
export function applyVariableQueryEdits(
variables: VariableFormModel[],
usages: VariableUsage[],
): VariableFormModel[] {
const edits = new Map(
usages
.filter((usage) => usage.sourceType === 'variable')
.map((usage) => [usage.sourceId, usage.resultingText]),
);
if (edits.size === 0) {
return variables;
}
return variables.map((variable) =>
edits.has(variable.name)
? { ...variable, queryValue: edits.get(variable.name) as string }
: variable,
);
}
/**
* Builds the atomic JSON-Patch for a variable rename/delete impact: replaces the
* whole variables array (which the caller has already updated for the rename/
* delete and any variable-query edits) and replaces each touched panel's queries
* with the user's resolved text.
*/
export function buildVariableImpactPatch(
dashboard: DashboardtypesGettableDashboardV2DTO,
nextVariables: VariableFormModel[],
usages: VariableUsage[],
): DashboardtypesJSONPatchOperationDTO[] {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
...buildVariablesPatch(nextVariables.map(formModelToDto)),
];
const panels = dashboard.spec.panels ?? {};
const byPanel = new Map<string, VariableUsage[]>();
usages
.filter((usage) => usage.sourceType === 'panel')
.forEach((usage) => {
const list = byPanel.get(usage.sourceId) ?? [];
list.push(usage);
byPanel.set(usage.sourceId, list);
});
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
list.forEach((usage) => applyPanelUsage(queries, usage));
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/queries`,
value: queries,
});
});
return ops;
}

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