Compare commits

..

220 Commits

Author SHA1 Message Date
Gaurav Tewari
412614c82f chore: add test tab 2026-07-07 09:36:47 +05:30
Gaurav Tewari
e0f1a59078 chore: improve the test ui 2026-07-07 01:54:10 +05:30
Gaurav Tewari
4483e4947d Merge branch 'feat/llm-attr-mapping-mapper-drawer-4' into feat/llm-attr-mapping-test
# Conflicts:
#	frontend/src/container/LLMObservability/AttributeMapping/LLMObservabilityAttributeMapping.tsx
#	frontend/src/container/LLMObservability/AttributeMapping/TestResult.tsx
#	frontend/src/container/LLMObservability/AttributeMapping/TestTab.module.scss
#	frontend/src/container/LLMObservability/AttributeMapping/TestTab.tsx
#	frontend/src/container/LLMObservability/AttributeMapping/testPayload.ts
#	frontend/src/container/LLMObservability/AttributeMapping/useTestSpanMapper.ts
2026-07-07 01:41:06 +05:30
Gaurav Tewari
2eeda8c24a Merge branch 'feat/llm-attr-mapping-group-drawer-3' into feat/llm-attr-mapping-mapper-drawer-4 2026-07-06 20:21:19 +05:30
Gaurav Tewari
90d9ab9116 Merge branch 'feat/llm-attr-mapping-listing-2' into feat/llm-attr-mapping-group-drawer-3 2026-07-06 20:19:04 +05:30
Gaurav Tewari
3689de90a7 Merge branch 'feat/llm-attr-mapping-foundation-1' into feat/llm-attr-mapping-listing-2 2026-07-06 20:13:21 +05:30
Gaurav Tewari
d7102b8efb Merge branch 'feat/llm-pricing-ui-update-and-tests' into feat/llm-attr-mapping-foundation-1 2026-07-06 20:10:16 +05:30
Gaurav Tewari
4195a1a0e3 Merge remote-tracking branch 'origin' into feat/llm-pricing-ui-update-and-tests 2026-07-06 20:06:37 +05:30
Gaurav Tewari
cc2c3527f2 chore: update env.ts 2026-07-06 20:05:15 +05:30
Gaurav Tewari
7c86db3f61 Merge branch 'feat/llm-pricing-ui-update-and-tests' into feat/llm-attr-mapping-foundation-1 2026-07-06 20:04:29 +05:30
Gaurav Tewari
5c1b520841 Merge remote-tracking branch 'origin' into feat/llm-attr-mapping-foundation-1 2026-07-06 20:02:17 +05:30
Gaurav Tewari
a81bfe9272 Merge remote-tracking branch 'origin' into feat/llm-pricing-ui-update-and-tests 2026-07-06 19:53:01 +05:30
Gaurav Tewari
b0a14754ca chore: update title in tabs 2026-07-06 19:40:57 +05:30
Gaurav Tewari
2c04dbc18a fix: mapper issue 2026-07-06 19:31:49 +05:30
Gaurav Tewari
e3e2c52873 chore: revert evn commit 2026-07-06 18:37:26 +05:30
Gaurav Tewari
15c6c68f42 Merge remote-tracking branch 'origin/main' into feat/llm-attr-mapping-mapper-drawer-4 2026-07-06 18:29:00 +05:30
Gaurav Tewari
fbba9d9d05 Merge branch 'feat/llm-attr-mapping-group-drawer-3', remote-tracking branch 'origin' into feat/llm-attr-mapping-mapper-drawer-4
# Conflicts:
#	frontend/src/container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore.ts
#	frontend/src/container/LLMObservability/AttributeMapping/saveDraft.ts
2026-07-06 18:28:18 +05:30
Gaurav Tewari
68d8089e1e chore: update save draft 2026-07-06 15:52:55 +05:30
Gaurav Tewari
a032155991 chore: use lodash 2026-07-06 15:44:39 +05:30
Gaurav Tewari
a079990f26 chore: lodash isEqual 2026-07-06 15:29:30 +05:30
Gaurav Tewari
0f84ef2b97 chore: minior ux update 2026-07-06 15:21:05 +05:30
Gaurav Tewari
91919cc7ed refactor(llm-attribute-mapping): group leaf components under components/
Move the flat leaf components into per-component folders under components/,
matching the AttributeMappingHeader convention:
- components/KeySearchInput/ (shared by the drawers + ConditionKeyList)
- components/GroupFormDrawer/ (with its private ConditionKeyList)
- components/MapperFormDrawer/ (with its private SourceAttributeRow)

Each folder exposes a default via index.ts. Container, hooks (useGroupFormDrawer,
useMapperFormDrawer) and data modules (types/utils/saveDraft) stay at the feature
root. Imports updated; no behavior change.
2026-07-01 20:09:17 +05:30
Gaurav Tewari
385a5c1d23 merge: sync feat/llm-attr-mapping-group-drawer-3 into mapper-drawer-4
Bring in g3's new test suites (fixtures, utils, store, tables, tab, header) and
the read-only refactors. Reconciled with the mapper-drawer feature:
- types.ts: keep MapperOperation/MapperOperationValue aliases (used by the mapper
  form/utils), consistent with the retained FieldContext value alias
- MappersTable/mappers.config: keep the store-driven interactive flow (Switch +
  MapperActionsMenu kebab) while adopting g3's testids and direct DTO enum usage;
  drop the now-unused mappersTableTarget style
- update the incoming MappersTable/MapperGroupsTable tests to the store-driven
  props (full store mock, group.mappers-backed rows, Switch checked assertion)
2026-07-01 19:50:27 +05:30
Gaurav Tewari
3c25d88f63 refactor(llm-attribute-mapping): move mapper row edit/delete into a kebab menu
Match the group row pattern: keep the enable/disable toggle inline (primary,
high-frequency) and move the lower-frequency Edit/Delete behind a MapperActionsMenu
kebab, mirroring MapperGroupActionsMenu. Keeps the mapper and group rows visually
consistent and the row compact.
2026-07-01 19:34:13 +05:30
Gaurav Tewari
df22c40df6 refactor(llm-attribute-mapping): align mapper drawer styles with group-drawer conventions
- give SourceAttributeRow its own CSS module (one module per component)
- migrate MapperFormDrawer/SourceAttributeRow raw px + palette colors to
  design tokens (--spacing-*, --font-*, --radius-2, --l3-foreground),
  matching the sibling GroupFormDrawer.module.scss
2026-07-01 19:25:12 +05:30
Gaurav Tewari
2b9b500dc0 chore: update test 2026-07-01 19:24:59 +05:30
Gaurav Tewari
2241e0f98b Merge remote-tracking branch 'origin/feat/llm-attr-mapping-listing-2' into feat/llm-attr-mapping-group-drawer-3
# Conflicts:
#	frontend/src/container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore.ts
#	frontend/src/container/LLMObservability/AttributeMapping/utils.ts
2026-07-01 19:21:46 +05:30
Gaurav Tewari
afd3ddf947 merge: sync feat/llm-attr-mapping-group-drawer-3 into mapper-drawer-4
Adopt group-drawer-3's component restructure (LLMObservability/AttributeMapping
tree with per-component CSS modules + TableConfig) and re-integrate the mapper
create/edit/delete drawer on top of it:
- MappersTable keeps the store-backed interactive flow (hydrate, add/edit/delete,
  MapperFormDrawer) and adopts g3's motion.div expand animation + own CSS module
- mappers.config renders the Target via Typography and keeps action buttons
- useAttributeMappingStore imports fixed for the hooks/ subfolder location
2026-07-01 19:18:09 +05:30
Gaurav Tewari
ed41a1cd8b chore: update mapper 2026-07-01 18:49:47 +05:30
Gaurav Tewari
47072d316a Merge remote-tracking branch 'origin/feat/llm-attr-mapping-listing-2' into feat/llm-attr-mapping-group-drawer-3
# Conflicts:
#	frontend/src/container/LLMObservability/AttributeMapping/AttributeMappingsTab/AttributeMappingsTab.tsx
#	frontend/src/container/LLMObservability/AttributeMapping/AttributeMappingsTab/components/IndexBadge/IndexBadge.tsx
#	frontend/src/container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore.ts
#	frontend/src/container/LLMObservability/AttributeMapping/ConditionKeyList.tsx
#	frontend/src/container/LLMObservability/AttributeMapping/GroupFormDrawer.module.scss
#	frontend/src/container/LLMObservability/AttributeMapping/GroupFormDrawer.tsx
#	frontend/src/container/LLMObservability/AttributeMapping/KeySearchInput.module.scss
#	frontend/src/container/LLMObservability/AttributeMapping/KeySearchInput.tsx
#	frontend/src/container/LLMObservability/AttributeMapping/saveDraft.ts
#	frontend/src/container/LLMObservability/AttributeMapping/useGroupFormDrawer.ts
#	frontend/src/container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping.module.scss
2026-07-01 18:44:01 +05:30
Gaurav Tewari
2ce340f8af chore: add comments & tests 2026-07-01 18:13:01 +05:30
Gaurav Tewari
10ceb1c7fe chore: remove hardcoded value 2026-07-01 16:59:52 +05:30
Gaurav Tewari
1d178c2936 chore: sycn with base 2026-07-01 16:56:29 +05:30
Gaurav Tewari
bc9ba7b039 refactor: basic components 2026-07-01 16:48:53 +05:30
Gaurav Tewari
90307c913e chore: use badge 2026-07-01 16:35:36 +05:30
Gaurav Tewari
14e5ff1be4 chore: typography changes 2026-07-01 15:43:21 +05:30
Gaurav Tewari
902952f249 chore: typography changes 2026-07-01 15:29:06 +05:30
Gaurav Tewari
696f2a3155 refactor: components update 2026-07-01 15:19:40 +05:30
Gaurav Tewari
817ed96d9a chore: update ui and add animation 2026-07-01 14:42:57 +05:30
Gaurav Tewari
e250bd75ac Merge branch 'feat/llm-attr-mapping-foundation-1' into feat/llm-attr-mapping-listing-2 2026-07-01 13:34:06 +05:30
Gaurav Tewari
bc746d48c4 chore: add attribute mapping foundation 2026-07-01 13:24:53 +05:30
Gaurav Tewari
9e0a662de4 Merge branch 'feat/llm-pricing-ui-update-and-tests' into feat/llm-attr-mapping-foundation-1 2026-07-01 13:10:22 +05:30
Gaurav Tewari
9b8834003f Merge remote-tracking branch 'origin' into feat/llm-pricing-ui-update-and-tests 2026-07-01 13:02:43 +05:30
Gaurav Tewari
abc2881b7b chore: revert env.ts 2026-07-01 12:38:36 +05:30
Gaurav Tewari
bd7da902fb chore: remove worktree 2026-07-01 12:37:11 +05:30
Gaurav Tewari
431ef9ae61 Merge remote-tracking branch 'origin' into feat/llm-pricing-ui-update-and-tests 2026-07-01 12:34:40 +05:30
Gaurav Tewari
d2ff4114fd Merge remote-tracking branch 'origin' into feat/llm-pricing-ui-update-and-tests 2026-07-01 12:26:48 +05:30
Gaurav Tewari
48dc9a7f69 Merge branch 'feat/llm-attr-mapping-foundation-1' into feat/llm-pricing-ui-update-and-tests
# Conflicts:
#	frontend/src/AppRoutes/routes.ts
#	frontend/src/container/TopNav/DateTimeSelectionV2/constants.ts
2026-07-01 00:46:19 +05:30
Gaurav Tewari
93682947aa Merge remote-tracking branch 'origin' into feat/llm-attr-mapping-foundation-1 2026-06-30 18:30:38 +05:30
Gaurav Tewari
1fcb9b85b7 chore: self review changes 2026-06-30 17:27:16 +05:30
Gaurav Tewari
72c99b12ed chore: self review refactor 2026-06-30 17:23:13 +05:30
Gaurav Tewari
2cd3ba3582 chore: self review changes 2026-06-30 17:17:30 +05:30
Gaurav Tewari
d15c4fc15a chore: update text 2026-06-30 15:29:30 +05:30
Gaurav Tewari
8e5cfcf197 feat: update test cases 2026-06-30 14:29:26 +05:30
Gaurav Tewari
3bf84f22fc feat: move ui to easily accessable tabs 2026-06-30 13:45:47 +05:30
Gaurav Tewari
f851b12c48 Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-29 23:33:03 +05:30
Gaurav Tewari
43f599b2a2 Merge remote-tracking branch 'origin' into feat/llm-pricing-drawer-3 2026-06-29 23:08:50 +05:30
Gaurav Tewari
43ea643f17 Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-29 21:30:38 +05:30
Gaurav Tewari
bec8b61c53 chore: remove scss file 2026-06-29 21:27:28 +05:30
Gaurav Tewari
4690018428 refactor: use delete confirm dialog 2026-06-29 21:26:55 +05:30
Gaurav Tewari
62decea54c Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-29 20:41:25 +05:30
Gaurav Tewari
878daa931b Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-29 20:39:16 +05:30
Gaurav Tewari
e3f9332890 chore: remove divider 2026-06-29 20:38:23 +05:30
Gaurav Tewari
66a49b84a7 chore: add enable check 2026-06-29 19:48:31 +05:30
Gaurav Tewari
5dd71bbc2f Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-29 19:13:38 +05:30
Gaurav Tewari
05d73ff074 chore: revert env changes 2026-06-29 19:10:37 +05:30
Gaurav Tewari
82d18654fa chore: remove extra comment 2026-06-29 19:08:07 +05:30
Gaurav Tewari
63dde6b4c7 feat: add quick action menu for delete and edit 2026-06-29 18:56:23 +05:30
Gaurav Tewari
31ffcd43ab chore: update edit and delete options 2026-06-29 18:09:23 +05:30
Gaurav Tewari
d8cc5ac727 Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-29 15:32:46 +05:30
Gaurav Tewari
a81a46bc4d Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-29 15:30:33 +05:30
Gaurav Tewari
5244ea1060 Merge remote-tracking branch 'origin' into feat/llm-pricing-listing-2 2026-06-29 15:28:35 +05:30
Gaurav Tewari
1d6137100f Merge branch 'feat/llm-pricing-listing-2', remote-tracking branch 'origin' into feat/llm-pricing-drawer-3 2026-06-29 15:26:18 +05:30
Gaurav Tewari
ce725f655d fix: update missing styles 2026-06-29 15:21:13 +05:30
Gaurav Tewari
c49bf131e9 Merge remote-tracking branch 'origin' into feat/llm-attr-mapping-foundation-1 2026-06-29 14:07:14 +05:30
Gaurav Tewari
bcb019048d Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-29 12:12:19 +05:30
Gaurav Tewari
fa6d2df271 Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-29 12:03:05 +05:30
Gaurav Tewari
2394c4789e Merge remote-tracking branch 'origin' into feat/llm-pricing-listing-2 2026-06-29 11:59:49 +05:30
Gaurav Tewari
9643d16c7c refactor: side nav changes 2026-06-26 02:33:19 +05:30
Gaurav Tewari
b1a582bd83 refactor: pricing feilds 2026-06-26 02:31:24 +05:30
Gaurav Tewari
ef8711bd51 refactor: remove extra classes 2026-06-26 02:21:04 +05:30
Gaurav Tewari
af41ac54b7 Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-26 02:02:38 +05:30
Gaurav Tewari
ef537e9841 fix: llm pricing listing 2026-06-26 02:01:17 +05:30
Gaurav Tewari
a3957949c0 fix: minior issues 2026-06-26 01:58:26 +05:30
Gaurav Tewari
b59e13a50d Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-26 01:41:58 +05:30
Gaurav Tewari
acd3aaaccd chore: use typograpgy test in table config 2026-06-26 01:41:00 +05:30
Gaurav Tewari
ed89f8ba96 Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-26 01:29:40 +05:30
Gaurav Tewari
eec621e6c6 chore: remove extra comment 2026-06-26 01:26:13 +05:30
Gaurav Tewari
0325b76c21 chore: sync table 2026-06-26 01:22:50 +05:30
Gaurav Tewari
7e246e5c0a refactor: use signoz button and minor css update 2026-06-26 01:07:07 +05:30
Gaurav Tewari
697dbc87bb refactor: css variables 2026-06-26 00:48:14 +05:30
Gaurav Tewari
b3db9fe7ee Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-26 00:16:13 +05:30
Gaurav Tewari
0fe112225e fix: update title 2026-06-25 23:55:35 +05:30
Gaurav Tewari
a562e126ff feat: add delete confirm modal 2026-06-25 23:47:14 +05:30
Gaurav Tewari
e3f47fbdab chore: add a tooltip on hover 2026-06-25 22:59:21 +05:30
Gaurav Tewari
646fd45ea4 refactor: styling and components 2026-06-25 22:44:30 +05:30
Gaurav Tewari
8babcb1496 refactor: styling and components 2026-06-25 22:19:27 +05:30
Gaurav Tewari
ce436ebf47 refactor: more changes 2026-06-25 20:09:45 +05:30
Gaurav Tewari
0daba7643c 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
2026-06-25 16:14:27 +05:30
Gaurav Tewari
a9e953f33f merge: sync feat/llm-pricing-listing-2 into feat/llm-pricing-drawer-3
Brings in the listing-2 refactors (layout-shift fix, fixed-height table,
Typography migration, and the ModelPricing component/folder breakdown) and
reconciles them with the drawer feature:
- constants: keep SKELETON_ROW_COUNT = PAGE_SIZE alongside drawer constants
- page module: trimmed to page-only styles (cell/table styles now live in the
  split modules)
- ModelCostTabPanel: listing's renamed/Typography structure + drawer's add
  button, permission gating, and drawer wiring; .filtersBar moved into the tab
  panel module
2026-06-25 15:53:09 +05:30
Gaurav Tewari
b727eed230 refactor: typograhy 2026-06-25 15:40:06 +05:30
Gaurav Tewari
769343b8af refactor: more changes 2026-06-25 15:31:00 +05:30
Gaurav Tewari
fc94bca621 refactor: typography component 2026-06-25 15:03:11 +05:30
Gaurav Tewari
f512127eee refactor: styles 2026-06-25 14:43:35 +05:30
Gaurav Tewari
d600fb9d66 fix: layout shift 2026-06-25 12:25:00 +05:30
Gaurav Tewari
7a4947d250 chore: sync with base 2026-06-24 21:30:18 +05:30
Gaurav Tewari
8586a4833e chore: sync with base 2026-06-24 21:18:38 +05:30
Gaurav Tewari
3a65de230b chore: remove usd selector for now 2026-06-24 21:11:06 +05:30
Gaurav Tewari
c18b624205 Merge branch 'feat/llm-pricing-foundation' into feat/llm-pricing-listing-2 2026-06-24 21:06:07 +05:30
Gaurav Tewari
76fad144e2 refactor: update routes 2026-06-24 20:35:22 +05:30
Gaurav Tewari
68e2e15caf chore: remove demo side nav 2026-06-24 20:16:32 +05:30
Gaurav Tewari
95e31e4624 fix: add demo side nav on sidenav 2026-06-24 18:57:04 +05:30
Gaurav Tewari
31443c25b1 chore: empty commit 2026-06-24 18:37:50 +05:30
Gaurav Tewari
471ac4979c empty commit 2026-06-24 15:12:26 +05:30
Gaurav Tewari
078446aaef Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-24 14:27:51 +05:30
Gaurav Tewari
6092a8e1de Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-24 14:26:02 +05:30
Gaurav Tewari
bc60bda32e Merge branch 'feat/llm-pricing-foundation' into feat/llm-pricing-listing-2 2026-06-24 14:24:51 +05:30
Gaurav Tewari
dbc69ecef5 Merge remote-tracking branch 'origin' into feat/llm-pricing-foundation 2026-06-24 14:23:23 +05:30
Gaurav Tewari
b4c5fe387b Merge branch 'feat/llm-pricing-drawer-3' into feat/llm-pricing-search-and-dropdown 2026-06-24 12:13:20 +05:30
Gaurav Tewari
e2451576c5 Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-24 12:12:06 +05:30
Gaurav Tewari
96379b833c Merge remote-tracking branch 'origin' into feat/llm-pricing-listing-2 2026-06-24 12:09:24 +05:30
Gaurav Tewari
1615204566 Merge remote-tracking branch 'origin/main' into feat/llm-pricing-foundation 2026-06-24 12:07:21 +05:30
Gaurav Tewari
f6f4285711 chore: add disable on source id 2026-06-24 09:38:18 +05:30
Gaurav Tewari
b2e0ade9e6 chore: sync with master 2026-06-23 21:32:27 +05:30
Gaurav Tewari
62f828fae3 chore: sync with main 2026-06-23 21:11:02 +05:30
Gaurav Tewari
8d5ad341ea refactor: types and other things 2026-06-23 20:50:53 +05:30
Gaurav Tewari
3fe8d760fb refactor: types and other things 2026-06-23 20:50:39 +05:30
Gaurav Tewari
6da8d54b40 chore: self review changes 2026-06-23 20:28:36 +05:30
Gaurav Tewari
e3edac6292 Merge remote-tracking branch 'origin/feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3
# Conflicts:
#	frontend/src/container/LLMObservability/Settings/ModelPricing/ExtraPricingBuckets.tsx
#	frontend/src/container/LLMObservability/Settings/ModelPricing/ModelCostDrawer.module.scss
#	frontend/src/container/LLMObservability/Settings/ModelPricing/ModelCostDrawer.tsx
#	frontend/src/container/LLMObservability/Settings/ModelPricing/PatternEditor.tsx
#	frontend/src/container/LLMObservability/Settings/ModelPricing/PricingFields.tsx
#	frontend/src/container/LLMObservability/Settings/ModelPricing/SourceSelector.tsx
#	frontend/src/container/LLMObservability/Settings/ModelPricing/useModelCostDrawer.ts
#	frontend/src/container/LLMObservabilityModelPricing/constants.ts
2026-06-23 18:22:17 +05:30
Gaurav Tewari
ef90f0a277 chore: add commet in utis 2026-06-23 18:13:43 +05:30
Gaurav Tewari
2739c69333 chore: additional refactor 2026-06-23 18:13:00 +05:30
Gaurav Tewari
d834f26459 Merge remote-tracking branch 'origin/feat/llm-pricing-foundation' into feat/llm-pricing-listing-2
# Conflicts:
#	frontend/src/container/LLMObservability/Settings/ModelPricing/ModelCostsTab.tsx
#	frontend/src/container/LLMObservability/Settings/ModelPricing/ModelCostsTable.tsx
#	frontend/src/container/LLMObservability/Settings/ModelPricing/constants.ts
#	frontend/src/container/LLMObservability/Settings/ModelPricing/table.config.tsx
#	frontend/src/container/LLMObservability/Settings/ModelPricing/utils.ts
#	frontend/src/container/LLMObservabilityModelPricing/LLMObservabilityModelPricing.module.scss
#	frontend/src/container/LLMObservabilityModelPricing/types.ts
2026-06-23 16:49:49 +05:30
Gaurav Tewari
38345d960a feat: add flags 2026-06-23 16:40:44 +05:30
Gaurav Tewari
d93a78edea Merge remote-tracking branch 'origin/feat/llm-pricing-foundation' into feat/llm-pricing-foundation
# Conflicts:
#	frontend/src/constants/features.ts
2026-06-23 15:58:15 +05:30
Gaurav Tewari
4d397e20de fix: add key to route 2026-06-23 15:28:41 +05:30
Gaurav Tewari
c6ff80a285 Merge branch 'main' into feat/llm-pricing-foundation 2026-06-23 13:14:38 +05:30
Gaurav Tewari
659ae5f1ab refactor: shell 2026-06-23 13:12:09 +05:30
Gaurav Tewari
c967c72c38 chore: update flag 2026-06-23 11:59:27 +05:30
Gaurav Tewari
9c1e01d3e6 feat(llm-attribute-mapping): add Test tab with sample-span runner
Add the Test tab to the Attribute Mapping page: paste a sample span,
run it through the working draft's mappers, and see which target
attributes get populated.

- Wire useTestSpanMappers (POST /api/v1/span_mapper_groups/test) generated
  client from #11795 into a TestTab + useTestSpanMapper hook + TestResult.
- Build the request from the staged draft, sending a group's mappers only
  when it is new or its mapper set changed vs the saved snapshot; otherwise
  omit them so the backend backfills saved mappers by group name.
- Expose the saved snapshot from useAttributeMappingStore for the diff.
2026-06-23 10:49:10 +05:30
Gaurav Tewari
f2f992d4e3 fix: add isFetchingFeatureFlags 2026-06-22 23:07:26 +05:30
Gaurav Tewari
9b53687b38 feat: feature flag on entire route and add mode costs tabs 2026-06-22 23:00:46 +05:30
Gaurav Tewari
d9d92c5ae9 feat: add search , dropdown and flag 2026-06-22 18:21:58 +05:30
Gaurav Tewari
1e982261a7 Merge remote-tracking branch 'origin/feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3 2026-06-22 15:19:33 +05:30
Gaurav Tewari
d2e89789fa Merge remote-tracking branch 'origin/feat/llm-pricing-foundation' into feat/llm-pricing-listing-2 2026-06-22 15:11:06 +05:30
Gaurav Tewari
2d1d8b2df7 Merge remote-tracking branch 'origin' into feat/llm-pricing-listing-2 2026-06-22 15:10:53 +05:30
Gaurav Tewari
2d3359c97e Merge branch 'main' into feat/llm-pricing-foundation 2026-06-22 15:06:44 +05:30
Gaurav Tewari
f10cd84147 refactor: number 2026-06-22 14:55:20 +05:30
Gaurav Tewari
6e25f09677 fix: disable isDirty in case of llm pricing 2026-06-22 14:30:29 +05:30
Gaurav Tewari
bcc1ff2444 Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3
# Conflicts:
#	frontend/src/container/LLMObservabilityModelPricing/ModelCostsTab.tsx
2026-06-22 13:51:55 +05:30
Gaurav Tewari
3babbae36d chore: remove comment 2026-06-22 13:47:29 +05:30
Gaurav Tewari
a358fc717b fix(llm-attribute-mapping): de-duplicate mapper sources by context+key
A key read from a span attribute vs the resource is a distinct source, so
both should be allowed to coexist as separate priorities. getCleanSources
de-duped by key alone, silently dropping the second context. Dedupe by the
(context, key) pair instead, keeping the higher-priority row.
2026-06-22 10:57:27 +05:30
Gaurav Tewari
feb6edc057 fix(llm-attribute-mapping): preserve unsaved mappers across lazy hydration
hydrateGroupMappers replaced a group's mappers with the fetched server list.
The Add-mapping drawer renders while the lazy fetch is in flight, so a mapper
staged before the response arrived (serverId === null) was clobbered on
resolve. Keep those local rows and fold the server mappers in ahead of them.
2026-06-22 10:52:51 +05:30
Gaurav Tewari
c9afc7a375 feat(llm-attribute-mapping): mapper create/edit/delete via drawer
Port the mapper-drawer feature onto the rewritten group-drawer (PR 3) base:
TanStack tables + CSS modules + config-based columns. Mapper rows are lazily
fetched on group expand, hydrated into the editable draft store, and managed
(create/edit/delete/toggle/reorder) through the drawer.
2026-06-22 00:06:43 +05:30
Gaurav Tewari
074915e22b 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.
2026-06-21 00:06:15 +05:30
Gaurav Tewari
eaef369eb1 Merge branch 'feat/llm-attr-mapping-listing-2' into feat/llm-attr-mapping-group-drawer-3
# Conflicts:
#	frontend/src/container/LLMObservabilityAttributeMapping/AttributeMappingsTab.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping.module.scss
#	frontend/src/container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/MapperGroupsTable.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/MappersTable.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/useAttributeMappingStore.ts
#	frontend/src/container/LLMObservabilityAttributeMapping/utils.ts
2026-06-21 00:03:07 +05:30
Gaurav Tewari
a13969c8bc chore: migrate tanstack table 2026-06-20 23:45:23 +05:30
Gaurav Tewari
3f0d07c28c 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.
2026-06-20 23:08:37 +05:30
Gaurav Tewari
f2b3c4125c refactor: css module 2026-06-20 23:08:24 +05:30
Gaurav Tewari
96cc6ec588 fix: css styling 2026-06-20 23:08:24 +05:30
Gaurav Tewari
c6705a72a7 feat(llm-attribute-mapping): add attribute mapping foundation (route, permission, page shell) 2026-06-20 23:08:24 +05:30
Gaurav Tewari
7a93fe8192 chore: remove comment 2026-06-20 22:40:12 +05:30
Gaurav Tewari
da82731195 Merge branch 'feat/llm-pricing-listing-2' into feat/llm-pricing-drawer-3
# Conflicts:
#	frontend/src/container/LLMObservabilityModelPricing/ModelCostsTab.tsx
#	frontend/src/container/LLMObservabilityModelPricing/constants.ts
2026-06-20 22:08:59 +05:30
Gaurav Tewari
63e46ad947 docs: clarify price precision comment 2026-06-20 21:58:42 +05:30
Gaurav Tewari
39f8419a9e refactor: migrate to tanstack table 2026-06-20 18:19:47 +05:30
Gaurav Tewari
64a9725e12 Merge branch 'feat/llm-pricing-foundation' into feat/llm-pricing-listing-2
# Conflicts:
#	frontend/src/container/LLMObservabilityModelPricing/LLMObservabilityModelPricing.module.scss
#	frontend/src/container/LLMObservabilityModelPricing/LLMObservabilityModelPricing.tsx
2026-06-20 16:16:13 +05:30
Gaurav Tewari
44d76c1306 Merge branch 'feat/llm-attr-mapping-listing-2' into feat/llm-attr-mapping-group-drawer-3
# Conflicts:
#	frontend/src/container/LLMObservabilityAttributeMapping/AttributeMappingsTab.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping.module.scss
#	frontend/src/container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/MapperGroupsTable.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/MappersTable.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/useAttributeMappingStore.ts
#	frontend/src/container/LLMObservabilityAttributeMapping/utils.ts
2026-06-19 17:33:54 +05:30
Gaurav Tewari
b9ac79898e refactor(llm-attribute-mapping): migrate styles to CSS modules 2026-06-19 17:16:00 +05:30
Gaurav Tewari
c136834ab5 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.
2026-06-19 16:59:32 +05:30
Gaurav Tewari
dd0155065f refactor: css module 2026-06-19 16:43:00 +05:30
Gaurav Tewari
08bc6ec75b refactor: migrate to css module 2026-06-19 16:07:57 +05:30
Gaurav Tewari
e47a0cd954 merge: bring css-module migration from listing-2 into drawer-3
Resolve conflicts: keep the drawer/permission additions from drawer-3 and
the CSS-module conversion from listing-2. Drop the dead filters-bar__add
class (no rule) and fold filters-bar's justify-content into the module.
2026-06-19 15:33:00 +05:30
Gaurav Tewari
2026349f6d refactor: migrate to css module 2026-06-19 14:24:56 +05:30
Gaurav Tewari
836a00cdfa refactor: migrate to css moduel 2026-06-19 13:55:54 +05:30
Gaurav Tewari
38f1506472 feat: update draft and mapper 2026-06-19 01:23:44 +05:30
Gaurav Tewari
7692f99f30 fix: update drawer 2026-06-19 01:05:35 +05:30
Gaurav Tewari
c3ba316ccf fix: update logic 2026-06-19 00:43:36 +05:30
Gaurav Tewari
eeb8353a17 fix: input 2026-06-19 00:28:16 +05:30
Gaurav Tewari
47b5fff3f6 Merge branch 'feat/llm-attr-mapping-listing-2' into feat/llm-attr-mapping-group-drawer-3
# Conflicts:
#	frontend/src/container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/MapperGroupsTable.tsx
#	frontend/src/container/LLMObservabilityAttributeMapping/useAttributeMappingStore.ts
2026-06-19 00:09:48 +05:30
Gaurav Tewari
a0940af68f chore: review comments 2026-06-18 23:56:37 +05:30
Gaurav Tewari
72713d996d fix: update col count 2026-06-18 23:25:11 +05:30
Gaurav Tewari
93a1caa9cc feat: add attribute mapping tab 2026-06-18 23:13:33 +05:30
Gaurav Tewari
bb8900113a fix: fetch data only when tab is open 2026-06-18 22:50:58 +05:30
Gaurav Tewari
1fb195b383 fix: text overflow in attribute mapping 2026-06-18 22:24:51 +05:30
Gaurav Tewari
c2761c24df fix(llm-attribute-mapping): unlock filters column shrink so long keys wrap within the cell 2026-06-18 22:11:54 +05:30
Gaurav Tewari
a6b77993c6 fix(llm-attribute-mapping): wrap long condition keys so the context badge isn't clipped 2026-06-18 22:04:24 +05:30
Gaurav Tewari
59d2468fb1 style(llm-attribute-mapping): use design-token css variables for spacing and typography 2026-06-18 21:54:27 +05:30
Gaurav Tewari
44d8d55b2f Merge branch 'feat/llm-attr-mapping-foundation-1' into feat/llm-attr-mapping-listing-2 2026-06-18 21:35:09 +05:30
Gaurav Tewari
8fdce2b9ad fix: css styling 2026-06-18 21:20:45 +05:30
Gaurav Tewari
1c06a57361 feat(llm-attribute-mapping): group create/edit/delete via drawer with diff-save 2026-06-18 20:13:40 +05:30
Gaurav Tewari
dfd3bce670 feat(llm-attribute-mapping): read-only groups and mappers listing 2026-06-18 19:39:57 +05:30
Gaurav Tewari
9725a268cc feat(llm-attribute-mapping): add attribute mapping foundation (route, permission, page shell) 2026-06-18 19:33:02 +05:30
Gaurav Tewari
ad73f98d0f fix: route thing 2026-06-18 17:03:19 +05:30
Gaurav Tewari
0b9a0c4ac8 fix: minor grammer thing 2026-06-18 16:52:25 +05:30
Gaurav Tewari
2323495c4a chore: self review changes 2026-06-18 16:45:34 +05:30
Gaurav Tewari
e1a18d36fe chore: self review changes 2026-06-18 16:29:19 +05:30
Gaurav Tewari
1b16aea459 chore: update more self review changes 2026-06-18 15:15:33 +05:30
Gaurav Tewari
9bd5d233ba fix: add error handling 2026-06-18 15:15:33 +05:30
Gaurav Tewari
b992f38bca chore: update color tokens 2026-06-18 15:15:33 +05:30
Gaurav Tewari
5d89b1a348 refactor: form in edit / add modal 2026-06-18 15:15:33 +05:30
Gaurav Tewari
e6daf07a06 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.
2026-06-18 15:15:33 +05:30
Gaurav Tewari
fda1bf17dc 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).
2026-06-18 15:15:33 +05:30
Gaurav Tewari
5c062be23a feat(llm-pricing): add model cost drawer and wire into listing page 2026-06-18 15:15:33 +05:30
Gaurav Tewari
585894b382 fix: add comments in utils 2026-06-18 15:11:26 +05:30
Gaurav Tewari
74e560409c fix: update styling 2026-06-18 11:02:52 +05:30
Gaurav Tewari
79a7efe8e2 refactor: initial prop 2026-06-17 22:29:53 +05:30
Gaurav Tewari
75a2208e9f refactor: self review changes 2026-06-17 22:27:33 +05:30
Gaurav Tewari
86db724069 fix: add skeleton loading 2026-06-17 22:06:59 +05:30
Gaurav Tewari
22a8f295f0 chore: self review changes 2026-06-17 21:50:27 +05:30
Gaurav Tewari
34e712d212 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.
2026-06-17 20:45:44 +05:30
Gaurav Tewari
a17cfcc131 style(llm-pricing): target @signozhq table slots, drop dead antd/leftover rules
The component uses @signozhq/ui Table/Tabs (Radix-based), not antd, so the
.ant-table-* and .ant-tabs-nav selectors never matched — the intended
uppercase/muted header styling wasn't applied. Retarget header/cell rules to
[data-slot='table-head'|'table-cell'] (no !important needed). Also remove dead
rules left over from the removed search/source/add UI (.filters-bar__search,
__source, __add, .page-header__actions) and the unused .source-badge--auto/
--override modifiers.
2026-06-17 20:39:56 +05:30
Gaurav Tewari
8697461476 refactor(llm-pricing): render model costs inside its tab + tab URL param
The listing was rendered outside the Tabs, so the tab was decorative.
Move all model-cost content (currency control, list query, table,
pagination, footer) into a ModelCostsTab component rendered as the
'Model costs' tab's children, and drive the active tab from a 'tab' URL
query param (nuqs). The container is now just the page shell. Unpriced
models stays a disabled placeholder for a later PR.
2026-06-17 20:32:08 +05:30
Gaurav Tewari
47e33e0610 feat(llm-pricing): disable currency selector (USD-only for now)
Only USD is priced today, so render the currency SelectSimple in a
disabled state pinned to USD. A disabled select can't fire onChange, so
the currency useState is dead — drop it (and the now-unused useState
import).
2026-06-17 20:24:08 +05:30
Gaurav Tewari
0829155247 refactor(llm-pricing): inline pagination, drop useModelPricingFilters
The hook had shrunk to a one-line nuqs wrapper after search/source were
removed, so inline the useQueryState call into the container and remove
the hook file plus the now-unused ModelPricingFilters type. When the
filters return (once the API honours them) they can move back into a
dedicated hook.
2026-06-17 20:19:34 +05:30
Gaurav Tewari
92b8fa86e7 refactor(llm-pricing): use nuqs for list pagination URL state
Replace the hand-rolled useHistory + URLSearchParams plumbing in
useModelPricingFilters with nuqs useQueryState, matching the convention
used by the dashboards, alerts and k8s list pages. Behaviour is
unchanged: parseAsInteger.withDefault(1) keeps ?page=1 out of the URL
and history:'replace' avoids polluting the back-stack.
2026-06-17 20:15:13 +05:30
Gaurav Tewari
31e1b21740 refactor(llm-pricing): centralize constants and shared types
Extract PAGE_SIZE, PAGE_KEY, COLUMN_COUNT and CURRENCY_OPTIONS into a
new constants.ts, and move the ModelPricingFilters contract into
types.ts. Component prop interfaces stay colocated with their
components, matching the convention in the drawer PR.
2026-06-17 20:06:12 +05:30
Gaurav Tewari
dcad825615 refactor(llm-pricing): drop dead NaN guard in formatPricePerMillion
Pricing fields are typed as required numbers and JSON can't carry NaN,
so Number.isNaN was unreachable. Keep the null/undefined guard as API
defensiveness (toFixed on a missing value would crash the row). Also
trims the now-redundant dayjs.extend comment.
2026-06-17 19:59:04 +05:30
Gaurav Tewari
6b508655bd refactor(llm-pricing): extract getRelativeTime helper in utils
Pull the relative-time formatting out of getRelativeLastSeen into a
small local getRelativeTime helper. Kept feature-local (not in the
shared utils/timeUtils) so the LLM pricing module owns its own dayjs
config; the local relativeTime extend stays for test self-sufficiency.
2026-06-17 19:46:00 +05:30
Gaurav Tewari
c3f1df3ed0 chore(llm-pricing): drop search + source filters from list request
The list API does not honour the q (search) and source params yet, so
the controls did nothing. Remove the search input and source dropdown
along with the params we sent, and trim useModelPricingFilters to the
URL-backed page state that pagination still needs. Currency dropdown,
tabs, table and pagination are unchanged. Filters will return once the
backend supports them.
2026-06-17 19:29:14 +05:30
Gaurav Tewari
a19c8f3cc6 feat(llm-pricing): add listing page and table 2026-06-17 18:38:44 +05:30
Gaurav Tewari
b1aaf3ec9a feat(llm-pricing): add model pricing foundation (route, permission, page shell) 2026-06-17 18:38:01 +05:30
195 changed files with 7074 additions and 5225 deletions

View File

@@ -155,8 +155,8 @@ querier:
cache_ttl: 168h
# The interval for recent data that should not be cached.
flux_interval: 5m
# The maximum number of queries a single query range request runs at once.
max_concurrent_queries: 8
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.

View File

@@ -4261,8 +4261,6 @@ components:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- clusterName
- clusterCPU
@@ -4271,7 +4269,6 @@ components:
- clusterMemoryAllocatable
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesClusters:
@@ -4327,8 +4324,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- daemonSetName
- daemonSetCPU
@@ -4340,7 +4335,6 @@ components:
- desiredNodes
- currentNodes
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDaemonSets:
@@ -4396,8 +4390,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- deploymentName
- deploymentCPU
@@ -4409,7 +4401,6 @@ components:
- desiredPods
- availablePods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDeployments:
@@ -4542,8 +4533,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
successfulPods:
type: integer
required:
@@ -4559,7 +4548,6 @@ components:
- failedPods
- successfulPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesJobs:
@@ -4650,14 +4638,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- namespaceName
- namespaceCPU
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesNamespaces:
@@ -4723,14 +4708,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
@@ -4776,64 +4758,6 @@ components:
- failed
- unknown
type: object
InframonitoringtypesPodCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
evicted:
type: integer
failed:
type: integer
imagePullBackOff:
type: integer
nodeAffinity:
type: integer
nodeLost:
type: integer
oomKilled:
type: integer
pending:
type: integer
running:
type: integer
shutdown:
type: integer
unexpectedAdmissionError:
type: integer
unknown:
type: integer
required:
- pending
- running
- failed
- unknown
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- evicted
- nodeAffinity
- nodeLost
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -4864,8 +4788,6 @@ components:
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
podMemory:
format: double
type: number
@@ -4877,11 +4799,6 @@ components:
type: number
podPhase:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podRestarts:
format: int64
type: integer
podStatus:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
podUID:
type: string
required:
@@ -4894,34 +4811,9 @@ components:
- podMemoryLimit
- podPhase
- podCountsByPhase
- podStatus
- podCountsByStatus
- podRestarts
- podAge
- meta
type: object
InframonitoringtypesPodStatus:
enum:
- pending
- running
- failed
- unknown
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- evicted
- nodeaffinity
- nodelost
- shutdown
- unexpectedadmissionerror
- no_data
type: string
InframonitoringtypesPods:
properties:
endTimeBeforeRetention:
@@ -5220,8 +5112,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
statefulSetCPU:
format: double
type: number
@@ -5253,7 +5143,6 @@ components:
- desiredPods
- currentPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesStatefulSets:

View File

@@ -1,149 +0,0 @@
# Authz
SigNoz uses [OpenFGA](https://openfga.dev/), a relationship-based access control (ReBAC) system, to authorize every request. Transactions are never attached to users directly — they are attached to **roles** (as relationship tuples in OpenFGA), and users or service accounts (principals) are made **assignees** of those roles. The central interface is `AuthZ` in [pkg/authz/authz.go](/pkg/authz/authz.go), backed by an embedded OpenFGA server in [pkg/authz/openfgaserver](/pkg/authz/openfgaserver/server.go).
As a feature author, you will rarely touch OpenFGA directly. You interact with two layers:
1. **The registries** in [pkg/types/coretypes](/pkg/types/coretypes) — where every resource, verb, and managed-role permission is declared in code.
2. **The route wiring** in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver) — where each route declares what resource it touches and which verb it needs, and the middleware turns that into a check.
## What are the building blocks?
### Type
A `Type` is the coarse FGA type of a resource. The set is finite and defined in [pkg/types/coretypes/registry_type.go](/pkg/types/coretypes/registry_type.go): `user`, `serviceaccount`, `anonymous`, `role`, `organization`, `metaresource`, and `telemetryresource`. Each type carries a selector regex (what a valid ID looks like) and the verbs allowed on it.
Almost every feature resource is a `metaresource` (dashboards, rules, pipelines, ...) or a `telemetryresource` (logs, traces, metrics). You should almost never need a new type.
### Verb
A `Verb` is the action being authorized. All verbs are defined in [pkg/types/coretypes/registry_verb.go](/pkg/types/coretypes/registry_verb.go): `create`, `read`, `update`, `delete`, `list`, `assignee`, `attach`, and `detach`.
- `assignee` is special: it is the membership relation between a subject and a role ("user X is an assignee of role Y"), not an action a route checks for.
- `attach`/`detach` authorize linking two resources together (e.g. assigning a role to a service account).
### Kind
A `Kind` is the fine-grained name of your resource — `dashboard`, `rule`, `quick-filter`. Kinds are registered in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go). A kind rides on top of an existing type, so adding one does **not** require any OpenFGA schema change.
### Resource
A `Resource` ties a type and a kind together and knows how to render itself as an FGA object string. The interface lives in [pkg/types/coretypes/resource.go](/pkg/types/coretypes/resource.go):
```go
type Resource interface {
Type() Type
Kind() Kind
Prefix(orgId valuer.UUID) string // metaresource:organization/<orgID>/dashboard
Object(orgId valuer.UUID, selector string) string
Scope(verb Verb) string // dashboard:read
AllowedVerbs() []Verb
}
```
All resources are registered in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) using constructors like `NewResourceMetaResource(KindDashboard)` or `NewResourceTelemetryResource(KindLogs)`.
### Selector
A `Selector` identifies *which* instance(s) of a resource a check is about — a UUID, a role name, or the wildcard `*`. A `SelectorFunc` maps the extracted resource ID to selectors at request time. Two prebuilt ones cover most routes ([pkg/types/coretypes/selector.go](/pkg/types/coretypes/selector.go)):
- `WildcardSelector` — the check is against all instances of the resource (`create`, `list`).
- `IDSelector` — the check is against the specific instance *or* the wildcard (`read`, `update`, `delete` of one object). A subject authorized on `*` is authorized on every instance.
When the ID in the request is not what FGA needs (e.g. routes receive a role UUID but FGA objects use role names), write a custom `SelectorFunc` — see `roleSelector` in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
### Roles, transactions, and tuples
SigNoz ships four managed roles, declared in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go): `signoz-admin`, `signoz-editor`, `signoz-viewer`, and `signoz-anonymous`. Their permissions are declared in code as `Transaction`s (a verb on an object) in `ManagedRoleToTransactions` — this map is the single source of truth for what each managed role can do.
At organization bootstrap, `CreateManagedRoles` and `CreateManagedUserRoleTransactions` (see [pkg/authz/authz.go](/pkg/authz/authz.go)) persist the role rows and write one OpenFGA tuple per transaction, linking `role:organization/<orgID>/role/<name>#assignee` to each permitted object. Users and service accounts are then granted roles via `Grant`/`Revoke`, which writes `assignee` tuples. Custom roles (enterprise) are managed through the roles API in [pkg/authz/signozauthzapi/handler.go](/pkg/authz/signozauthzapi/handler.go).
### Schema
The OpenFGA authorization model is a hand-written DSL, embedded at build time: [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) for community and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) for enterprise. The community model only supports role assignment; the enterprise model defines per-verb relations on every type, enabling genuine per-resource checks. This split is why `CheckWithTupleCreation` behaves differently per edition (see [How does a check work at runtime?](#how-does-a-check-work-at-runtime)). You only touch these files when introducing a brand-new **type** — never for a new kind.
## How do I add authz to my feature?
### 1. Register the kind
Add your kind in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go) and append it to `Kinds`:
```go
KindThing = MustNewKind("thing")
```
### 2. Register the resource
Add the resource in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) and append it to `Resources`:
```go
ResourceMetaResourceThing = NewResourceMetaResource(KindThing)
```
Pass an explicit verb list to `NewResourceMetaResource` only if your resource supports fewer verbs than the type default.
### 3. Grant permissions to managed roles
Decide what each managed role can do with your resource and add the transactions in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go):
```go
// thing — editors manage, viewers read
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindThing}, WildCardSelectorString)},
```
The convention so far: admin gets everything, editor gets CRUD on day-to-day observability resources, viewer gets `read`/`list`, anonymous gets nothing (except public dashboards).
### 4. Wire the route
Register the route in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver), wrapping your handler with `CheckResources` and declaring what the route touches via a `ResourceDef` ([pkg/http/handler/resourcedef.go](/pkg/http/handler/resourcedef.go)). A complete example from [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go):
```go
router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccount",
// ...
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost)
```
The pieces:
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
### 5. Backfill existing organizations (only if needed)
Managed-role tuples are written from the registry at organization creation, so **new resources need no migration for new organizations**. If existing organizations must get the new permissions, add a migration in [pkg/sqlmigration](/pkg/sqlmigration) that inserts the tuples — see [083_add_role_crud_tuples.go](/pkg/sqlmigration/083_add_role_crud_tuples.go) for the pattern.
## How does a check work at runtime?
1. The resource middleware ([pkg/http/middleware/resource.go](/pkg/http/middleware/resource.go)) runs on every request. It reads the matched handler's `ResourceDef`s, extracts the resource IDs from path/body, and stores the resolved resources in the request context.
2. `CheckResources` reads them back, runs each `SelectorFunc`, and calls `AuthZ.CheckWithTupleCreation(ctx, claims, orgID, relation, resource, selectors, roleSelectors)`.
3. What happens next depends on the edition:
- **Community** ([pkg/authz/openfgaserver/server.go](/pkg/authz/openfgaserver/server.go)) ignores the resource and selectors and only checks whether the subject is an `assignee` of one of the allowed roles — a plain role gate.
- **Enterprise** ([ee/authz/openfgaserver/server.go](/ee/authz/openfgaserver/server.go)) builds tuples via `authtypes.NewTuples` — subject `user:organization/<orgID>/user/<userID>`, relation `create`, object `serviceaccount:organization/<orgID>/serviceaccount/*` — and batch-checks them against OpenFGA: genuine per-resource authorization, including custom roles.
Because both paths go through the same middleware and the same `ResourceDef` declarations, a route wired once works correctly in both editions.
## What should I remember?
- Declare authz in the registries ([pkg/types/coretypes](/pkg/types/coretypes)), not in migrations — tuples for new organizations are derived from code at bootstrap.
- A new kind never needs an OpenFGA schema change; only a new type does, and then **both** [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) must be updated together.
- Prefer `CheckResources` + `ResourceDef` over the coarse `ViewAccess`/`EditAccess`/`AdminAccess` gates for new routes.
- Use `WildcardSelector` for `create`/`list`, `IDSelector` for instance operations, and a custom `SelectorFunc` when the request ID is not the FGA selector.
- Linking routes check **both** sides: peers via `AttachDetachSiblingResourceDef` (same verb on both), parent-child via a `BasicResourceDef` on the child (`create`/`delete`) paired with an `AttachDetachParentChildResourceDef` on the parent (`attach`/`detach`).
- Changing `ManagedRoleToTransactions` only affects organizations created afterwards — add a [pkg/sqlmigration](/pkg/sqlmigration) migration to backfill existing ones.

View File

@@ -11,7 +11,6 @@ We adhere to three primary style guides as our foundation:
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package:
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
- [Authz](authz.md) - Authorization, roles, and access control
- [Errors](errors.md) - Structured error handling
- [Endpoint](endpoint.md) - HTTP endpoint patterns
- [Flagger](flagger.md) - Feature flag patterns

View File

@@ -62,6 +62,7 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
}

View File

@@ -87,6 +87,7 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
}

View File

@@ -329,10 +329,3 @@ export const LLMObservabilityPage = Loadable(
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -24,7 +24,6 @@ import {
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -515,17 +514,24 @@ const routes: AppRoutes[] = [
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_BASE,
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_BASE',
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
},
];

View File

@@ -5704,81 +5704,6 @@ export interface InframonitoringtypesPodCountsByPhaseDTO {
unknown: number;
}
export interface InframonitoringtypesPodCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
evicted: number;
/**
* @type integer
*/
failed: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
nodeAffinity: number;
/**
* @type integer
*/
nodeLost: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
pending: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
shutdown: number;
/**
* @type integer
*/
unexpectedAdmissionError: number;
/**
* @type integer
*/
unknown: number;
}
export interface InframonitoringtypesClusterRecordDTO {
/**
* @type number
@@ -5810,7 +5735,6 @@ export interface InframonitoringtypesClusterRecordDTO {
meta: InframonitoringtypesClusterRecordDTOMeta;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export enum InframonitoringtypesResponseTypeDTO {
@@ -5914,7 +5838,6 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
*/
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDaemonSetsDTO {
@@ -5992,7 +5915,6 @@ export interface InframonitoringtypesDeploymentRecordDTO {
*/
meta: InframonitoringtypesDeploymentRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDeploymentsDTO {
@@ -6159,7 +6081,6 @@ export interface InframonitoringtypesJobRecordDTO {
*/
meta: InframonitoringtypesJobRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type integer
*/
@@ -6213,7 +6134,6 @@ export interface InframonitoringtypesNamespaceRecordDTO {
*/
namespaceName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNamespacesDTO {
@@ -6280,7 +6200,6 @@ export interface InframonitoringtypesNodeRecordDTO {
*/
nodeName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNodesDTO {
@@ -6318,27 +6237,6 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
@@ -6365,7 +6263,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podCPURequest: number;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -6382,12 +6279,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podMemoryRequest: number;
podPhase: InframonitoringtypesPodPhaseDTO;
/**
* @type integer
* @format int64
*/
podRestarts: number;
podStatus: InframonitoringtypesPodStatusDTO;
/**
* @type string
*/
@@ -6705,7 +6596,6 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
*/
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -8979,6 +8869,44 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
/**
* @type object,null
*/
resource?: SpantypesSpanMapperTestSpanDTOResource;
}
export interface SpantypesGettableSpanMapperTestDTO {
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -9274,6 +9202,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesPostableSpanMapperTestGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type array,null
*/
mappers?: SpantypesPostableSpanMapperDTO[] | null;
/**
* @type string
*/
name: string;
}
export interface SpantypesPostableSpanMapperTestDTO {
/**
* @type array,null
*/
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export interface SpantypesSpanAggregationDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
@@ -10663,6 +10618,14 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type TestSpanMappers200 = {
data: SpantypesGettableSpanMapperTestDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {

View File

@@ -30,8 +30,10 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestSpanMappers200>({
url: `/api/v1/span_mapper_groups/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableSpanMapperTestDTO,
signal,
});
};
export const getTestSpanMappersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
const mutationKey = ['testSpanMappers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testSpanMappers>>,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
> = (props) => {
const { data } = props ?? {};
return testSpanMappers(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestSpanMappersMutationResult = NonNullable<
Awaited<ReturnType<typeof testSpanMappers>>
>;
export type TestSpanMappersMutationBody =
| BodyType<SpantypesPostableSpanMapperTestDTO>
| undefined;
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test span mappers against sample spans
*/
export const useTestSpanMappers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
return useMutation(getTestSpanMappersMutationOptions(options));
};

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#1287b1" viewBox="0 0 24 24"><title>Apache Cassandra</title><path d="M10.374 10.53a3 3 0 0 1-.428-.222l.555.143c0 .02-.01.036-.01.055l-.117.025zm-.283 1.506-.315.253.852-1.079-1.078.391c.002.017.009.033.009.05a.57.57 0 0 1-.184.42q.154.327.375.616a3.2 3.2 0 0 1 .34-.651zm.717-2.347-.652-.82a.43.43 0 0 1-.506.162c-.054.073-.083.162-.13.24l1.258.463zm-1.666.444c-.07.314-.087.637-.05.956a.57.57 0 0 1 .451.475l.946-.606c-.067-.022-.126-.06-.191-.088l-1.119-.08.64-.14a3.2 3.2 0 0 1-.668-.554zM20.1 11.648c-.164.202.833 1.022.833 1.022s-1.654-1.022-2.234-.72c-.278.144.574.811 1.175 1.242-.428-.274-.982-.571-1.175-.408-.328.277 1.565 2.549 1.565 2.549s-2.145-2.322-2.36-2.209c-.214.114.593 1.224.593 1.224s-1.06-1.16-1.35-.959c-.29.202 1.514 3.218 1.514 3.218s-1.956-3.091-2.763-2.574c1.268 2.782.795 3.18.795 3.18s-.162-2.839-1.742-2.764c-.795.038.379 2.12.379 2.12s-1.08-1.902-1.8-1.864c1.326 2.51.854 3.53.854 3.53s.219-2.143-1.58-3.336c.682.606-.427 3.336-.427 3.336s.976-4.023-.719-3.256c-.268.121-.019 2.007-.019 2.007s-.34-2.158-.851-2.045c-.298.066-1.893 2.99-1.893 2.99s1.306-3.16.908-3.027c-.29.096-.833 1.4-.833 1.4s.265-1.287 0-1.363c-.264-.075-1.74 1.363-1.74 1.363s1.097-1.287.908-1.552c-.287-.402-.623-.42-1.022-.265-.581.226-1.363 1.287-1.363 1.287s.78-1.074.643-1.476c-.219-.647-2.46 1.249-2.46 1.249s1.325-1.25 1.022-1.514c-.303-.265-1.947-.183-2.46-.185-1.515-.004-2.039-.36-2.498-.724 1.987.997 3.803-.151 6.094.494l.21.06c-1.3-.558-2.144-1.378-2.226-2.354-.036-.416.074-.827.297-1.222.619-.4 1.29-.773 2.06-1.095a4 4 0 0 0-.064.698c0 2.44 2.203 4.417 4.92 4.417s4.92-1.977 4.92-4.417c0-.45-.083-.881-.223-1.29 1.431.404 2.45.968 3.132 1.335.022.092.045.184.053.279.024.274-.018.547-.11.814.095-.147.198-.288.28-.445.367-.997 1.855.227 1.855.227s-1.085-.454-1.06-.24c.026.215 1.628.96 1.628.96s-1.45-.455-1.362-.114c.088.34 1.817 1.703 1.817 1.703s-1.956-1.489-2.12-1.287zm-7.268 2.65.042-.008-.06.01zM9.256 9.753c.12.13.26.234.396.343l.927-.029-1.064-.788c-.093.154-.195.303-.26.474Zm10.62 3.44c.3.215.54.373.54.373s-.24-.181-.54-.374zM7.507 8.617c-.14.229-.214.492-.215.76a3.99 3.99 0 0 0 2.358 3.64q0-.008.003-.014a3.2 3.2 0 0 1-.58-.788c-.648.099-.926-.794-.336-1.08a3.2 3.2 0 0 1 .138-1.388 3.16 3.16 0 0 1-.52-1.36q-.444.105-.848.23m1.488.82q.163-.36.402-.661a.435.435 0 0 1 .568-.557c.077-.059.166-.099.248-.15a16 16 0 0 0-1.727.284c.114.388.272.76.509 1.084m2.285 3.928c1.4 0 2.633-.723 3.344-1.816a3.4 3.4 0 0 0-1.265-.539l-.297-.023.916.9-1.197-.467.704 1.078-1.074-.832-.012.006.347 1.278-.596-1.134-.098 1.33-.401-1.326-.472 1.261.114-1.359-.015-.008-.814 1.154.286-1.067c-.34.322-.605.713-.781 1.146q.143.153.303.29.484.126 1.008.128m10.145-4.434c.971-.567 1.716-1.955 1.716-1.955s-1.893 1.955-3.205 1.665c1.186-.934 1.766-2.549 1.766-2.549s-1.506 2.325-2.448 2.423c1.086-.959 1.54-2.322 1.54-2.322s-1.237 1.817-2.196 1.944c1.287-1.161 1.338-1.893 1.338-1.893s-1.781 2.302-2.499 1.943c.858-.934 1.439-2.12 1.439-2.12s-1.489 2.019-1.893 1.69c-.277-.05.454-.958.454-.958s-.908.807-1.16.606c.454-.278 1.236-1.64 1.236-1.64S16 7.505 15.621 7.304l.731-1.483s-.73 1.483-1.715 1.23c.454-.58.63-1.112.63-1.112s-.756 1.213-1.69.885c-.22-.077.273-.635.273-.635s-.626.61-1.055.534c-.43-.076.025-.858.025-.858s-.757 1.186-.908 1.136.075-.833.075-.833-.555.908-.858.858 0-.934 0-.934-.328.984-.58.909c-.252-.076-.303-.656-.303-.656s-.068.788-.429.858c-2.725.53-5.728 1.69-9.489 5.45C3.887 10.738 5.3 7.91 11.962 7.659c5.044-.191 7.399 2.137 8.177 2.17C22.51 9.93 24 7.633 24 7.633s-1.489 1.716-2.574 1.3zm-7.74.872-.608.464v.001l.054.003a3.4 3.4 0 0 0 .554-.468m1.583-.426c0-.536-.237-.929-.594-1.217a3.2 3.2 0 0 1-.165.825.393.393 0 0 1-.328.681c-.154.233-.34.445-.549.63l.661.034-.995.237c-.025.018-.045.041-.07.058a3.2 3.2 0 0 1 1.536.691c.32-.574.504-1.235.504-1.94zM10.99 7.996a3.5 3.5 0 0 0-.785.46.43.43 0 0 1-.013.357l.885.643.023-.016-.36-1.262.627 1.12c.018-.006.04-.006.058-.011l-.02-1.251.398 1.163.477-1.15.016 1.268.012.007.713-1.005-.363 1.218.009.01 1.04-.69-.759 1.05.002.005.95-.34.041-.045a.395.395 0 0 1 .394-.632 3.4 3.4 0 0 0 .27-.784 14 14 0 0 0-2.798-.168c-.286.011-.55.033-.817.053"/></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 -2.25 262.5 364"><defs><style>.cls-1{fill:#326ce5}.cls-3{fill:none}</style></defs><path d="M59.724 97.778a10.183 10.183 0 0 1 0-17.073l114.07-74.16a10.18 10.18 0 0 1 11.1 0l114.07 74.16a10.183 10.183 0 0 1 0 17.073l-114.07 74.16a10.18 10.18 0 0 1-11.1 0z" class="cls-1"/><path fill="#c1d2f7" d="M197.356 110.866h7.912a6.003 6.003 0 0 0 5.2-9.005l-25.924-44.902a6.004 6.004 0 0 0-10.399 0l-25.924 44.902a6.003 6.003 0 0 0 5.2 9.005h7.912a6.003 6.003 0 0 1 6.004 6.003v51.257l5.31 3.452a12.29 12.29 0 0 0 13.395 0l5.31-3.452v-51.257a6.003 6.003 0 0 1 6.004-6.003"/><path d="M173.793 353.271a10.1 10.1 0 0 0 3.457 1.402 119 119 0 0 0-6.623-3.46zm-6.456-161.195-11.316 7.356a111 111 0 0 0 11.316 6.6zm24.015 23.716c9.739 3.115 19.813 5.648 30.11 8.186 10.92 2.692 21.973 5.427 32.818 9.009l-35.416-23.025a216 216 0 0 1-27.512-9.41zm0 62.191v.586c0 2.57-2.688 4.652-6.003 4.652H173.34c-3.315 0-6.003-2.083-6.003-4.652v-6.605c-24.906-6.402-49.873-14.485-70.86-33.82l-14.529 9.445c22.468 22.05 50.28 28.933 79.715 36.189 27.178 6.698 55.18 13.651 78.685 33.442l14.68-9.544c-18.532-16.504-40.41-23.687-63.676-29.693M93.315 300.95l38.908 25.295c20.064 5.113 40.2 11.248 58.31 23.36l14.901-9.687c-19.752-13.672-42.55-19.313-66.419-25.197-15.287-3.767-30.835-7.616-45.7-13.771m74.022-82.254a124.3 124.3 0 0 1-21.534-12.62l-14.805 9.625a124.5 124.5 0 0 0 36.339 18.708zm51.41 16.294c-9.106-2.245-18.304-4.524-27.395-7.299v13.682q5.051 1.286 10.177 2.538c29.934 7.379 60.888 15.01 85.708 39.829.61.61 1.162 1.24 1.752 1.857l9.974-6.484a10.1 10.1 0 0 0 3.475-3.8 101 101 0 0 0-3.289-3.486c-22.585-22.585-50.669-29.508-80.402-36.837m-19.933 19.933q-3.722-.918-7.462-1.855v13.21c25.817 6.561 51.803 14.688 73.455 35.04l14.521-9.44-.11-.118c-22.586-22.585-50.67-29.509-80.404-36.837m-31.477-8.587c-16.28-5.278-32.118-12.397-46.344-24.131l-14.682 9.545c17.87 15.557 38.784 22.673 61.026 28.5z" class="cls-3"/><path d="M73.24 254.96c-.348-.348-.658-.71-1-1.06l-12.514 8.136a10 10 0 0 0-1.644 1.397c1.053 1.157 2.115 2.31 3.245 3.44 22.585 22.585 50.67 29.509 80.403 36.837 25.343 6.247 51.401 12.717 73.865 29.603l14.806-9.626c-20.865-16.42-45.53-22.509-71.453-28.898-29.934-7.379-60.888-15.01-85.708-39.83" class="cls-3"/><path d="m218.864 209.962-27.512-17.886v8.476a216 216 0 0 0 27.512 9.41m-27.512 5.83v11.9c9.091 2.774 18.289 5.053 27.395 7.298 29.733 7.329 57.817 14.252 80.402 36.837a101 101 0 0 1 3.29 3.486 10.193 10.193 0 0 0-3.476-13.277l-44.683-29.05c-10.845-3.58-21.898-6.316-32.818-9.008-10.297-2.538-20.371-5.071-30.11-8.186m-24.015-9.76a111 111 0 0 1-11.316-6.6l-10.218 6.644a124.3 124.3 0 0 0 21.534 12.62zm34.192 37.88q-5.125-1.264-10.177-2.539v11.695q3.737.938 7.462 1.855c29.733 7.328 57.818 14.252 80.403 36.837.039.039.073.079.111.118l9.66-6.28c-.589-.618-1.142-1.248-1.751-1.858-24.82-24.82-55.774-32.45-85.708-39.829m-34.192-9.503a124.5 124.5 0 0 1-36.34-18.708l-10.004 6.504c14.226 11.734 30.064 18.853 46.344 24.13zm0 25.842c-22.242-5.828-43.156-12.944-61.026-28.5l-9.834 6.392c20.987 19.336 45.954 27.42 70.86 33.821zm24.015 17.732c23.266 6.006 45.144 13.19 63.675 29.693l9.78-6.358c-21.652-20.352-47.638-28.479-73.455-35.04zM81.948 247.59l-9.707 6.311c.341.35.652.712 1 1.06 24.82 24.82 55.773 32.45 85.707 39.829 25.922 6.39 50.588 12.478 71.452 28.898l9.948-6.467c-23.505-19.791-51.507-26.744-78.685-33.442-29.436-7.256-57.247-14.138-79.715-36.189m-20.621 19.284c-1.13-1.13-2.192-2.283-3.245-3.44a10.154 10.154 0 0 0 1.644 15.68l33.589 21.837c14.865 6.155 30.413 10.004 45.7 13.771 23.869 5.884 46.667 11.525 66.419 25.197l10.16-6.605c-22.463-16.886-48.521-23.356-73.864-29.603-29.733-7.328-57.818-14.252-80.403-36.837m109.3 84.34a119 119 0 0 1 6.623 3.46 10.16 10.16 0 0 0 7.645-1.402l5.639-3.666c-18.11-12.112-38.247-18.247-58.311-23.36z" class="cls-1"/></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><title>PlanetScale</title><path d="M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a12 12 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.96 11.96 0 0 1 0 12m12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24"/></svg>

Before

Width:  |  Height:  |  Size: 326 B

View File

@@ -2,7 +2,6 @@ import { Button } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface CheckboxValueRowProps {
value: string;
@@ -47,11 +46,9 @@ function CheckboxValueRow({
{customRendererForValue ? (
customRendererForValue(value)
) : (
<TooltipSimple title={value} side="top" align="center" arrow>
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
</TooltipSimple>
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}

View File

@@ -31,7 +31,6 @@ export enum LOCALSTORAGE {
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -89,8 +89,10 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
} as const;
export default ROUTES;

View File

@@ -177,13 +177,7 @@ export default function ConversationView({
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
onSendSuggestedPrompt={(text): void => {
handleSend(
text,
undefined,
autoContexts.length > 0 ? autoContexts : undefined,
);
}}
onSendSuggestedPrompt={(text): void => handleSend(text)}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">

View File

@@ -1,142 +0,0 @@
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { fireEvent, render } from '@testing-library/react';
import { MessageContext } from 'api/ai-assistant/chat';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { VariantContext } from 'container/AIAssistant/VariantContext';
const CHIP_ID = 'recent-errors';
const CHIP_TEXT = 'Show me recent errors';
// Auto-derived page context that a normal (typed) send would attach. The chip
// send must forward the exact same array.
const mockAutoContexts: MessageContext[] = [
{
source: 'auto',
type: 'dashboard',
resourceId: 'dashboard-123',
resourceName: 'Checkout dashboard',
},
];
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/getAutoContexts', () => ({
getAutoContexts: jest.fn(() => mockAutoContexts),
}));
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
normalizePage: (page: string): string => page,
useAIAssistantAnalyticsContext: (): unknown => ({ threadId: 'thread-1' }),
}));
// ChatInput is heavy and irrelevant here — the chip path lives entirely in the
// empty state. Provide a lightweight stub plus the `autoContextKey` named export
// ConversationView imports for its dismissed-context filter.
jest.mock('container/AIAssistant/components/ChatInput', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="chat-input" />,
autoContextKey: (): string => '',
}));
jest.mock('container/AIAssistant/components/ConversationSkeleton', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="skeleton" />,
}));
// VirtualizedMessages renders the real empty-state chips; stub only its
// never-rendered-in-empty-state children and the virtual list.
jest.mock('components/Noz/Noz', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="noz" />,
}));
jest.mock('container/AIAssistant/components/MessageBubble', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('container/AIAssistant/components/StreamingMessage', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('react-virtuoso', () => ({
__esModule: true,
Virtuoso: (): null => null,
}));
jest.mock(
'container/AIAssistant/components/VirtualizedMessages/useEmptyStateChips',
() => ({
useEmptyStateChips: (): { chips: { id: string; text: string }[] } => ({
chips: [{ id: CHIP_ID, text: CHIP_TEXT }],
}),
}),
);
// eslint-disable-next-line import/first
import ConversationView from '../ConversationView';
const CONVERSATION_ID = 'conv-1';
function renderView(variant: 'panel' | 'page' | 'modal'): {
getByTestId: (id: string) => HTMLElement;
} {
return render(
<MemoryRouter initialEntries={['/dashboard/dashboard-123']}>
<VariantContext.Provider value={variant}>
<ConversationView conversationId={CONVERSATION_ID} />
</VariantContext.Provider>
</MemoryRouter>,
);
}
describe('ConversationView — empty-state chip context', () => {
let sendMessage: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
sendMessage = jest.fn();
useAIAssistantStore.setState({
conversations: {
[CONVERSATION_ID]: {
id: CONVERSATION_ID,
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: CONVERSATION_ID,
isLoadingThread: false,
sendMessage,
} as unknown as Partial<ReturnType<typeof useAIAssistantStore.getState>>);
});
it('forwards the page auto-contexts when a chip is clicked (embedded variant)', () => {
const { getByTestId } = renderView('panel');
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
// The chip send must carry the same auto-contexts a typed message would.
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
CHIP_TEXT,
undefined,
mockAutoContexts,
);
});
it('sends undefined contexts on the standalone page (no page context to attach)', () => {
const { getByTestId } = renderView('page');
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(CHIP_TEXT, undefined, undefined);
});
});

View File

@@ -149,18 +149,6 @@
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
overflow-wrap: anywhere;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.dashboard-variables {

View File

@@ -43,7 +43,6 @@ import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { linkifyText } from 'utils/linkifyText';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
@@ -516,9 +515,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">
{linkifyText(description ?? '')}
</section>
<section className="dashboard-description-section">{description}</section>
)}
{!isEmpty(dashboardVariables) && (

View File

@@ -0,0 +1,13 @@
.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);
}
.pageFooter {
margin-top: var(--spacing-8);
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}

View File

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

View File

@@ -0,0 +1,156 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
makeMapper,
makeMappersResponse,
mappersEndpoint,
mockGroups,
mockMappers,
} from '../../__tests__/fixtures';
import AttributeMappingsTab from '../AttributeMappingsTab';
import { useAttributeMappingStore } from '../hooks/useAttributeMappingStore';
function setupGroups(): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
}
// The tab is a presentational view over a store owned by the container, so the
// harness creates the store (via the hook, backed by the mocked API) and wires
// the edit/add callbacks the tab needs. This suite only exercises the listing,
// so the callbacks are no-ops.
function AttributeMappingHarness(): JSX.Element {
const store = useAttributeMappingStore();
return (
<AttributeMappingsTab
store={store}
onEditGroup={jest.fn()}
onAddGroup={jest.fn()}
/>
);
}
// The real Save button lives in the page header; this harness exposes the same
// store.save() path the header wires up, so the tab suite can exercise it.
function SaveableHarness(): JSX.Element {
const store = useAttributeMappingStore();
return (
<>
<button
type="button"
data-testid="save-button"
onClick={(): void => {
void store.save();
}}
>
Save
</button>
<AttributeMappingsTab
store={store}
onEditGroup={jest.fn()}
onAddGroup={jest.fn()}
/>
</>
);
}
describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
});
afterEach(() => {
server.resetHandlers();
});
it('renders no error banner on a successful load', async () => {
setupGroups();
render(<AttributeMappingHarness />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows an error banner when the groups request fails', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<AttributeMappingHarness />);
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
'Failed to load mapping groups. Please try again.',
);
});
it("lazily fetches and renders a group's mappers on first expand", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mockMappers))),
),
);
render(<AttributeMappingHarness />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument();
await user.click(screen.getByTestId('group-expand-group-1'));
await expect(
screen.findByTestId('mapper-target-mapper-1'),
).resolves.toHaveTextContent('gen_ai.request.model');
});
it("re-fetches an open group's mappers on save instead of showing stale rows", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
// The mapper list changes server-side between the first open and the save
// (e.g. another edit landed, or the save reconciled ids). The open table
// must reflect the latest list — not the list cached on first expand.
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();
// Server now returns a different mapper for the group.
mappers = [makeMapper({ id: 'mapper-2', name: 'gen_ai.response.model' })];
await user.click(screen.getByTestId('save-button'));
// Fresh row appears; the stale one is gone. Without removeQueries the table
// would keep showing mapper-1 (the hydrate once-guard blocks the update).
await expect(
screen.findByTestId('mapper-target-mapper-2'),
).resolves.toHaveTextContent('gen_ai.response.model');
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,57 @@
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 '../../../types';
interface MapperGroupActionsMenuProps {
group: DraftGroup;
onEdit: (group: DraftGroup) => void;
onRemove: (localId: string) => void;
}
// Per-row overflow menu for a mapping group. The enable/disable toggle stays
// inline in the row (it's the primary, high-frequency action); the lower-
// frequency Edit and Delete actions live behind the kebab to keep the row
// compact.
function MapperGroupActionsMenu({
group,
onEdit,
onRemove,
}: MapperGroupActionsMenuProps): 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"
testId={`group-actions-${group.localId}`}
>
<EllipsisVertical size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default MapperGroupActionsMenu;

View File

@@ -0,0 +1,20 @@
.groupsTableWrapper {
// Reserve a stable row height so short skeleton rows and taller loaded rows
// (multi-line filter cells) share a height and the table doesn't jump on load.
// Acts as a floor — richer rows still grow. 48px matches the model-costs table.
--tanstack-table-row-height: var(--spacing-24);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.addRow {
align-self: flex-start;
}

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Plus } from '@signozhq/icons';
import TanStackTable from 'components/TanStackTableView';
import { DraftGroup } from '../../../types';
import { AttributeMappingStore } from '../../hooks/useAttributeMappingStore';
import MappersTable from '../MappersTable';
import styles from './MapperGroupsTable.module.scss';
import { getMapperGroupsColumns } from './TableConfig';
const SKELETON_ROW_COUNT = 5;
interface MapperGroupsTableProps {
store: AttributeMappingStore;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
// Top-level listing of mapping groups. Each row expands to reveal its mappers,
// which MappersTable fetches lazily on first open. Built on the shared
// TanStackTable with virtual scroll disabled — this is a small, content-height
// list, and nested expanded tables need to grow with their content rather than
// live inside a fixed-height viewport. Row actions (edit/delete/toggle) live in
// the column config; the add-group affordance sits below the table.
function MapperGroupsTable({
store,
onEditGroup,
onAddGroup,
}: MapperGroupsTableProps): JSX.Element {
const columns = useMemo(
() =>
getMapperGroupsColumns({
onEditGroup,
onRemoveGroup: store.removeGroup,
onToggleGroup: store.toggleGroup,
}),
[onEditGroup, store.removeGroup, store.toggleGroup],
);
const isEmpty = !store.isLoading && store.groups.length === 0;
return (
<div className={styles.groupsTableWrapper}>
{isEmpty ? (
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
No mapping groups yet.
</div>
) : (
<TanStackTable<DraftGroup>
data={store.groups}
columns={columns}
isLoading={store.isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.localId}
getRowCanExpand={(): boolean => true}
renderExpandedRow={(row): JSX.Element => (
<MappersTable group={row} store={store} />
)}
disableVirtualScroll
testId="mapper-groups-table"
/>
)}
<Button
variant="link"
color="primary"
prefix={<Plus size={14} />}
className={styles.addRow}
onClick={onAddGroup}
testId="add-group-row"
disabled={store.isLoading}
>
Add a new group
</Button>
</div>
);
}
export default MapperGroupsTable;

View File

@@ -0,0 +1 @@
export { getMapperGroupsColumns } from './mapperGroups.config';

View File

@@ -0,0 +1,121 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { TableColumnDef } from 'components/TanStackTableView';
import type { DraftGroup } from '../../../../types';
import { conditionFiltersFromGroup } from '../../../../utils';
import MapperGroupActionsMenu from '../MapperGroupActionsMenu';
import styles from './tableConfig.module.scss';
interface ColumnsConfig {
onEditGroup: (group: DraftGroup) => void;
onRemoveGroup: (localId: string) => void;
onToggleGroup: (localId: string, enabled: boolean) => void;
}
// Column definitions for the mapping-groups TanStackTable. Sorting is off across
// the board — the groups list API returns the full set unordered, so there's no
// server-side ordering to back a sortable header yet.
export function getMapperGroupsColumns({
onEditGroup,
onRemoveGroup,
onToggleGroup,
}: ColumnsConfig): TableColumnDef<DraftGroup>[] {
return [
{
id: 'name',
header: 'Group name',
accessorFn: (row): string => row.name,
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row, isExpanded, toggleExpanded }): JSX.Element => (
<div className={styles.groupsTableNameCell}>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
onClick={(): void => toggleExpanded()}
testId={`group-expand-${row.localId}`}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</Button>
<span
className={styles.groupsTableName}
data-testid={`group-name-${row.localId}`}
>
{row.name}
</span>
</div>
),
},
{
id: 'filters',
header: 'Filters',
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
const filters = conditionFiltersFromGroup(row);
if (filters.length === 0) {
return (
<span
className={styles.muted}
data-testid={`group-filters-${row.localId}`}
>
No condition · always runs
</span>
);
}
return (
<div
className={styles.groupsTableFilters}
data-testid={`group-filters-${row.localId}`}
>
{filters.map((filter) => (
<div
className={styles.groupsTableFilter}
key={`${filter.context}:${filter.key}`}
>
<Badge
color={filter.context === 'resource' ? 'amber' : 'robin'}
variant="outline"
>
{filter.context}
</Badge>
<span className={styles.groupsTableFilterKey}>
contains {filter.key}
</span>
</div>
))}
</div>
);
},
},
{
id: 'actions',
header: 'Actions',
// Compact, right-aligned action cluster — opt out of the "last column
// fills 100%" rule so the spare width flows into Group name / Filters.
width: { fixed: '160px', ignoreLastColumnFill: true },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.rowActions}>
<Switch
value={row.enabled}
onChange={(checked): void => onToggleGroup(row.localId, checked)}
testId={`group-enabled-${row.localId}`}
/>
<MapperGroupActionsMenu
group={row}
onEdit={onEditGroup}
onRemove={onRemoveGroup}
/>
</div>
),
},
];
}

View File

@@ -0,0 +1,49 @@
.groupsTableNameCell {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.groupsTableName {
font-weight: var(--font-weight-semibold);
}
.groupsTableFilters {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
// Allow this column to shrink within the cell so long keys wrap
// instead of forcing the cell wider than its allotted width.
min-width: 0;
}
.groupsTableFilter {
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
font-size: var(--periscope-font-size-base);
min-width: 0;
// Keep the context badge at full width; only the key text flexes.
> *:first-child {
flex-shrink: 0;
}
}
.groupsTableFilterKey {
min-width: 0;
font-family: 'Geist Mono', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted {
color: var(--l3-foreground);
}
.rowActions {
display: flex;
align-items: center;
gap: var(--spacing-3);
}

View File

@@ -0,0 +1,117 @@
import { render, screen } from 'tests/test-utils';
import { AttributeMappingStore } from '../../../hooks/useAttributeMappingStore';
import { buildDraftGroup } from '../../../../utils';
import { makeGroup } from '../../../../__tests__/fixtures';
import MapperGroupsTable from '../MapperGroupsTable';
function storeWith(
overrides: Partial<AttributeMappingStore>,
): AttributeMappingStore {
return {
groups: [],
snapshot: [],
isLoading: false,
isError: false,
isDirty: false,
isSaving: false,
saveError: null,
upsertGroup: jest.fn(),
removeGroup: jest.fn(),
toggleGroup: jest.fn(),
hydrateGroupMappers: jest.fn(),
upsertMapper: jest.fn(),
removeMapper: jest.fn(),
toggleMapper: jest.fn(),
save: jest.fn(),
discard: jest.fn(),
...overrides,
};
}
// The table is driven by the container's store plus edit/add callbacks. These
// tests focus on rendering, so the callbacks are no-ops.
function renderTable(store: AttributeMappingStore): void {
render(
<MapperGroupsTable
store={store}
onEditGroup={jest.fn()}
onAddGroup={jest.fn()}
/>,
);
}
describe('MapperGroupsTable', () => {
beforeEach(() => {
// The shared TanStackTable owns page/limit URL state via nuqs, which
// reads window.location — jsdom shares that across tests in a file.
window.history.pushState(null, '', '/');
});
it('renders the empty state when not loading and there are no groups', () => {
renderTable(storeWith({ groups: [] }));
expect(screen.getByTestId('mapper-groups-empty')).toHaveTextContent(
'No mapping groups yet.',
);
});
it('does not show the empty state while loading even with no groups', () => {
renderTable(storeWith({ groups: [], isLoading: true }));
expect(screen.queryByTestId('mapper-groups-empty')).not.toBeInTheDocument();
expect(screen.getByTestId('mapper-groups-table')).toBeInTheDocument();
});
it('renders a row with its name, condition filters and enabled status', () => {
const group = buildDraftGroup(
makeGroup({
id: 'group-1',
name: 'demo',
enabled: true,
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
}),
[],
);
renderTable(storeWith({ groups: [group] }));
expect(screen.getByTestId('group-name-group-1')).toHaveTextContent('demo');
const filters = screen.getByTestId('group-filters-group-1');
expect(filters).toHaveTextContent('attribute');
expect(filters).toHaveTextContent('contains ai.embeddings');
expect(filters).toHaveTextContent('resource');
expect(filters).toHaveTextContent('contains cloud.account.id');
expect(screen.getByTestId('group-enabled-group-1')).toBeChecked();
});
it('shows a disabled badge for a disabled group', () => {
const group = buildDraftGroup(
makeGroup({ id: 'group-2', enabled: false, condition: null }),
[],
);
renderTable(storeWith({ groups: [group] }));
expect(screen.getByTestId('group-enabled-group-2')).not.toBeChecked();
});
it('shows the no-condition placeholder when a group has no attribute/resource keys', () => {
const group = buildDraftGroup(
makeGroup({ id: 'group-3', condition: null }),
[],
);
renderTable(storeWith({ groups: [group] }));
expect(screen.getByTestId('group-filters-group-3')).toHaveTextContent(
'No condition · always runs',
);
});
// NOTE: the expand/collapse affordance is verified end-to-end in
// AttributeMappingsTab.test.tsx ("lazily fetches and renders a group's
// mappers on first expand"). It isn't re-tested here because the shared
// TanStackTable keeps row-expanded state in URL params (nuqs), which does
// not toggle reliably when the table is mounted in isolation in jsdom.
});

View File

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

View File

@@ -0,0 +1,56 @@
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 '../../../types';
interface MapperActionsMenuProps {
mapper: DraftMapper;
onEdit: (mapper: DraftMapper) => void;
onRemove: (localId: string) => void;
}
// Per-row overflow menu for a mapping. The enable/disable toggle stays inline in
// the row (it's the primary, high-frequency action); the lower-frequency Edit
// and Delete actions live behind the kebab to keep the row compact.
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

@@ -0,0 +1,26 @@
.mappersTableWrapper {
--tanstack-expansion-first-col-padding-left: var(--spacing-6);
--tanstack-table-row-height: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-2) var(--spacing-6) var(--spacing-6) var(--spacing-12);
padding: var(--spacing-4) 0;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
// Clip content while the panel grows during its expand (height) animation.
overflow: hidden;
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.addRow {
align-self: flex-start;
}

View File

@@ -0,0 +1,150 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Plus } from '@signozhq/icons';
import { useListSpanMappers } from 'api/generated/services/spanmapper';
import TanStackTable from 'components/TanStackTableView';
import { motion, useReducedMotion } from 'motion/react';
import MapperFormDrawer from '../../../components/MapperFormDrawer';
import { DraftGroup, DraftMapper, Mapper } from '../../../types';
import { useMapperFormDrawer } from '../../../useMapperFormDrawer';
import { AttributeMappingStore } from '../../hooks/useAttributeMappingStore';
import styles from './MappersTable.module.scss';
import { getMappersColumns } from './TableConfig';
const SKELETON_ROW_COUNT = 3;
// Expand reveal: the panel mounts already-open, so this mount transition IS the
// group's expand animation (height + fade).
const EXPAND_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
interface MappersTableProps {
group: DraftGroup;
store: AttributeMappingStore;
}
// Nested table of a group's mappers, rendered inside the group's expanded row.
// This component only mounts when its group row is expanded, so the fetch is
// lazy by construction — a group's mappers load on first open, are folded into
// the store's draft so they're editable, and are then cached by react-query.
// New (unsaved) groups have no serverId, so skip the fetch.
function MappersTable({ group, store }: MappersTableProps): JSX.Element {
const prefersReducedMotion = useReducedMotion();
const drawer = useMapperFormDrawer();
const { hydrateGroupMappers, upsertMapper, removeMapper, toggleMapper } =
store;
const hasServerId = group.serverId !== null;
const { data, isLoading, isError } = useListSpanMappers(
{ groupId: group.serverId ?? '' },
{ query: { enabled: hasServerId } },
);
useEffect(() => {
const items = data?.data?.items;
if (group.serverId && items) {
// The generated schema mis-types this list response with the groups DTO;
// the runtime payload is mappers.
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
}
}, [group.serverId, data, hydrateGroupMappers]);
const isLoadingMappers = hasServerId && isLoading;
const isErrorMappers = hasServerId && isError;
const handleSave = useCallback((): void => {
upsertMapper(group.localId, drawer.draft);
drawer.close();
}, [upsertMapper, group.localId, drawer]);
const handleDelete = useCallback((): void => {
if (drawer.draft.id) {
removeMapper(group.localId, drawer.draft.id);
}
drawer.close();
}, [removeMapper, group.localId, drawer]);
const columns = useMemo(
() =>
getMappersColumns({
onEdit: drawer.openForEdit,
onRemove: (localId): void => removeMapper(group.localId, localId),
onToggle: (localId, enabled): void =>
toggleMapper(group.localId, localId, enabled),
}),
[drawer.openForEdit, removeMapper, toggleMapper, group.localId],
);
let content: JSX.Element;
if (isErrorMappers) {
content = (
<div
className={styles.tableEmpty}
data-testid={`mappers-error-${group.localId}`}
>
Failed to load mappings. Please try again.
</div>
);
} else if (!isLoadingMappers && group.mappers.length === 0) {
content = (
<div
className={styles.tableEmpty}
data-testid={`mappers-empty-${group.localId}`}
>
No mappings in this group yet.
</div>
);
} else {
content = (
<TanStackTable<DraftMapper>
data={group.mappers}
columns={columns}
isLoading={isLoadingMappers}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.localId}
disableVirtualScroll
testId={`mappers-table-${group.localId}`}
/>
);
}
return (
<motion.div
className={styles.mappersTableWrapper}
initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={EXPAND_TRANSITION}
>
{content}
<Button
variant="link"
color="primary"
size="sm"
prefix={<Plus size={14} />}
className={styles.addRow}
onClick={drawer.openForAdd}
testId={`add-mapper-${group.localId}`}
>
Add mapping
</Button>
{drawer.isOpen && (
<MapperFormDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={handleSave}
onDelete={handleDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
)}
</motion.div>
);
}
export default MappersTable;

View File

@@ -0,0 +1 @@
export { getMappersColumns } from './mappers.config';

View File

@@ -0,0 +1,124 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import cx from 'classnames';
import { DraftMapper } from '../../../../types';
import MapperActionsMenu from '../MapperActionsMenu';
import styles from './tableConfig.module.scss';
const MAX_VISIBLE_SOURCES = 3;
interface ColumnsConfig {
onEdit: (mapper: DraftMapper) => void;
onRemove: (localId: string) => void;
onToggle: (localId: string, enabled: boolean) => void;
}
// Column definitions for the per-group mappers TanStackTable (rendered inside an
// expanded group row). Sorting is off — priority order is positional (top wins).
export function getMappersColumns({
onEdit,
onRemove,
onToggle,
}: ColumnsConfig): TableColumnDef<DraftMapper>[] {
return [
{
id: 'target',
header: 'Target',
accessorFn: (row): string => row.name,
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text
weight="semibold"
data-testid={`mapper-target-${row.localId}`}
>
{row.name}
</Typography.Text>
),
},
{
id: 'sources',
header: 'Sources',
width: { min: 220, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
// Skeleton placeholder rows reach the cell before real data, so
// `sources` can be undefined — default to empty.
const sources = row.sources ?? [];
if (sources.length === 0) {
return (
<span
className={styles.muted}
data-testid={`mapper-sources-${row.localId}`}
>
</span>
);
}
const visible = sources.slice(0, MAX_VISIBLE_SOURCES);
const remaining = sources.length - visible.length;
return (
<div
className={styles.mappersTableSources}
data-testid={`mapper-sources-${row.localId}`}
>
{visible.map((source) => (
<Badge
variant="outline"
color="vanilla"
className={styles.mappersTableSourceChip}
key={`${source.context}:${source.key}`}
>
{source.key}
</Badge>
))}
{remaining > 0 && (
<span className={cx(styles.mappersTableSourceMore, styles.muted)}>
+{remaining} more
</span>
)}
</div>
);
},
},
{
id: 'writesTo',
header: 'Writes to',
width: { min: 130 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color={
row.fieldContext === SpantypesFieldContextDTO.resource ? 'amber' : 'robin'
}
variant="outline"
>
{row.fieldContext}
</Badge>
),
},
{
id: 'actions',
header: 'Actions',
// Compact, right-aligned action cluster — opt out of the "last column
// fills 100%" rule so the spare width flows into Target / Sources.
width: { fixed: '160px', ignoreLastColumnFill: true },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.rowActions}>
<Switch
value={row.enabled}
onChange={(checked): void => onToggle(row.localId, checked)}
testId={`mapper-enabled-${row.localId}`}
/>
<MapperActionsMenu mapper={row} onEdit={onEdit} onRemove={onRemove} />
</div>
),
},
];
}

View File

@@ -0,0 +1,28 @@
.mappersTableSources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-3);
}
.mappersTableSourceChip {
// Badge's outline/vanilla variant supplies the border, background, radius and
// padding; textEllipsis measures against this max-width to truncate long keys.
max-width: 220px;
font-family: 'Geist Mono', monospace;
}
.mappersTableSourceMore {
font-size: var(--font-size-xs);
white-space: nowrap;
}
.muted {
color: var(--l3-foreground);
}
.rowActions {
display: flex;
align-items: center;
gap: var(--spacing-3);
}

View File

@@ -0,0 +1,173 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import { AttributeMappingStore } from '../../../hooks/useAttributeMappingStore';
import {
makeGroup,
makeMapper,
makeMappersResponse,
mappersEndpoint,
} from '../../../../__tests__/fixtures';
import { buildDraftGroup } from '../../../../utils';
import MappersTable from '../MappersTable';
// MappersTable is store-driven: it renders the group's draft mappers and folds
// the lazy fetch into the store via hydrateGroupMappers. These rendering tests
// supply the draft mappers on the group directly and stub the store callbacks.
function storeWith(
overrides: Partial<AttributeMappingStore> = {},
): AttributeMappingStore {
return {
groups: [],
snapshot: [],
isLoading: false,
isError: false,
isDirty: false,
isSaving: false,
saveError: null,
upsertGroup: jest.fn(),
removeGroup: jest.fn(),
toggleGroup: jest.fn(),
hydrateGroupMappers: jest.fn(),
upsertMapper: jest.fn(),
removeMapper: jest.fn(),
toggleMapper: jest.fn(),
save: jest.fn(),
discard: jest.fn(),
...overrides,
};
}
function setupMappers(mappers = [makeMapper()]): void {
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
),
);
}
describe('MappersTable', () => {
afterEach(() => {
server.resetHandlers();
});
it('shows the error state when the mappers request fails', async () => {
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(500)),
),
);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [])}
store={storeWith()}
/>,
);
await expect(
screen.findByTestId('mappers-error-group-1'),
).resolves.toHaveTextContent('Failed to load mappings. Please try again.');
});
it('shows the empty state when the group has no mappers', async () => {
setupMappers([]);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [])}
store={storeWith()}
/>,
);
await expect(
screen.findByTestId('mappers-empty-group-1'),
).resolves.toHaveTextContent('No mappings in this group yet.');
});
it('does not fetch and shows the empty state for a group with no server id', () => {
const fetchSpy = jest.fn();
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) => {
fetchSpy();
return res(ctx.status(200), ctx.json(makeMappersResponse([])));
}),
);
const draftGroup = {
...buildDraftGroup(makeGroup({ id: 'group-1' }), []),
serverId: null,
};
render(<MappersTable group={draftGroup} store={storeWith()} />);
expect(screen.getByTestId('mappers-empty-group-1')).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
it('renders a mapper row with target, sources, field context and status', async () => {
const mapper = makeMapper({
id: 'mapper-1',
name: 'gen_ai.request.model',
enabled: true,
});
setupMappers([mapper]);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [mapper])}
store={storeWith()}
/>,
);
await waitFor(() =>
expect(screen.getByTestId('mapper-target-mapper-1')).toBeInTheDocument(),
);
expect(screen.getByTestId('mapper-target-mapper-1')).toHaveTextContent(
'gen_ai.request.model',
);
const sources = screen.getByTestId('mapper-sources-mapper-1');
expect(sources).toHaveTextContent('genai.model');
expect(sources).toHaveTextContent('llm.model');
expect(screen.getByText('attribute')).toBeInTheDocument();
expect(screen.getByTestId('mapper-enabled-mapper-1')).toBeChecked();
});
it('collapses extra sources into a "+N more" label beyond the visible cap', async () => {
const mapper = makeMapper({
id: 'mapper-1',
config: {
sources: [1, 2, 3, 4, 5].map((priority) => ({
key: `source-${priority}`,
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
})),
},
});
setupMappers([mapper]);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [mapper])}
store={storeWith()}
/>,
);
await expect(screen.findByText('+2 more')).resolves.toBeInTheDocument();
});
it('shows a muted placeholder when a mapper has no sources', async () => {
const mapper = makeMapper({ id: 'mapper-1', config: { sources: [] } });
setupMappers([mapper]);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [mapper])}
store={storeWith()}
/>,
);
await waitFor(() =>
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
);
});
});

View File

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

View File

@@ -0,0 +1,149 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { rest, server } from 'mocks-server/server';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
mockGroups,
} from '../../../__tests__/fixtures';
import { useAttributeMappingStore } from '../useAttributeMappingStore';
function createWrapper(): ({
children,
}: {
children: React.ReactNode;
}) => React.ReactElement {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
function renderStore(): ReturnType<
typeof renderHook<ReturnType<typeof useAttributeMappingStore>, unknown>
> {
return renderHook(() => useAttributeMappingStore(), {
wrapper: createWrapper(),
});
}
describe('useAttributeMappingStore', () => {
afterEach(() => {
server.resetHandlers();
});
it('starts loading with no groups', () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
const { result } = renderStore();
expect(result.current.isLoading).toBe(true);
expect(result.current.groups).toStrictEqual([]);
});
it('builds a draft tree from the server groups once loaded', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
const { result } = renderStore();
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.groups).toHaveLength(2);
expect(result.current.groups[0]).toStrictEqual({
localId: 'group-1',
serverId: 'group-1',
name: 'demo',
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
enabled: true,
mappers: [],
});
expect(result.current.isError).toBe(false);
});
it('surfaces isError when the groups request fails', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
const { result } = renderStore();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.groups).toStrictEqual([]);
});
// Regression guard for the "empty mappers after save" bug: on save the groups
// list must be *invalidated* in place (it stays mounted, so no flash), while
// each per-group mapper list must be *removed* from the cache — invalidate
// alone leaves an expanded group's table stale/empty because react-query keeps
// `data` referentially stable when the list is unchanged, so the hydrate effect
// never re-fires. This asserts the exact query-client calls and their key
// targeting so a revert to invalidate-everything fails here.
it('invalidates the groups list and removes cached mapper lists on save', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
const removeSpy = jest.spyOn(queryClient, 'removeQueries');
const { result } = renderHook(() => useAttributeMappingStore(), {
wrapper: function Wrapper({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
},
});
// Wait until the draft is initialised, otherwise save() no-ops early.
await waitFor(() => expect(result.current.groups.length).toBeGreaterThan(0));
await act(async () => {
await result.current.save();
});
const groupsKey = ['/api/v1/span_mapper_groups'];
const mappersKey = ['/api/v1/span_mapper_groups/group-1/span_mappers'];
// Groups list: invalidated, and the predicate matches *only* the list key.
expect(invalidateSpy).toHaveBeenCalledTimes(1);
const invalidatePredicate = invalidateSpy.mock.calls[0][0] as unknown as {
predicate: (query: { queryKey: unknown[] }) => boolean;
};
expect(invalidatePredicate.predicate({ queryKey: groupsKey })).toBe(true);
expect(invalidatePredicate.predicate({ queryKey: mappersKey })).toBe(false);
// Per-group mapper lists: removed, and the predicate matches *only* the
// child mapper keys (never the groups list).
expect(removeSpy).toHaveBeenCalledTimes(1);
const removeFilters = removeSpy.mock.calls[0][0] as unknown as {
predicate: (query: { queryKey: unknown[] }) => boolean;
};
expect(removeFilters.predicate({ queryKey: mappersKey })).toBe(true);
expect(removeFilters.predicate({ queryKey: groupsKey })).toBe(false);
});
});

View File

@@ -0,0 +1,331 @@
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 AttributeMappingStore {
groups: DraftGroup[];
// The last-saved server baseline the working `groups` are diffed against.
// Exposed so the Test tab can send only the groups whose mappers changed.
snapshot: 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 useAttributeMappingStore(): AttributeMappingStore {
const queryClient = useQueryClient();
const groupsQuery = useListSpanMapperGroups();
const serverGroups: MapperGroup[] = useMemo(
() => groupsQuery.data?.data?.items ?? [],
[groupsQuery.data],
);
const ready = !groupsQuery.isLoading;
// A group's mappers are fetched lazily when its row is first expanded (see
// MappersTable -> hydrateGroupMappers) and cached here, keyed by group server
// id. Page load stays a single groups request — never an N+1 fan-out across
// every group.
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,
),
);
}, []);
// Fold a group's freshly-fetched server mappers into both the snapshot
// baseline and the working draft, exactly once per group (the guard skips
// re-expands). The "Add mapping" affordance renders while the fetch is still
// in flight, so a user can stage a new mapper (serverId === null) before the
// server list arrives — those unsaved rows are preserved, with the server
// mappers folded in ahead of them, so a racing add isn't clobbered.
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);
// Refetch the groups list in place — it stays mounted, so this just
// swaps in fresh data without a loading flash. Exact-match the key so
// this doesn't also touch the per-group mapper lists (handled below).
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey?.[0] === GROUPS_KEY_PREFIX,
});
// Reset the working copy and the lazily-loaded mapper mirror *before*
// the mapper queries re-emit, so the re-hydrate isn't clobbered.
loadedRef.current = new Set();
setLoadedMappers({});
setDraft(null);
// removeQueries (not invalidate) for the per-group mapper lists: an
// expanded group's table only re-hydrates when its query `data` changes
// reference, but react-query's structural sharing keeps `data` stable
// when the list is unchanged — so invalidate alone leaves the table
// empty. Removing the cache forces undefined -> refetch -> fresh, which
// always fires the hydrate effect. Also fixes stale mappers on re-expand.
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, queryClient]);
const isDirty = useMemo(
() => draft !== null && !isEqual(draft, snapshot),
[draft, snapshot],
);
return {
groups: draft ?? [],
snapshot,
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,
isSaving,
saveError,
upsertGroup,
removeGroup,
toggleGroup,
hydrateGroupMappers,
upsertMapper,
removeMapper,
toggleMapper,
save,
discard,
};
}

View File

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

View File

@@ -0,0 +1,14 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.pageError {
padding: var(--padding-3) var(--padding-4);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,88 @@
import { useCallback } from 'react';
import { Tabs } from '@signozhq/ui/tabs';
import AttributeMappingHeader from './components/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab';
import GroupFormDrawer from './components/GroupFormDrawer';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import TestTab from './TestTab';
import { useAttributeMappingStore } from './AttributeMappingsTab/hooks/useAttributeMappingStore';
import { useGroupFormDrawer } from './useGroupFormDrawer';
function LLMObservabilityAttributeMapping(): JSX.Element {
const store = useAttributeMappingStore();
const groupDrawer = useGroupFormDrawer();
const handleGroupSave = useCallback((): void => {
store.upsertGroup(groupDrawer.draft);
groupDrawer.close();
}, [store, groupDrawer]);
const handleGroupDelete = useCallback((): void => {
if (groupDrawer.draft.id) {
store.removeGroup(groupDrawer.draft.id);
}
groupDrawer.close();
}, [store, groupDrawer]);
const tabItems = [
{
key: 'attribute-mappings',
label: 'Attribute mappings',
children: (
<AttributeMappingsTab
store={store}
onEditGroup={groupDrawer.openForEdit}
onAddGroup={groupDrawer.openForAdd}
/>
),
},
{
key: 'test',
label: 'Test',
children: <TestTab store={store} />,
},
];
return (
<div
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={store.isDirty}
isSaving={store.isSaving}
onDiscard={store.discard}
onSave={store.save}
/>
{store.saveError && (
<div className={styles.pageError} role="alert">
{store.saveError}
</div>
)}
<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}
onDelete={handleGroupDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
)}
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -0,0 +1,88 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { useMemo } from 'react';
import { AttrChangeStatus, diffSpanAttributes } from './testPayload';
import styles from './TestTab.module.scss';
interface TestResultProps {
index: number;
span: SpantypesSpanMapperTestSpanDTO;
inputAttributes: Record<string, unknown>;
}
const STATUS_BADGE: Partial<
Record<
AttrChangeStatus,
{ color: 'success' | 'robin' | 'sienna'; label: string }
>
> = {
added: { color: 'success', label: 'populated' },
changed: { color: 'robin', label: 'remapped' },
removed: { color: 'sienna', label: 'moved out' },
};
// Only added/removed rows carry a background treatment; the rest render plain.
// Kept as an explicit map so we never do dynamic `styles[...]` access.
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
added: styles.added,
removed: styles.removed,
};
function formatValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
// Renders one transformed span as a key/value list, highlighting which target
// attributes the mappers populated and which source keys a move consumed.
function TestResult({
index,
span,
inputAttributes,
}: TestResultProps): JSX.Element {
const entries = useMemo(
() => diffSpanAttributes(inputAttributes, span.attributes ?? {}),
[inputAttributes, span.attributes],
);
return (
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
<div className={styles.resultTitle}>Resulting attributes</div>
{entries.length === 0 ? (
<div className={styles.resultEmpty}>This span has no attributes.</div>
) : (
<div className={styles.attrRows}>
{entries.map((entry) => {
const badge = STATUS_BADGE[entry.status];
return (
<div
key={entry.key}
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
>
<span className={styles.attrKey} title={entry.key}>
{entry.key}
</span>
<span className={styles.attrValue} title={formatValue(entry.value)}>
{formatValue(entry.value)}
</span>
{badge ? (
<Badge color={badge.color} variant="outline">
{badge.label}
</Badge>
) : (
<span />
)}
</div>
);
})}
</div>
)}
</div>
);
}
export default TestResult;

View File

@@ -0,0 +1,160 @@
.testTab {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
// Heading + description on the left, Run button pinned to the top-right.
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-6);
}
.headerText {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.description {
margin: 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
height: calc(100vh - 320px);
min-height: 320px;
}
.editor {
width: 100%;
height: 100%;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}
.resultsPanel {
height: 100%;
overflow-y: auto;
padding: var(--padding-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
}
// Pre-run empty state that fills the blank right half.
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
height: 100%;
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.placeholderTitle {
font-weight: var(--font-weight-semibold);
color: var(--l2-foreground);
}
.error {
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);
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.resultCard {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--padding-4);
border-radius: var(--radius-2);
background: var(--l1-background);
}
.resultTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.resultEmpty {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.attrRows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.attrRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--padding-3);
border-radius: var(--radius-2);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
&.added {
background: var(--callout-success-background);
}
&.removed {
opacity: 0.65;
.attrKey,
.attrValue {
text-decoration: line-through;
}
}
}
.attrKey,
.attrValue {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attrKey {
font-weight: var(--font-weight-medium);
}
.attrValue {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,118 @@
import { Play } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import Editor from 'components/Editor';
import styles from './TestTab.module.scss';
import TestResult from './TestResult';
import { AttributeMappingStore } from './AttributeMappingsTab/hooks/useAttributeMappingStore';
import { useTestSpanMapper } from './useTestSpanMapper';
interface TestTabProps {
store: AttributeMappingStore;
}
// "Test" tab: paste a sample span, run it through the working draft's mappers,
// and see which target attributes get populated. Only groups whose mappers
// changed are sent in full — unchanged groups are backfilled server-side.
function TestTab({ store }: TestTabProps): JSX.Element {
const { input, setInput, run, isRunning, result, testedAttributes, error } =
useTestSpanMapper(store.snapshot, store.groups);
function renderResults(): JSX.Element {
if (error) {
return (
<div className={styles.error} role="alert" data-testid="test-error">
{error}
</div>
);
}
if (result) {
if (result.length === 0) {
return (
<div className={styles.resultEmpty} data-testid="test-results-empty">
No spans returned. The mappers produced no output for this input.
</div>
);
}
return (
<div className={styles.results} data-testid="test-results">
{result.map((span, index) => (
<TestResult
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
span={span}
inputAttributes={testedAttributes ?? {}}
/>
))}
</div>
);
}
return (
<div className={styles.placeholder} data-testid="test-results-placeholder">
<span className={styles.placeholderTitle}>No results yet</span>
<span>
Run the test to see which target attributes your mappers populate.
</span>
</div>
);
}
return (
<div className={styles.testTab} data-testid="test-tab">
<div className={styles.header}>
<div className={styles.headerText}>
<h3 className={styles.heading}>Test with sample span</h3>
<p className={styles.description}>
Paste a JSON span object to see which target attributes get populated and
which source key matched.
</p>
</div>
<Button
testId="run-test-button"
variant="solid"
color="primary"
prefix={<Play size={14} />}
onClick={run}
loading={isRunning}
disabled={isRunning}
>
Run Test
</Button>
</div>
<div className={styles.body}>
<div
className={styles.editor}
data-testid="test-span-input"
aria-label="Sample span JSON"
>
<Editor
language="json"
value={input}
onChange={setInput}
height="100%"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
formatOnPaste: true,
tabSize: 2,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
</div>
<div className={styles.resultsPanel} data-testid="test-results-panel">
{renderResults()}
</div>
</div>
</div>
);
}
export default TestTab;

View File

@@ -0,0 +1,72 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
function setupGroups(): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
}
describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupGroups();
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page shell', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('shows the attribute-mappings and test sub-tab labels', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByRole('tab', { name: 'Attribute mappings' }),
).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
});
it('activates the attribute-mappings tab by default and renders its content', async () => {
render(<LLMObservabilityAttributeMapping />);
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute mappings',
});
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('enables the test tab and renders its content when activated', async () => {
const user = userEvent.setup();
render(<LLMObservabilityAttributeMapping />);
const testTab = screen.getByRole('tab', { name: 'Test' });
expect(testTab).toBeEnabled();
await user.click(testTab);
await expect(screen.findByTestId('test-tab')).resolves.toBeInTheDocument();
});
it('renders the header with Save/Discard disabled by default', () => {
render(<LLMObservabilityAttributeMapping />);
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,114 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
import {
buildDraftGroup,
buildDraftMapper,
conditionFiltersFromGroup,
getMapperSources,
} from '../utils';
import { makeGroup, makeMapper } from './fixtures';
describe('conditionFiltersFromGroup', () => {
it('lists attribute keys before resource keys', () => {
const filters = conditionFiltersFromGroup({
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
});
expect(filters).toStrictEqual([
{ context: 'attribute', key: 'ai.embeddings' },
{ context: 'resource', key: 'cloud.account.id' },
]);
});
it('defaults missing attributes/resource to no filters', () => {
expect(conditionFiltersFromGroup({})).toStrictEqual([]);
});
});
describe('getMapperSources', () => {
it('orders sources by priority, highest first', () => {
const mapper = makeMapper({
config: {
sources: [
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
},
{
key: 'genai.model',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
},
],
},
});
expect(getMapperSources(mapper)).toStrictEqual([
{
key: 'genai.model',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
},
]);
});
it('defaults a null sources config to an empty list', () => {
const mapper = makeMapper({ config: { sources: null } });
expect(getMapperSources(mapper)).toStrictEqual([]);
});
});
describe('buildDraftMapper', () => {
it('maps the server mapper into a draft node keyed by the server id', () => {
const mapper = makeMapper({ id: 'mapper-9', enabled: false });
const draft = buildDraftMapper(mapper);
expect(draft.localId).toBe('mapper-9');
expect(draft.serverId).toBe('mapper-9');
expect(draft.name).toBe(mapper.name);
expect(draft.enabled).toBe(false);
expect(draft.sources).toStrictEqual(getMapperSources(mapper));
});
});
describe('buildDraftGroup', () => {
it('maps the server group and its mappers into a draft tree', () => {
const group = makeGroup({
id: 'group-9',
condition: { attributes: ['a'], resource: ['b'] },
});
const mapper = makeMapper({ id: 'mapper-1' });
const draft = buildDraftGroup(group, [mapper]);
expect(draft.localId).toBe('group-9');
expect(draft.serverId).toBe('group-9');
expect(draft.attributes).toStrictEqual(['a']);
expect(draft.resource).toStrictEqual(['b']);
expect(draft.mappers).toHaveLength(1);
expect(draft.mappers[0].localId).toBe('mapper-1');
});
it('defaults a null condition to empty attributes/resource', () => {
const group = makeGroup({ condition: null });
const draft = buildDraftGroup(group, []);
expect(draft.attributes).toStrictEqual([]);
expect(draft.resource).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,17 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

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

View File

@@ -0,0 +1,89 @@
import { render, screen, userEvent } from 'tests/test-utils';
import AttributeMappingHeader from '../AttributeMappingHeader';
describe('AttributeMappingHeader', () => {
it('renders the description copy', () => {
render(
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={jest.fn()}
onSave={jest.fn()}
/>,
);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
});
it('hides the unsaved-changes indicator and disables Save/Discard when not dirty', () => {
render(
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={jest.fn()}
onSave={jest.fn()}
/>,
);
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
});
it('shows the unsaved-changes indicator and enables Save/Discard when dirty', () => {
render(
<AttributeMappingHeader
isDirty
isSaving={false}
onDiscard={jest.fn()}
onSave={jest.fn()}
/>,
);
expect(screen.getByTestId('unsaved-changes')).toHaveTextContent(
'Unsaved changes',
);
expect(screen.getByTestId('discard-changes-btn')).toBeEnabled();
expect(screen.getByTestId('save-changes-btn')).toBeEnabled();
});
it('disables Save/Discard and shows the saving label while saving', () => {
render(
<AttributeMappingHeader
isDirty
isSaving
onDiscard={jest.fn()}
onSave={jest.fn()}
/>,
);
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
expect(screen.getByTestId('save-changes-btn')).toHaveTextContent('Saving…');
});
it('calls onSave and onDiscard when their buttons are clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
const onDiscard = jest.fn();
render(
<AttributeMappingHeader
isDirty
isSaving={false}
onDiscard={onDiscard}
onSave={onSave}
/>,
);
await user.click(screen.getByTestId('save-changes-btn'));
await user.click(screen.getByTestId('discard-changes-btn'));
expect(onSave).toHaveBeenCalledTimes(1);
expect(onDiscard).toHaveBeenCalledTimes(1);
});
});

View File

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

View File

@@ -0,0 +1,93 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import styles from './GroupFormDrawer.module.scss';
import KeySearchInput from '../KeySearchInput';
import { FieldContextValue } from '../../types';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
}
// Editor for one list of condition keys (the group's span-attribute or
// resource gating keys). Substring "contains" match, order irrelevant.
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.groupFormField}>
<span className={styles.groupFormLabel}>
{label}
{labelHint && (
<span className={styles.groupFormLabelHint}> {labelHint}</span>
)}
</span>
{keys.length > 0 && (
<div className={styles.groupFormKeys}>
{keys.map((key, index) => (
// eslint-disable-next-line react/no-array-index-key
<div className={styles.groupFormKey} key={index}>
<KeySearchInput
className={styles.groupFormKeyInput}
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,76 @@
.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);
}
.groupFormLabelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.groupFormHint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.groupFormKeys {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.groupFormKey {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.groupFormKeyInput {
flex: 1;
font-family: 'Geist Mono', monospace;
}
.groupFormError {
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);
}
.groupFormFooter {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--spacing-4);
}
.groupFormFooterActions {
display: flex;
gap: var(--spacing-4);
margin-left: auto;
}

View File

@@ -0,0 +1,147 @@
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 { Trash2 } from '@signozhq/icons';
import ConditionKeyList from './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;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function GroupFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: 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}>
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="group-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className={styles.groupFormFooterActions}>
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="group-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="group-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save group' : 'Create group'}
</Button>
</div>
</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>
{saveError && (
<div className={styles.groupFormError} role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default GroupFormDrawer;

View File

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

View File

@@ -0,0 +1,51 @@
.keySearch {
position: relative;
flex: 1;
min-width: 0;
}
.keySearchDropdown {
position: absolute;
top: calc(100% + var(--spacing-2));
left: 0;
z-index: 20;
min-width: 100%;
width: max-content;
max-width: 420px;
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 rgba(0, 0, 0, 0.4);
}
.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-family: 'Geist Mono', monospace;
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,117 @@
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 styles from './KeySearchInput.module.scss';
import { FieldContext, FieldContextValue } from '../../types';
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}
// 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 @@
export { default } from './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,257 @@
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';
import styles from './MapperFormDrawer.module.scss';
import SourceAttributeRow from './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);
// Stable per-row ids for the sortable list. These are UI-only (never sent to
// the API and excluded from the draft), so dnd-kit can track rows reliably
// even though sources are stored as a plain array. Re-seeded each time the
// drawer opens; kept in lockstep with the sources array on add/remove/drag.
const [rowIds, setRowIds] = useState<string[]>(() =>
draft.sources.map(() => uuid()),
);
const sourceIds = draft.sources.map(
(_, index) => rowIds[index] ?? `pending-${index}`,
);
// 5px activation distance so clicking into the input never starts a drag.
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,116 @@
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 KeySearchInput from '../KeySearchInput';
import styles from './SourceAttributeRow.module.scss';
import {
FieldContext,
FieldContextValue,
MapperOperation,
MapperOperationValue,
SourceConfig,
} from '../../types';
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;
}
// A single draggable source row. Order = priority (top wins). Each source can
// be read from a span attribute or the resource, and moved (delete source) or
// copied (keep source). Only the grip is a drag handle.
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 @@
export { default } from './MapperFormDrawer';

View File

@@ -0,0 +1,187 @@
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 store 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),
);
// Deleted mappers.
await Promise.all(
snapshotMappers
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
);
// Created + updated mappers (sequential to keep ordering deterministic).
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)),
);
}
}
}
}
// 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),
);
// Apply additive work (creates/updates) before deletes, so a failure here
// leaves at worst an incomplete set of additions rather than groups that
// were deleted with no replacement persisted. Deletes are irreversible
// (they cascade mappers server-side), so we do them last, once everything
// else has succeeded.
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

@@ -0,0 +1,135 @@
import {
SpantypesPostableSpanMapperTestDTO,
SpantypesPostableSpanMapperTestGroupDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DraftGroup } from './types';
import {
buildPostableGroup,
buildPostableMapper,
groupDraftFromNode,
mapperDraftFromNode,
} from './utils';
// A group's mappers must be sent in full when the group is new (the backend has
// no saved group of that name to backfill from) or when its mapper set has
// diverged from the saved baseline. Otherwise we omit them and let the backend
// load the saved mappers by group name — which also covers groups whose rows
// were never expanded (their draft carries no mappers yet).
function shouldSendMappers(
snap: DraftGroup | undefined,
group: DraftGroup,
): boolean {
if (!snap) {
return true;
}
return JSON.stringify(snap.mappers) !== JSON.stringify(group.mappers);
}
// Builds the `groups` portion of the test request from the working draft,
// sending each group's name/enabled/condition from the current draft and its
// `mappers` only when they changed (null otherwise → backend backfills).
export function buildTestGroups(
snapshot: DraftGroup[],
draft: DraftGroup[],
): SpantypesPostableSpanMapperTestGroupDTO[] {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
return draft.map((group) => {
const base = buildPostableGroup(groupDraftFromNode(group));
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
return {
...base,
mappers: shouldSendMappers(snap, group)
? group.mappers.map((mapper) =>
buildPostableMapper(mapperDraftFromNode(mapper)),
)
: null,
};
});
}
// Parses the pasted JSON into a single test span. The pasted object is treated
// as the span's attribute map (matching the sample shown to the user); resource
// is left empty. Throws a friendly error on anything that isn't a JSON object.
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('Paste a JSON span object to run the test.');
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error(
'Invalid JSON — check for trailing commas or missing quotes.',
);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Span must be a JSON object of attribute key-value pairs.');
}
return { attributes: parsed as Record<string, unknown>, resource: {} };
}
export function buildTestRequest(
snapshot: DraftGroup[],
draft: DraftGroup[],
input: string,
): SpantypesPostableSpanMapperTestDTO {
return {
groups: buildTestGroups(snapshot, draft),
spans: [parseSpanInput(input)],
};
}
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
export interface AttrDiffEntry {
key: string;
value: unknown;
status: AttrChangeStatus;
}
function valuesEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
// Diffs the span attributes a user pasted against the attributes the mappers
// produced, so the UI can highlight which target keys got populated ('added'),
// which source keys were consumed by a move ('removed'), and what stayed.
// Added keys (the populated targets) sort first as the primary signal.
export function diffSpanAttributes(
inputAttributes: Record<string, unknown>,
resultAttributes: Record<string, unknown>,
): AttrDiffEntry[] {
const added: AttrDiffEntry[] = [];
const changed: AttrDiffEntry[] = [];
const unchanged: AttrDiffEntry[] = [];
const removed: AttrDiffEntry[] = [];
Object.entries(resultAttributes).forEach(([key, value]) => {
if (!(key in inputAttributes)) {
added.push({ key, value, status: 'added' });
} else if (!valuesEqual(inputAttributes[key], value)) {
changed.push({ key, value, status: 'changed' });
} else {
unchanged.push({ key, value, status: 'unchanged' });
}
});
Object.entries(inputAttributes).forEach(([key, value]) => {
if (!(key in resultAttributes)) {
removed.push({ key, value, status: 'removed' });
}
});
return [...added, ...changed, ...unchanged, ...removed];
}

View File

@@ -0,0 +1,78 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
// Convenience aliases over the generated DTOs. The read-only listing consumes
// the generated types directly, but the group-drawer form code reads better
// against these domain names (and FieldContext is used as a value/enum).
export type MapperGroup = SpantypesSpanMapperGroupDTO;
export type Mapper = SpantypesSpanMapperDTO;
export const FieldContext = SpantypesFieldContextDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
// A single human-readable condition clause shown in the group's Filters column.
export interface ConditionFilter {
context: 'attribute' | 'resource';
key: string;
}
export type MapperDraftMode = 'add' | 'edit';
// One source candidate. `context` is where the key is read from (span
// attribute or resource); `operation` is move (delete source) or copy (keep).
// Priority is implicit in list order (top wins), derived on save.
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
// 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;
}
// Editable form state for a group. The group runs when a span carries a
// span-attribute key matching `attributes` OR a resource key matching
// `resource` (plain substring match).
export interface GroupDraft {
id: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}
// Working-copy node for a mapper. `localId` is a stable client key (the server
// id once persisted, or a temporary id for not-yet-saved rows). `serverId` is
// null until the row has been persisted.
export interface DraftMapper {
localId: string;
serverId: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
// Working-copy node for a group, holding its mappers inline so the whole tree
// can be staged locally and diffed against the server snapshot on save.
export interface DraftGroup {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
}

View File

@@ -0,0 +1,40 @@
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;
}
// Form state for the group drawer. Persistence is staged through the store,
// so this hook only owns open/draft/mode.
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,40 @@
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;
}
// Form state for the mapper drawer. Persistence is staged through the store,
// so this hook only owns open/draft/mode.
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,109 @@
import { useCallback, useState } from 'react';
import {
RenderErrorResponseDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
import { AxiosError } from 'axios';
import { buildTestRequest } from './testPayload';
import { DraftGroup } from './types';
// Pre-filled sample so the tab is runnable on first open (mirrors the design).
export const SAMPLE_SPAN_JSON = `{
"my_company.llm.input": "What is quantum computing?",
"llm.input_messages": "What is quantum computing?",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.total_tokens": 1250,
"gen_ai.content.completion": "Quantum computing leverages..."
}`;
function apiErrorMessage(error: unknown): string {
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
return (
axiosError?.response?.data?.error?.message ??
(error instanceof Error ? error.message : 'Test failed. Please try again.')
);
}
export interface UseTestSpanMapper {
input: string;
setInput: (value: string) => void;
run: () => void;
reset: () => void;
isRunning: boolean;
result: SpantypesSpanMapperTestSpanDTO[] | null;
// The attributes that were actually submitted with the last successful run,
// so the result diff stays stable even if the textarea is edited afterwards.
testedAttributes: Record<string, unknown> | null;
error: string | null;
}
// Owns the Test tab's local state: the pasted span JSON, the parsed/built
// request, and the result/error of the test mutation. Builds the request from
// the working draft (sending only changed groups' mappers — see testPayload).
export function useTestSpanMapper(
snapshot: DraftGroup[],
draft: DraftGroup[],
): UseTestSpanMapper {
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
null,
);
const [testedAttributes, setTestedAttributes] = useState<Record<
string,
unknown
> | null>(null);
const { mutate, isLoading } = useTestSpanMappers();
const run = useCallback((): void => {
setError(null);
let body;
try {
body = buildTestRequest(snapshot, draft, input);
} catch (parseError) {
setResult(null);
setError(apiErrorMessage(parseError));
return;
}
const submittedAttributes = (body.spans?.[0]?.attributes ?? {}) as Record<
string,
unknown
>;
mutate(
{ data: body },
{
onSuccess: (response) => {
setTestedAttributes(submittedAttributes);
setResult(response.data?.spans ?? []);
},
onError: (mutationError) => {
setResult(null);
setError(apiErrorMessage(mutationError));
},
},
);
}, [snapshot, draft, input, mutate]);
const reset = useCallback((): void => {
setError(null);
setResult(null);
setTestedAttributes(null);
}, []);
return {
input,
setInput,
run,
reset,
isRunning: isLoading,
result,
testedAttributes,
error,
};
}

View File

@@ -0,0 +1,264 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import {
ConditionFilter,
DraftGroup,
DraftMapper,
FieldContext,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from './types';
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
// with a server UUID and is easy to spot in logs.
export function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
export 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;
}
// Display clauses for a group's condition keys (span attribute keys first,
// then resource keys).
export function conditionFiltersFromGroup(group: {
attributes?: string[];
resource?: string[];
}): ConditionFilter[] {
// TanStackTable renders skeleton placeholder rows through the cells on first
// render, so these arrays can be undefined before real data lands — default
// to empty rather than crashing the cell.
return [
...(group.attributes ?? []).map((key) => ({
context: 'attribute' as const,
key,
})),
...(group.resource ?? []).map((key) => ({
context: 'resource' as const,
key,
})),
];
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
export function getMapperSources(mapper: Mapper): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => b.priority - a.priority)
.map((source) => ({
key: source.key,
context: source.context,
operation: source.operation,
}));
}
export function createEmptySource(): SourceConfig {
return {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
};
}
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
id: null,
name: '',
fieldContext: FieldContext.attribute,
sources: [createEmptySource()],
enabled: true,
};
// Trimmed, de-duplicated (by context+key), non-empty sources in priority order,
// preserving each source's context and operation. A key identifies a different
// source per context (span attribute vs resource), so both can coexist; only an
// exact (context, key) repeat is collapsed, keeping the higher-priority row.
export 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),
},
};
}
// A full group payload is also a valid partial-update payload (all updatable
// fields are present), so we reuse the postable builder.
export function buildUpdatableGroup(
draft: GroupDraft,
): SpantypesUpdatableSpanMapperGroupDTO {
return buildPostableGroup(draft);
}
// ---- working-copy (draft tree) helpers ----
export function buildDraftMapper(mapper: Mapper): DraftMapper {
return {
localId: mapper.id,
serverId: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
export function buildDraftGroup(
group: MapperGroup,
mappers: Mapper[],
): DraftGroup {
return {
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,
};
}
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
// staged tree already holds normalized values.
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

@@ -1,27 +1,7 @@
.llmObservability {
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);
}
height: 100%;
margin-top: var(--spacing-2);
margin-left: var(--spacing-2);
}

View File

@@ -1,16 +1,22 @@
import { Tabs } from '@signozhq/ui/tabs';
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
import styles from './LLMObservability.module.scss';
// Shell for the LLM Observability page: renders the top-level tab bar
// (Overview / Configuration) using the SigNoz design-system Tabs, with
// route-driven active state from useLLMObservabilityTabs.
function LLMObservability(): JSX.Element {
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
return (
<div className={styles.llmObservability} data-testid="llm-observability-page">
<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>
<Tabs
items={items}
value={activeTab}
onChange={onTabChange}
testId="llm-observability-tabs"
/>
</div>
);
}

View File

@@ -0,0 +1,27 @@
.overview {
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);
}
}

View File

@@ -0,0 +1,20 @@
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 {
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>
</div>
);
}
export default Overview;

View File

@@ -2,29 +2,4 @@
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 {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.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);
}
}

View File

@@ -1,5 +1,4 @@
import { Tabs } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import ModelCostTabPanel from './ModelCostTabPanel';
import styles from './LLMObservabilityModelPricing.module.scss';
@@ -10,20 +9,9 @@ function LLMObservabilityModelPricing(): JSX.Element {
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<Typography.Text as="h1" size="large" weight="semibold">
Configuration
</Typography.Text>
<Typography.Text color="muted">
Model pricing and cost estimation settings
</Typography.Text>
</div>
</header>
<Tabs
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands, this can become a URL-backed param.
// the unpriced-models tab lands in a later PR.
defaultValue="model-costs"
items={[
{

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Search, X } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
@@ -161,12 +160,6 @@ function ModelCostTabPanel(): JSX.Element {
onDelete={deletion.requestDelete}
/>
<footer>
<Typography.Text color="muted" size="small">
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}

View File

@@ -0,0 +1,229 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
LLM_PRICING_ENDPOINT,
LLM_PRICING_RULE_ENDPOINT,
makeListResponse,
mockRules,
} from '../../__tests__/fixtures';
import ModelCostTabPanel from '../ModelCostTabPanel';
const toastSuccess = jest.fn();
const toastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): void => toastSuccess(...args),
error: (...args: unknown[]): void => toastError(...args),
},
}));
function setupList(items = mockRules, total = items.length): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(items, total))),
),
);
}
// The list panel keeps page/search/source in the URL via nuqs, which reads
// window.location. jsdom shares that across tests in a file, so reset it.
function resetUrl(): void {
window.history.pushState(null, '', '/');
}
// The row kebab is a DropdownMenuSimple trigger; its testId isn't forwarded, so
// select it as the row's only button and open the Edit/Delete menu.
async function openRowMenu(
user: ReturnType<typeof userEvent.setup>,
ruleId: string,
): Promise<void> {
const row = screen.getByTestId(`model-cell-name-${ruleId}`).closest('tr');
await user.click(within(row as HTMLElement).getByRole('button'));
}
describe('ModelCostTabPanel (integration)', () => {
beforeEach(() => {
resetUrl();
});
afterEach(() => {
server.resetHandlers();
});
it('renders pricing rules returned by the list API', async () => {
setupList();
render(<ModelCostTabPanel />);
const openaiCell = await screen.findByTestId('model-cell-name-rule-openai');
expect(openaiCell).toHaveTextContent('gpt-4o');
expect(
screen.getByTestId('model-cell-name-rule-anthropic'),
).toHaveTextContent('claude-3-5-sonnet');
// Canonical id under the model name + provider column.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
// Source badges reflect the override flag.
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
'User override',
);
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
'Auto',
);
});
it('shows the empty state when there are no rules', async () => {
setupList([], 0);
render(<ModelCostTabPanel />);
const empty = await screen.findByTestId('model-costs-empty');
expect(empty).toHaveTextContent('No model costs yet.');
});
it('shows an error message when the list request fails', async () => {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<ModelCostTabPanel />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(
'Failed to load pricing rules. Please try again.',
);
});
it('sends the debounced search term as the q param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let lastParams: URLSearchParams | null = null;
server.use(
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
lastParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.type(
screen.getByPlaceholderText('Search by model or provider'),
'claude',
);
await waitFor(() => expect(lastParams?.get('q')).toBe('claude'));
});
it('clears the search via the clear button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
const input = screen.getByPlaceholderText(
'Search by model or provider',
) as HTMLInputElement;
await user.type(input, 'gpt');
expect(input.value).toBe('gpt');
await user.click(screen.getByTestId('model-cost-search-clear'));
await waitFor(() => expect(input.value).toBe(''));
});
it('sends isOverride=true when the source filter is set to User override', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let lastParams: URLSearchParams | null = null;
server.use(
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
lastParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.click(screen.getByTestId('source-filter'));
// Scope to the listbox option — "User override" also appears as a row badge.
await user.click(
await screen.findByRole('option', { name: 'User override' }),
);
await waitFor(() => expect(lastParams?.get('isOverride')).toBe('true'));
});
it('opens the add drawer for a manager (ADMIN)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.click(screen.getByTestId('add-model-cost-btn'));
const modelInput = await screen.findByTestId('drawer-model-id-input');
expect(modelInput).toBeInTheDocument();
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
});
it('hides the add button and row actions for a viewer', async () => {
setupList();
render(<ModelCostTabPanel />, undefined, { role: 'VIEWER' });
const row = (
await screen.findByTestId('model-cell-name-rule-openai')
).closest('tr') as HTMLElement;
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
// View-only rows render no action menu (no buttons in the row).
expect(within(row).queryByRole('button')).not.toBeInTheDocument();
});
it('opens the edit drawer prefilled from the row action menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await openRowMenu(user, 'rule-openai');
await user.click(await screen.findByText('Edit'));
const drawerTitle = await screen.findByText('Edit model cost');
expect(drawerTitle).toBeInTheDocument();
const modelInput = screen.getByTestId(
'drawer-model-id-input',
) as HTMLInputElement;
expect(modelInput.value).toBe('gpt-4o');
expect(modelInput).toBeDisabled();
});
it('deletes a rule through the confirm dialog', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let deletedId: string | null = null;
setupList();
server.use(
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
deletedId = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await openRowMenu(user, 'rule-openai');
await user.click(await screen.findByText('Delete'));
await user.click(await screen.findByTestId('drawer-delete-confirm-btn'));
await waitFor(() => expect(deletedId).toBe('rule-openai'));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith('Model cost deleted'),
);
});
it('renders cache buckets for rules that have cache pricing', async () => {
setupList();
render(<ModelCostTabPanel />);
const anthropicRow = (
await screen.findByTestId('model-cell-name-rule-anthropic')
).closest('tr') as HTMLElement;
expect(within(anthropicRow).getByText(/Cache Read/i)).toBeInTheDocument();
expect(within(anthropicRow).getByText(/Cache Write/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
import { render, screen, userEvent } from 'tests/test-utils';
import DeleteConfirmDialog from '../DeleteConfirmDialog';
describe('DeleteConfirmDialog', () => {
it('renders the model name in the confirmation copy', () => {
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting={false}
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>,
);
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
});
it('calls onConfirm when the confirm button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onConfirm = jest.fn();
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting={false}
onConfirm={onConfirm}
onCancel={jest.fn()}
/>,
);
await user.click(screen.getByTestId('drawer-delete-confirm-btn'));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('calls onCancel when the cancel button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onCancel = jest.fn();
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting={false}
onConfirm={jest.fn()}
onCancel={onCancel}
/>,
);
await user.click(screen.getByTestId('drawer-delete-cancel-btn'));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('disables the confirm button while deleting', () => {
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-delete-confirm-btn')).toBeDisabled();
});
it('calls onCancel when the dialog is dismissed via Escape', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onCancel = jest.fn();
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting={false}
onConfirm={jest.fn()}
onCancel={onCancel}
/>,
);
await user.keyboard('{Escape}');
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,173 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { makePricingRule } from '../../../../__tests__/fixtures';
import { EMPTY_DRAFT } from '../../../../constants';
import { draftFromRule } from '../../../../utils';
import ModelCostDrawer from '../ModelCostDrawer';
const editDraft = draftFromRule(
makePricingRule({
id: 'rule-openai',
modelName: 'gpt-4o',
provider: 'OpenAI',
}),
);
describe('ModelCostDrawer (integration)', () => {
it('renders the add title and a save button for a manager', () => {
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByText('Add model cost')).toBeInTheDocument();
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
});
it('disables save until the form is dirty', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByTestId('drawer-save-btn')).toBeDisabled();
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
await waitFor(() =>
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
);
});
it('shows the model id required error and does not call onSave when the name is empty', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={onSave}
isSaving={false}
saveError={null}
canManage
/>,
);
// Make the form dirty without touching the model id: add a pattern, which
// mutates the `patterns` form field while leaving the name empty.
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt');
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
await waitFor(() =>
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
);
await user.click(screen.getByTestId('drawer-save-btn'));
const error = await screen.findByText('Billing model ID is required.');
expect(error).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('calls onSave once on the happy path with valid model id and pricing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={onSave}
isSaving={false}
saveError={null}
canManage
/>,
);
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
await user.type(screen.getByTestId('drawer-input-cost'), '3');
await user.type(screen.getByTestId('drawer-output-cost'), '9');
await user.click(screen.getByTestId('drawer-save-btn'));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
});
it('renders the edit title with disabled, prefilled model id and disabled provider', () => {
render(
<ModelCostDrawer
isOpen
mode="edit"
initialDraft={editDraft}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByText('Edit model cost')).toBeInTheDocument();
const modelInput = screen.getByTestId(
'drawer-model-id-input',
) as HTMLInputElement;
expect(modelInput.value).toBe('gpt-4o');
expect(modelInput).toBeDisabled();
expect(screen.getByTestId('drawer-provider-select')).toBeDisabled();
});
it('renders a read-only view with a Close button and no save for a viewer', () => {
render(
<ModelCostDrawer
isOpen
mode="edit"
initialDraft={editDraft}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage={false}
/>,
);
expect(screen.getByText('View model cost')).toBeInTheDocument();
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
expect(screen.getByTestId('drawer-cancel-btn')).toHaveTextContent('Close');
});
it('renders the save error text', () => {
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError="boom"
canManage
/>,
);
expect(screen.getByText('boom')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,120 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import type { DrawerDraft } from '../../../../../../types';
import ExtraPricingBuckets from '../ExtraPricingBuckets';
type Pricing = DrawerDraft['pricing'];
function makePricing(overrides: Partial<Pricing> = {}): Pricing {
return {
input: 3,
output: 9,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
...overrides,
};
}
describe('ExtraPricingBuckets', () => {
it('shows only the add button when no bucket has a value', () => {
render(
<ExtraPricingBuckets
pricing={makePricing()}
isReadOnly={false}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-add-bucket-btn')).toBeInTheDocument();
expect(
screen.queryByTestId('drawer-cache-read-cost'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('drawer-cache-mode')).not.toBeInTheDocument();
});
it('opens the picker and adds a cache_read row with the cache mode select', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ExtraPricingBuckets
pricing={makePricing()}
isReadOnly={false}
onChange={jest.fn()}
/>,
);
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
expect(screen.getByTestId('drawer-bucket-picker')).toBeInTheDocument();
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
});
it('calls onChange with the cache_read value typed into the row', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<ExtraPricingBuckets
pricing={makePricing()}
isReadOnly={false}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
fireEvent.change(screen.getByTestId('drawer-cache-read-cost'), {
target: { value: '2' },
});
expect(onChange).toHaveBeenCalledWith({ cacheRead: 2 });
});
it('calls onChange with cacheRead null when the row is removed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<ExtraPricingBuckets
pricing={makePricing({ cacheRead: 2 })}
isReadOnly={false}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('drawer-remove-cache-read'));
expect(onChange).toHaveBeenCalledWith({ cacheRead: null });
});
it('renders the cache_read row on mount when pricing already has a value', () => {
render(
<ExtraPricingBuckets
pricing={makePricing({ cacheRead: 2 })}
isReadOnly={false}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
});
it('hides the add and remove buttons when read-only', () => {
render(
<ExtraPricingBuckets
pricing={makePricing({ cacheRead: 2 })}
isReadOnly
onChange={jest.fn()}
/>,
);
expect(screen.queryByTestId('drawer-add-bucket-btn')).not.toBeInTheDocument();
expect(
screen.queryByTestId('drawer-remove-cache-read'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,99 @@
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import PatternEditor from '../PatternEditor';
describe('PatternEditor', () => {
it('adds a typed pattern via the Add button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<PatternEditor
patterns={['gpt-4o']}
isReadOnly={false}
onChange={onChange}
/>,
);
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt-5');
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
expect(onChange).toHaveBeenCalledWith(['gpt-4o', 'gpt-5']);
});
it('adds a pattern when Enter is pressed', () => {
const onChange = jest.fn();
render(
<PatternEditor patterns={[]} isReadOnly={false} onChange={onChange} />,
);
const input = screen.getByTestId('drawer-pattern-input');
fireEvent.change(input, { target: { value: 'claude' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith(['claude']);
});
it('does not call onChange for a duplicate and clears the input', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<PatternEditor
patterns={['gpt-4o']}
isReadOnly={false}
onChange={onChange}
/>,
);
const input = screen.getByTestId('drawer-pattern-input') as HTMLInputElement;
await user.type(input, 'gpt-4o');
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
expect(onChange).not.toHaveBeenCalled();
expect(input.value).toBe('');
});
it('trims surrounding whitespace before adding', () => {
const onChange = jest.fn();
render(
<PatternEditor patterns={[]} isReadOnly={false} onChange={onChange} />,
);
const input = screen.getByTestId('drawer-pattern-input');
fireEvent.change(input, { target: { value: ' gemini ' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith(['gemini']);
});
it('removes a pattern when its chip remove button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<PatternEditor
patterns={['gpt-4o', 'gpt-5']}
isReadOnly={false}
onChange={onChange}
/>,
);
await user.click(
screen.getByRole('button', { name: 'Remove pattern gpt-4o' }),
);
expect(onChange).toHaveBeenCalledWith(['gpt-5']);
});
it('renders chips without remove buttons and no input when read-only', () => {
render(
<PatternEditor patterns={['gpt-4o']} isReadOnly onChange={jest.fn()} />,
);
expect(screen.queryByTestId('drawer-pattern-input')).not.toBeInTheDocument();
expect(
screen.queryByTestId('drawer-pattern-add-btn'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Remove pattern gpt-4o' }),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,85 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import { fireEvent, render, screen } from 'tests/test-utils';
import type { DrawerDraft } from '../../../../../../types';
import PricingFields from '../PricingFields';
type Pricing = DrawerDraft['pricing'];
function makePricing(overrides: Partial<Pricing> = {}): Pricing {
return {
input: null,
output: null,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
...overrides,
};
}
describe('PricingFields', () => {
it('calls onChange with the parsed input cost', () => {
const onChange = jest.fn();
render(
<PricingFields
pricing={makePricing()}
isReadOnly={false}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('drawer-input-cost'), {
target: { value: '5' },
});
expect(onChange).toHaveBeenCalledWith({ input: 5 });
});
it('calls onChange with the parsed output cost', () => {
const onChange = jest.fn();
render(
<PricingFields
pricing={makePricing()}
isReadOnly={false}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('drawer-output-cost'), {
target: { value: '12' },
});
expect(onChange).toHaveBeenCalledWith({ output: 12 });
});
it('calls onChange with null when the input is cleared', () => {
const onChange = jest.fn();
render(
<PricingFields
pricing={makePricing({ input: 5 })}
isReadOnly={false}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('drawer-input-cost'), {
target: { value: '' },
});
expect(onChange).toHaveBeenCalledWith({ input: null });
});
it('disables the inputs and shows the read-only label when read-only', () => {
render(
<PricingFields
pricing={makePricing({ input: 3, output: 9 })}
isReadOnly
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).toBeDisabled();
expect(screen.getByTestId('drawer-readonly-label')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,76 @@
import { render, screen, userEvent } from 'tests/test-utils';
import SourceSelector from '../SourceSelector';
describe('SourceSelector', () => {
it('calls onChange(true) when picking override while currently auto', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<SourceSelector isOverride={false} isReadOnly={false} onChange={onChange} />,
);
await user.click(screen.getByTestId('drawer-source-override'));
expect(onChange).toHaveBeenCalledWith(true);
});
it('shows the reset confirm UI without calling onChange when switching to auto from override', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(<SourceSelector isOverride isReadOnly={false} onChange={onChange} />);
await user.click(screen.getByTestId('drawer-source-auto'));
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('hides the confirm UI and does not call onChange when Keep is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(<SourceSelector isOverride isReadOnly={false} onChange={onChange} />);
await user.click(screen.getByTestId('drawer-source-auto'));
await user.click(screen.getByTestId('drawer-reset-keep-btn'));
expect(
screen.queryByTestId('drawer-reset-confirm-btn'),
).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('calls onChange(false) when Reset is confirmed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(<SourceSelector isOverride isReadOnly={false} onChange={onChange} />);
await user.click(screen.getByTestId('drawer-source-auto'));
await user.click(screen.getByTestId('drawer-reset-confirm-btn'));
expect(onChange).toHaveBeenCalledWith(false);
expect(
screen.queryByTestId('drawer-reset-confirm-btn'),
).not.toBeInTheDocument();
});
it('shows the managed label when read-only', () => {
render(<SourceSelector isOverride isReadOnly onChange={jest.fn()} />);
expect(screen.getByTestId('drawer-managed-label')).toBeInTheDocument();
});
it('disables the auto radio when disableAuto is set', () => {
render(
<SourceSelector
isOverride
isReadOnly={false}
disableAuto
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-source-auto')).toBeDisabled();
});
});

View File

@@ -0,0 +1,152 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { rest, server } from 'mocks-server/server';
import { EMPTY_DRAFT } from '../../../../../constants';
import {
LLM_PRICING_ENDPOINT,
makePricingRule,
} from '../../../../../__tests__/fixtures';
import { draftFromRule } from '../../../../../utils';
import { useModelCostDrawer } from '../useModelCostDrawer';
const toastSuccess = jest.fn();
const toastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): void => toastSuccess(...args),
error: (...args: unknown[]): void => toastError(...args),
},
}));
function createWrapper(): ({
children,
}: {
children: React.ReactNode;
}) => React.ReactElement {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
function renderUseModelCostDrawer(): ReturnType<
typeof renderHook<ReturnType<typeof useModelCostDrawer>, unknown>
> {
return renderHook(() => useModelCostDrawer(), { wrapper: createWrapper() });
}
describe('useModelCostDrawer', () => {
afterEach(() => {
server.resetHandlers();
});
it('starts closed in add mode with no selected rule', () => {
const { result } = renderUseModelCostDrawer();
expect(result.current.isOpen).toBe(false);
expect(result.current.mode).toBe('add');
expect(result.current.selectedRuleId).toBeNull();
});
it('openForAdd opens the drawer in add mode with the empty draft', () => {
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForAdd();
});
expect(result.current.isOpen).toBe(true);
expect(result.current.mode).toBe('add');
expect(result.current.selectedRuleId).toBeNull();
expect(result.current.initialDraft).toStrictEqual({
...EMPTY_DRAFT,
modelName: '',
patterns: [],
});
});
it('openForEdit opens the drawer in edit mode prefilled from the rule', () => {
const rule = makePricingRule({ id: 'rule-edit', modelName: 'gpt-4o' });
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForEdit(rule);
});
expect(result.current.isOpen).toBe(true);
expect(result.current.mode).toBe('edit');
expect(result.current.selectedRuleId).toBe('rule-edit');
expect(result.current.initialDraft).toStrictEqual(draftFromRule(rule));
});
it('close resets the open state and selection', () => {
const rule = makePricingRule({ id: 'rule-edit' });
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForEdit(rule);
});
act(() => {
result.current.close();
});
expect(result.current.isOpen).toBe(false);
expect(result.current.selectedRuleId).toBeNull();
expect(result.current.saveError).toBeNull();
});
it('save success closes the drawer and shows a success toast', async () => {
server.use(
rest.put(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForAdd();
});
const draft = { ...EMPTY_DRAFT, modelName: 'gpt-4o' };
await act(async () => {
await result.current.save(draft);
});
await waitFor(() => expect(result.current.isOpen).toBe(false));
expect(toastSuccess).toHaveBeenCalledWith('Model cost added');
expect(result.current.saveError).toBeNull();
});
it('save failure sets saveError and keeps the drawer open', async () => {
server.use(
rest.put(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForAdd();
});
const draft = { ...EMPTY_DRAFT, modelName: 'gpt-4o' };
await act(async () => {
await result.current.save(draft);
});
await waitFor(() => expect(result.current.saveError).not.toBeNull());
expect(result.current.isOpen).toBe(true);
expect(toastSuccess).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,7 @@
.modelCostsTable {
margin-top: var(--spacing-8);
--tanstack-table-row-height: 48px;
height: calc(100vh - 250px);
height: calc(100vh - 170px);
overflow-y: auto;
:global(table) tbody tr {

View File

@@ -0,0 +1,73 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { makePricingRule } from '../../../../__tests__/fixtures';
import ModelCostActionsMenu from '../ModelCostActionsMenu';
const rule = makePricingRule({ id: 'rule-openai', modelName: 'gpt-4o' });
describe('ModelCostActionsMenu', () => {
it('renders nothing when the user cannot manage', () => {
const { container } = render(
<ModelCostActionsMenu
rule={rule}
canManage={false}
onEdit={jest.fn()}
onDelete={jest.fn()}
/>,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('renders a trigger button when the user can manage', () => {
render(
<ModelCostActionsMenu
rule={rule}
canManage
onEdit={jest.fn()}
onDelete={jest.fn()}
/>,
);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('calls onEdit with the rule when clicking Edit', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onEdit = jest.fn();
render(
<ModelCostActionsMenu
rule={rule}
canManage
onEdit={onEdit}
onDelete={jest.fn()}
/>,
);
await user.click(screen.getByRole('button'));
await user.click(await screen.findByText('Edit'));
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledWith(rule);
});
it('calls onDelete with the rule when clicking Delete', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onDelete = jest.fn();
render(
<ModelCostActionsMenu
rule={rule}
canManage
onEdit={jest.fn()}
onDelete={onDelete}
/>,
);
await user.click(screen.getByRole('button'));
await user.click(await screen.findByText('Delete'));
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith(rule);
});
});

View File

@@ -0,0 +1,115 @@
import { render, screen, within } from 'tests/test-utils';
import { mockRules } from '../../../../__tests__/fixtures';
import ModelCostsTable from '../ModelCostsTable';
const noop = (): void => {};
// The table owns page/limit URL state via nuqs, which reads window.location.
// jsdom shares that across tests in a file, so reset it before each.
function resetUrl(): void {
window.history.pushState(null, '', '/');
}
function getRow(ruleId: string): HTMLElement {
return screen
.getByTestId(`model-cell-name-${ruleId}`)
.closest('tr') as HTMLElement;
}
describe('ModelCostsTable', () => {
beforeEach(() => {
resetUrl();
});
it('renders the empty state when not loading and there are no rules', () => {
render(
<ModelCostsTable
rules={[]}
isLoading={false}
total={0}
selectedRuleId={null}
canManage
onEdit={noop}
onDelete={noop}
/>,
);
const empty = screen.getByTestId('model-costs-empty');
expect(empty).toHaveTextContent('No model costs yet.');
});
it('does not show the empty state while loading even with no rules', () => {
render(
<ModelCostsTable
rules={[]}
isLoading
total={0}
selectedRuleId={null}
canManage
onEdit={noop}
onDelete={noop}
/>,
);
expect(screen.queryByTestId('model-costs-empty')).not.toBeInTheDocument();
expect(screen.getByTestId('model-costs-table')).toBeInTheDocument();
});
it('renders rows from the rules with formatted prices, provider, canonical id and source badges', () => {
render(
<ModelCostsTable
rules={mockRules}
isLoading={false}
total={mockRules.length}
selectedRuleId={null}
canManage
onEdit={noop}
onDelete={noop}
/>,
);
// Model names.
expect(screen.getByTestId('model-cell-name-rule-openai')).toHaveTextContent(
'gpt-4o',
);
expect(
screen.getByTestId('model-cell-name-rule-anthropic'),
).toHaveTextContent('claude-3-5-sonnet');
// Canonical id under the model name.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
// Provider column.
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
// Formatted input price ($3.00) for the openai row.
const openaiRow = getRow('rule-openai');
expect(within(openaiRow).getByText('$3.00')).toBeInTheDocument();
// Source label badges reflect the override flag.
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
'User override',
);
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
'Auto',
);
});
it('renders no row action button when the user cannot manage', () => {
render(
<ModelCostsTable
rules={mockRules}
isLoading={false}
total={mockRules.length}
selectedRuleId={null}
canManage={false}
onEdit={noop}
onDelete={noop}
/>,
);
const openaiRow = getRow('rule-openai');
expect(within(openaiRow).queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,123 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { rest, server } from 'mocks-server/server';
import { LLM_PRICING_RULE_ENDPOINT } from '../../../__tests__/fixtures';
import { useModelCostDelete } from '../useModelCostDelete';
const toastSuccess = jest.fn();
const toastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): void => toastSuccess(...args),
error: (...args: unknown[]): void => toastError(...args),
},
}));
function createWrapper(): ({
children,
}: {
children: React.ReactNode;
}) => React.ReactElement {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
function renderUseModelCostDelete(): ReturnType<
typeof renderHook<ReturnType<typeof useModelCostDelete>, unknown>
> {
return renderHook(() => useModelCostDelete(), { wrapper: createWrapper() });
}
const PENDING = { id: 'rule-openai', modelName: 'gpt-4o' };
describe('useModelCostDelete', () => {
afterEach(() => {
server.resetHandlers();
});
it('starts with no pending delete', () => {
const { result } = renderUseModelCostDelete();
expect(result.current.pendingDelete).toBeNull();
});
it('requestDelete queues the rule for deletion', () => {
const { result } = renderUseModelCostDelete();
act(() => {
result.current.requestDelete(PENDING);
});
expect(result.current.pendingDelete).toStrictEqual(PENDING);
});
it('cancelDelete clears the pending delete', () => {
const { result } = renderUseModelCostDelete();
act(() => {
result.current.requestDelete(PENDING);
});
act(() => {
result.current.cancelDelete();
});
expect(result.current.pendingDelete).toBeNull();
});
it('confirmDelete success fires the DELETE, clears state and toasts success', async () => {
let deletedId: string | null = null;
server.use(
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
deletedId = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const { result } = renderUseModelCostDelete();
act(() => {
result.current.requestDelete(PENDING);
});
await act(async () => {
await result.current.confirmDelete();
});
await waitFor(() => expect(deletedId).toBe('rule-openai'));
await waitFor(() => expect(result.current.pendingDelete).toBeNull());
expect(toastSuccess).toHaveBeenCalledWith('Model cost deleted');
});
it('confirmDelete failure keeps the pending delete and toasts an error', async () => {
server.use(
rest.delete(LLM_PRICING_RULE_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(500)),
),
);
const { result } = renderUseModelCostDelete();
act(() => {
result.current.requestDelete(PENDING);
});
await act(async () => {
await result.current.confirmDelete();
});
await waitFor(() => expect(toastError).toHaveBeenCalled());
expect(result.current.pendingDelete).toStrictEqual(PENDING);
expect(toastSuccess).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,59 @@
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
import { LLM_PRICING_ENDPOINT, makeListResponse, mockRules } from './fixtures';
function setupList(items = mockRules): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(items))),
),
);
}
describe('LLMObservabilityModelPricing', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupList();
});
afterEach(() => {
server.resetHandlers();
});
it('renders the model-pricing page', () => {
render(<LLMObservabilityModelPricing />);
expect(
screen.getByTestId('llm-observability-model-pricing-page'),
).toBeInTheDocument();
});
it('shows the model-costs and unpriced-models sub-tab labels', () => {
render(<LLMObservabilityModelPricing />);
expect(screen.getByRole('tab', { name: 'Model costs' })).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: /Unpriced models/ }),
).toBeInTheDocument();
});
it('activates the model-costs tab by default and renders its content', async () => {
render(<LLMObservabilityModelPricing />);
const modelCostsTab = screen.getByRole('tab', { name: 'Model costs' });
expect(modelCostsTab).toHaveAttribute('data-state', 'active');
const searchInput = await screen.findByPlaceholderText(
'Search by model or provider',
);
expect(searchInput).toBeInTheDocument();
});
it('disables the unpriced-models tab', () => {
render(<LLMObservabilityModelPricing />);
const unpricedTab = screen.getByRole('tab', { name: /Unpriced models/ });
expect(unpricedTab).toBeDisabled();
});
});

View File

@@ -0,0 +1,80 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type ListLLMPricingRules200,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } from '../types';
// Endpoint glob used by MSW handlers. The generated client hits a relative
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
// replaced wholesale when provided so callers can shape cache buckets freely.
export function makePricingRule(
overrides: Partial<PricingRule> = {},
): PricingRule {
const { pricing, ...rest } = overrides;
return {
id: 'rule-1',
enabled: true,
isOverride: true,
modelName: 'gpt-4o',
modelPattern: ['gpt-4o'],
orgId: 'org-1',
provider: 'OpenAI',
sourceId: 'source-1',
unit: UnitDTO.per_million_tokens,
createdAt: '2023-10-01T00:00:00.000Z',
updatedAt: '2023-10-10T00:00:00.000Z',
syncedAt: '2023-10-10T00:00:00.000Z',
pricing: {
input: 3,
output: 9,
...pricing,
},
...rest,
};
}
export const mockRules: PricingRule[] = [
makePricingRule({
id: 'rule-openai',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
pricing: { input: 3, output: 9 },
}),
makePricingRule({
id: 'rule-anthropic',
modelName: 'claude-3-5-sonnet',
provider: 'Anthropic',
isOverride: false,
pricing: {
input: 2,
output: 30,
cache: { mode: CacheModeDTO.additive, read: 3, write: 6 },
},
}),
];
// Wraps items in the list response envelope the list query reads
// (`data.data.items` / `data.data.total`).
export function makeListResponse(
items: PricingRule[],
total = items.length,
offset = 0,
limit = 20,
): ListLLMPricingRules200 {
return {
status: 'success',
data: {
items,
total,
offset,
limit,
},
};
}

View File

@@ -0,0 +1,293 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EMPTY_DRAFT } from '../constants';
import type { DrawerDraft } from '../types';
import {
buildPricingPayload,
buildRulePayload,
draftFromRule,
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
parsePricingAmount,
validateModelName,
validatePricing,
validateProvider,
} from '../utils';
import { makePricingRule } from './fixtures';
describe('parsePricingAmount', () => {
it('returns null for empty / whitespace-only input', () => {
expect(parsePricingAmount('')).toBeNull();
expect(parsePricingAmount(' ')).toBeNull();
});
it('parses numeric strings', () => {
expect(parsePricingAmount('3.5')).toBe(3.5);
expect(parsePricingAmount('0')).toBe(0);
expect(parsePricingAmount('-2')).toBe(-2);
});
it('returns 0 for non-numeric input', () => {
expect(parsePricingAmount('abc')).toBe(0);
});
});
describe('formatPricePerMillion', () => {
it('renders an em dash for missing values', () => {
expect(formatPricePerMillion(undefined)).toBe('—');
expect(formatPricePerMillion(null as unknown as undefined)).toBe('—');
});
it('formats numbers to 2dp with a dollar sign', () => {
expect(formatPricePerMillion(3)).toBe('$3.00');
expect(formatPricePerMillion(0)).toBe('$0.00');
expect(formatPricePerMillion(1.2345)).toBe('$1.23');
});
});
describe('getExtraBuckets', () => {
it('returns no buckets when there is no cache pricing', () => {
expect(
getExtraBuckets(makePricingRule({ pricing: { input: 1, output: 2 } })),
).toStrictEqual([]);
});
it('includes only buckets with a positive value', () => {
const rule = makePricingRule({
pricing: {
input: 1,
output: 2,
cache: { mode: CacheModeDTO.subtract, read: 5, write: 0 },
},
});
expect(getExtraBuckets(rule)).toStrictEqual([
{ key: 'cache_read', pricePerMillion: 5 },
]);
});
it('includes both read and write when both are positive', () => {
const rule = makePricingRule({
pricing: {
input: 1,
output: 2,
cache: { mode: CacheModeDTO.additive, read: 5, write: 7 },
},
});
expect(getExtraBuckets(rule)).toStrictEqual([
{ key: 'cache_read', pricePerMillion: 5 },
{ key: 'cache_write', pricePerMillion: 7 },
]);
});
});
describe('getSourceLabel', () => {
it('maps the override flag to a label', () => {
expect(getSourceLabel(makePricingRule({ isOverride: true }))).toBe(
'User override',
);
expect(getSourceLabel(makePricingRule({ isOverride: false }))).toBe('Auto');
});
});
describe('getCanonicalId', () => {
it('lowercases and trims provider:model', () => {
expect(
getCanonicalId(
makePricingRule({ provider: ' OpenAI ', modelName: ' GPT-4o ' }),
),
).toBe('openai:gpt-4o');
});
it('falls back to "unknown" for missing segments', () => {
expect(
getCanonicalId(
makePricingRule({
provider: '' as unknown as string,
modelName: '' as unknown as string,
}),
),
).toBe('unknown:unknown');
});
});
describe('getRelativeLastSeen', () => {
it('returns an em dash when no timestamps are present', () => {
const rule = makePricingRule({
updatedAt: undefined,
syncedAt: null,
createdAt: undefined,
});
expect(getRelativeLastSeen(rule)).toBe('—');
});
it('returns a relative string for a valid timestamp', () => {
const rule = makePricingRule({ updatedAt: '2023-10-10T00:00:00.000Z' });
expect(getRelativeLastSeen(rule)).not.toBe('—');
expect(typeof getRelativeLastSeen(rule)).toBe('string');
});
});
describe('draftFromRule', () => {
it('maps a rule to a drawer draft with cache defaults', () => {
const rule = makePricingRule({
id: 'r1',
sourceId: 's1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o', 'gpt-4'],
isOverride: true,
pricing: {
input: 3,
output: 9,
cache: { mode: CacheModeDTO.subtract, read: 1, write: 2 },
},
});
expect(draftFromRule(rule)).toStrictEqual({
id: 'r1',
sourceId: 's1',
modelName: 'gpt-4o',
provider: 'OpenAI',
patterns: ['gpt-4o', 'gpt-4'],
isOverride: true,
pricing: {
input: 3,
output: 9,
cacheMode: CacheModeDTO.subtract,
cacheRead: 1,
cacheWrite: 2,
},
});
});
it('defaults cache mode/values when cache is absent', () => {
const draft = draftFromRule(
makePricingRule({ modelPattern: null, pricing: { input: 1, output: 2 } }),
);
expect(draft.patterns).toStrictEqual([]);
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
expect(draft.pricing.cacheRead).toBeNull();
expect(draft.pricing.cacheWrite).toBeNull();
});
});
describe('buildPricingPayload', () => {
it('omits cache when neither bucket has a value', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
pricing: { ...EMPTY_DRAFT.pricing, input: 3, output: 9 },
};
expect(buildPricingPayload(draft)).toStrictEqual({ input: 3, output: 9 });
});
it('includes only the cache buckets that have a value', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
pricing: {
input: 3,
output: 9,
cacheMode: CacheModeDTO.additive,
cacheRead: 1,
cacheWrite: null,
},
};
expect(buildPricingPayload(draft)).toStrictEqual({
input: 3,
output: 9,
cache: { mode: CacheModeDTO.additive, read: 1 },
});
});
});
describe('buildRulePayload', () => {
it('trims names, sets defaults and patterns', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
id: 'r1',
sourceId: 's1',
modelName: ' gpt-4o ',
provider: ' OpenAI ',
patterns: ['gpt-4o'],
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, input: 3, output: 9 },
};
expect(buildRulePayload(draft)).toStrictEqual({
id: 'r1',
sourceId: 's1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: true,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 3, output: 9 },
});
});
it('drops empty id/sourceId to undefined', () => {
const payload = buildRulePayload({
...EMPTY_DRAFT,
modelName: 'm',
provider: 'p',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1 },
});
expect(payload.id).toBeUndefined();
expect(payload.sourceId).toBeUndefined();
});
});
describe('validateModelName', () => {
it('requires a name only in add mode', () => {
expect(validateModelName('', 'add')).toBe('Billing model ID is required.');
expect(validateModelName(' ', 'add')).toBe('Billing model ID is required.');
expect(validateModelName('gpt-4o', 'add')).toBe(true);
expect(validateModelName('', 'edit')).toBe(true);
});
});
describe('validateProvider', () => {
it('requires a non-empty provider', () => {
expect(validateProvider('')).toBe('Provider is required.');
expect(validateProvider(' ')).toBe('Provider is required.');
expect(validateProvider('OpenAI')).toBe(true);
});
});
describe('validatePricing', () => {
const base = EMPTY_DRAFT.pricing;
it('skips validation when not an override', () => {
expect(validatePricing({ ...base, input: null, output: null }, false)).toBe(
true,
);
});
it('requires positive input and output when override', () => {
expect(validatePricing({ ...base, input: 0, output: 9 }, true)).toBe(
'Input cost must be greater than 0.',
);
expect(validatePricing({ ...base, input: 3, output: 0 }, true)).toBe(
'Output cost must be greater than 0.',
);
});
it('rejects negative cache values', () => {
expect(
validatePricing({ ...base, input: 3, output: 9, cacheRead: -1 }, true),
).toBe('Cache costs must be non-negative.');
});
it('passes for valid override pricing', () => {
expect(
validatePricing(
{ ...base, input: 3, output: 9, cacheRead: 1, cacheWrite: 2 },
true,
),
).toBe(true);
});
});

View File

@@ -0,0 +1,95 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import ROUTES from 'constants/routes';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
LLM_PRICING_ENDPOINT,
makeListResponse,
mockRules,
} from '../Settings/ModelPricing/__tests__/fixtures';
import LLMObservability from '../LLMObservability';
function setupList(items = mockRules): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(items))),
),
);
}
describe('LLMObservability (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
});
afterEach(() => {
server.resetHandlers();
});
it('renders the overview panel and the tab bar on the overview route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
});
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
expect(screen.getByText('LLM Observability')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: 'Model pricing' }),
).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: 'Attribute Mapping' }),
).toBeInTheDocument();
});
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
);
});
it('navigates to the attribute mapping route when that tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
);
});
it('renders the attribute mapping page on the attribute mapping route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
});
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('renders the model-pricing page on the configuration route', async () => {
setupList();
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
});
await waitFor(() =>
expect(
screen.getByTestId('llm-observability-model-pricing-page'),
).toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,67 @@
import { MemoryRouter } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import ROUTES from 'constants/routes';
import { useLLMObservabilityTabs } from '../useLLMObservabilityTabs';
function renderTabsAt(
route: string,
): ReturnType<
typeof renderHook<ReturnType<typeof useLLMObservabilityTabs>, unknown>
> {
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): React.ReactElement => (
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
);
return renderHook(() => useLLMObservabilityTabs(), { wrapper });
}
describe('useLLMObservabilityTabs', () => {
it('marks the overview tab active on the overview route', () => {
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_OVERVIEW);
expect(result.current.activeTab).toBe(ROUTES.LLM_OBSERVABILITY_OVERVIEW);
});
it('marks the configuration tab active on the configuration route', () => {
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_CONFIGURATION);
expect(result.current.activeTab).toBe(ROUTES.LLM_OBSERVABILITY_CONFIGURATION);
});
it('marks the attribute mapping tab active on the attribute mapping route', () => {
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING);
expect(result.current.activeTab).toBe(
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
);
});
it('exposes all route-keyed tab items', () => {
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_OVERVIEW);
expect(result.current.items).toHaveLength(3);
const keys = result.current.items.map((item) => item.key);
expect(keys).toStrictEqual([
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
]);
});
it('navigates to the selected tab key on change', () => {
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_OVERVIEW);
act(() => {
result.current.onTabChange(ROUTES.LLM_OBSERVABILITY_CONFIGURATION);
});
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
);
});
});

View File

@@ -0,0 +1,62 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { type TabItemProps } from '@signozhq/ui/tabs';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabilityAttributeMapping';
import Overview from '../Overview/Overview';
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
interface UseLLMObservabilityTabsResult {
items: TabItemProps[];
activeTab: string;
onTabChange: (key: string) => void;
}
// Drives the top-level LLM Observability tabs. Route-driven: the active tab is
// derived from the pathname (each tab owns a URL) and changing tabs navigates,
// so tabs stay shareable/back-button friendly while rendering with the SigNoz
// design-system Tabs.
export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
let activeTab: string = OVERVIEW_KEY;
if (pathname.startsWith(CONFIGURATION_KEY)) {
activeTab = CONFIGURATION_KEY;
} else if (pathname.startsWith(ATTRIBUTE_MAPPING_KEY)) {
activeTab = ATTRIBUTE_MAPPING_KEY;
}
const onTabChange = useCallback(
(key: string): void => {
safeNavigate(key);
},
[safeNavigate],
);
const items: TabItemProps[] = [
{
key: OVERVIEW_KEY,
label: 'Overview',
children: <Overview />,
},
{
key: CONFIGURATION_KEY,
label: 'Model pricing',
children: <LLMObservabilityModelPricing />,
},
{
key: ATTRIBUTE_MAPPING_KEY,
label: 'Attribute Mapping',
children: <LLMObservabilityAttributeMapping />,
},
];
return { items, activeTab, onTabChange };
}

View File

@@ -1625,9 +1625,6 @@ export const getHostQueryPayload = (
const diskPendingKey = dotMetricsEnabled
? 'system.disk.pending_operations'
: 'system_disk_pending_operations';
const fsUsageKey = dotMetricsEnabled
? 'system.filesystem.usage'
: 'system_filesystem_usage';
return [
{
@@ -2660,155 +2657,6 @@ export const getHostQueryPayload = (
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_filesystem_usage--float64--Gauge--true',
key: fsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'A',
filters: {
items: [
{
id: 'fs_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
{
id: 'fs_f2',
key: {
dataType: DataTypes.String,
id: 'state--string--tag--false',
key: 'state',
type: 'tag',
},
op: '=',
value: 'used',
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'mountpoint--string--tag--false',
key: 'mountpoint',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_filesystem_usage--float64--Gauge--true',
key: fsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'B',
filters: {
items: [
{
id: 'fs_f3',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'mountpoint--string--tag--false',
key: 'mountpoint',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [
{
disabled: false,
expression: 'A/B',
legend: '{{mountpoint}}',
queryName: 'F1',
},
],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};
@@ -2884,5 +2732,4 @@ export const hostWidgetInfo = [
{ title: 'System disk operations/s', yAxisUnit: 'short' },
{ title: 'Queue size', yAxisUnit: 'short' },
{ title: 'System disk operation time/s', yAxisUnit: 's' },
{ title: 'Disk Usage (%) by mountpoint', yAxisUnit: 'percentunit' },
];

View File

@@ -20,7 +20,6 @@ import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
import basetenUrl from '@/assets/Logos/baseten.svg';
import cassandraUrl from '@/assets/Logos/cassandra.svg';
import celeryUrl from '@/assets/Logos/celery.svg';
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
@@ -52,7 +51,6 @@ import externalApiMonitoringUrl from '@/assets/Logos/external-api-monitoring.svg
import fluentbitUrl from '@/assets/Logos/fluentbit.svg';
import fluentdUrl from '@/assets/Logos/fluentd.svg';
import flutterMonitoringUrl from '@/assets/Logos/flutter-monitoring.svg';
import fluxcdUrl from '@/assets/Logos/fluxcd.svg';
import flyIoUrl from '@/assets/Logos/fly-io.svg';
import fromLogFileUrl from '@/assets/Logos/from-log-file.svg';
import gcpAppEngineUrl from '@/assets/Logos/gcp-app-engine.svg';
@@ -96,7 +94,6 @@ import langtraceUrl from '@/assets/Logos/langtrace.svg';
import litellmUrl from '@/assets/Logos/litellm.svg';
import livekitUrl from '@/assets/Logos/livekit.svg';
import llamaindexUrl from '@/assets/Logos/llamaindex.svg';
import llmMonitoringUrl from '@/assets/Logos/llm-monitoring.svg';
import logrusUrl from '@/assets/Logos/logrus.svg';
import logsUrl from '@/assets/Logos/logs.svg';
import logstashUrl from '@/assets/Logos/logstash.svg';
@@ -123,7 +120,6 @@ import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
import phpUrl from '@/assets/Logos/php.svg';
import pinoUrl from '@/assets/Logos/pino.svg';
import pipecatUrl from '@/assets/Logos/pipecat.svg';
import planetscaleUrl from '@/assets/Logos/planetscale.svg';
import postgresqlUrl from '@/assets/Logos/postgresql.svg';
import prometheusUrl from '@/assets/Logos/prometheus.svg';
import pydanticAiUrl from '@/assets/Logos/pydantic-ai.svg';
@@ -1530,28 +1526,6 @@ const onboardingConfigWithLinks = [
id: 'nginx-tracing',
link: '/docs/instrumentation/opentelemetry-nginx/',
},
{
dataSource: 'nginx-ingress-controller',
label: 'NGINX Ingress Controller',
imgUrl: nginxUrl,
tags: ['infrastructure monitoring'],
module: 'metrics',
relatedSearchKeywords: [
'ingress',
'ingress controller',
'kubernetes ingress',
'monitoring',
'nginx ingress',
'nginx ingress controller',
'nginx ingress metrics',
'nginx ingress monitoring',
'nginx ingress observability',
'observability',
'opentelemetry nginx ingress',
],
id: 'nginx-ingress-controller',
link: '/docs/metrics-management/nginx-ingress-controller/',
},
{
dataSource: 'opentelemetry-cloudflare',
label: 'Cloudflare Tracing',
@@ -1612,119 +1586,6 @@ const onboardingConfigWithLinks = [
id: 'opentelemetry-cloudflare-logs',
link: '/docs/logs-management/send-logs/cloudflare-logs/',
},
{
dataSource: 'cloudflare-workers',
label: 'Cloudflare Workers',
imgUrl: cloudflareUrl,
tags: ['apm/traces'],
module: 'apm',
relatedSearchKeywords: [
'cloudflare',
'cloudflare workers',
'cloudflare workers monitoring',
'cloudflare workers observability',
'cloudflare workers otlp',
'edge computing monitoring',
'monitor cloudflare workers',
'monitoring',
'observability',
'opentelemetry cloudflare workers',
'otlp',
'serverless monitoring',
],
id: 'cloudflare-workers',
link: '/docs/integrations/outposts/cloudflare-workers/',
},
{
dataSource: 'opentelemetry-cassandra',
label: 'Cassandra',
imgUrl: cassandraUrl,
tags: ['database'],
module: 'apm',
relatedSearchKeywords: [
'apache cassandra',
'cassandra',
'cassandra database',
'cassandra logs',
'cassandra metrics',
'cassandra monitoring',
'cassandra observability',
'database',
'monitoring',
'nosql',
'observability',
'opentelemetry cassandra',
],
id: 'opentelemetry-cassandra',
link: '/docs/integrations/opentelemetry-cassandra/',
},
{
dataSource: 'fluxcd',
label: 'FluxCD',
imgUrl: fluxcdUrl,
tags: ['infrastructure monitoring'],
module: 'metrics',
relatedSearchKeywords: [
'continuous delivery',
'flux',
'fluxcd',
'fluxcd dashboard',
'fluxcd metrics',
'fluxcd monitoring',
'fluxcd observability',
'gitops',
'kubernetes',
'monitoring',
'observability',
'opentelemetry fluxcd',
],
id: 'fluxcd',
link: '/docs/metrics-management/fluxcd-metrics/',
},
{
dataSource: 'planetscale',
label: 'PlanetScale',
imgUrl: planetscaleUrl,
tags: ['database'],
module: 'apm',
relatedSearchKeywords: [
'database',
'monitoring',
'mysql',
'observability',
'opentelemetry planetscale',
'planetscale',
'planetscale database',
'planetscale metrics',
'planetscale monitoring',
'planetscale observability',
'serverless database',
],
id: 'planetscale',
link: '/docs/metrics-management/opentelemetry-planetscale/',
},
{
dataSource: 'hermes-agent',
label: 'Hermes Agent',
imgUrl: llmMonitoringUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'ai agent monitoring',
'hermes',
'hermes agent',
'hermes agent monitoring',
'hermes agent observability',
'hermes monitoring',
'llm monitoring',
'monitoring',
'nous research',
'observability',
'opentelemetry hermes',
],
id: 'hermes-agent',
link: '/docs/hermes-monitoring/',
},
{
dataSource: 'convex-logs',
label: 'Convex Logs',

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