Compare commits

...

162 Commits

Author SHA1 Message Date
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
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
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
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
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
68 changed files with 3645 additions and 107 deletions

View File

@@ -62,6 +62,6 @@
"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"
}

View File

@@ -87,6 +87,6 @@
"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"
}

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

@@ -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

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

View File

@@ -0,0 +1,24 @@
import MapperGroupsTable from './components/MapperGroupsTable';
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
import styles from './AttributeMappingsTab.module.scss';
// "Attribute mappings" tab: the mapping-groups listing, its load/error states
// and footer summary. Lives in its own tab so siblings (e.g. "Test") can be
// added alongside without entangling this view's data fetching.
function AttributeMappingsTab(): JSX.Element {
const store = useAttributeMappingStore();
return (
<div data-testid="attribute-mappings-tab">
{store.isError && (
<div className={styles.pageError} role="alert">
Failed to load mapping groups. Please try again.
</div>
)}
<MapperGroupsTable store={store} />
</div>
);
}
export default AttributeMappingsTab;

View File

@@ -0,0 +1,75 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
makeMappersResponse,
mappersEndpoint,
mockGroups,
mockMappers,
} from '../../__tests__/fixtures';
import AttributeMappingsTab from '../AttributeMappingsTab';
function setupGroups(): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
}
describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
});
afterEach(() => {
server.resetHandlers();
});
it('renders no error banner on a successful load', async () => {
setupGroups();
render(<AttributeMappingsTab />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows an error banner when the groups request fails', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<AttributeMappingsTab />);
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
'Failed to load mapping groups. Please try again.',
);
});
it("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(<AttributeMappingsTab />);
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');
});
});

View File

@@ -0,0 +1,13 @@
.indexBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 var(--spacing-3);
border-radius: 999px;
background: var(--accent-primary);
color: var(--accent-primary-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}

View File

@@ -0,0 +1,12 @@
import styles from './IndexBadge.module.scss';
interface IndexBadgeProps {
index: number;
}
// Small positional badge mirroring the Pipelines list ordering chip.
function IndexBadge({ index }: IndexBadgeProps): JSX.Element {
return <span className={styles.indexBadge}>{index + 1}</span>;
}
export default IndexBadge;

View File

@@ -0,0 +1,17 @@
import { render, screen } from 'tests/test-utils';
import IndexBadge from '../IndexBadge';
describe('IndexBadge', () => {
it('renders a 1-based label for a 0-based index', () => {
render(<IndexBadge index={0} />);
expect(screen.getByText('1')).toBeInTheDocument();
});
it('renders the correct label for a later index', () => {
render(<IndexBadge index={4} />);
expect(screen.getByText('5')).toBeInTheDocument();
});
});

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react';
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;
}
// 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.
function MapperGroupsTable({ store }: MapperGroupsTableProps): JSX.Element {
const columns = useMemo(() => getMapperGroupsColumns(), []);
if (!store.isLoading && store.groups.length === 0) {
return (
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
No mapping groups yet.
</div>
);
}
return (
<TanStackTable<DraftGroup>
className={styles.groupsTableWrapper}
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} />}
disableVirtualScroll
testId="mapper-groups-table"
/>
);
}
export default MapperGroupsTable;

View File

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

View File

@@ -0,0 +1,97 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { TableColumnDef } from 'components/TanStackTableView';
import type { DraftGroup } from '../../../../types';
import { conditionFiltersFromGroup } from '../../../../utils';
import styles from './tableConfig.module.scss';
// 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(): 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: 'status',
header: 'Status',
width: { min: 120, ignoreLastColumnFill: true },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge color={row.enabled ? 'forest' : 'vanilla'} variant="outline">
{row.enabled ? 'Enabled' : 'Disabled'}
</Badge>
),
},
];
}

View File

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

View File

@@ -0,0 +1,98 @@
import { render, screen, userEvent } 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: [], isLoading: false, isError: false, ...overrides };
}
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', () => {
render(<MapperGroupsTable store={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', () => {
render(
<MapperGroupsTable store={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'],
},
}),
[],
);
render(<MapperGroupsTable store={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.getByText('Enabled')).toBeInTheDocument();
});
it('shows a disabled badge for a disabled group', () => {
const group = buildDraftGroup(
makeGroup({ id: 'group-2', enabled: false, condition: null }),
[],
);
render(<MapperGroupsTable store={storeWith({ groups: [group] })} />);
expect(screen.getByText('Disabled')).toBeInTheDocument();
});
it('shows the no-condition placeholder when a group has no attribute/resource keys', () => {
const group = buildDraftGroup(
makeGroup({ id: 'group-3', condition: null }),
[],
);
render(<MapperGroupsTable store={storeWith({ groups: [group] })} />);
expect(screen.getByTestId('group-filters-group-3')).toHaveTextContent(
'No condition · always runs',
);
});
it('toggles the expand button label when a row is expanded and collapsed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const group = buildDraftGroup(makeGroup({ id: 'group-1' }), []);
render(<MapperGroupsTable store={storeWith({ groups: [group] })} />);
const expandButton = screen.getByTestId('group-expand-group-1');
expect(expandButton).toHaveAccessibleName('Expand group');
await user.click(expandButton);
expect(expandButton).toHaveAccessibleName('Collapse group');
await user.click(expandButton);
expect(expandButton).toHaveAccessibleName('Expand group');
});
});

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import { useMemo } from 'react';
import type { SpantypesSpanMapperDTO } from 'api/generated/services/sigNoz.schemas';
import { useListSpanMappers } from 'api/generated/services/spanmapper';
import TanStackTable from 'components/TanStackTableView';
import { motion, useReducedMotion } from 'motion/react';
import { DraftGroup, DraftMapper } from '../../../types';
import { buildDraftMapper } from '../../../utils';
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;
}
// 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 and are then
// cached by react-query. New (unsaved) groups have no serverId, so skip.
function MappersTable({ group }: MappersTableProps): JSX.Element {
const prefersReducedMotion = useReducedMotion();
const { data, isLoading, isError } = useListSpanMappers(
{ groupId: group.serverId ?? '' },
{ query: { enabled: group.serverId !== null } },
);
const mappers = useMemo<DraftMapper[]>(() => {
const items = (data?.data?.items ??
[]) as unknown as SpantypesSpanMapperDTO[];
return items.map(buildDraftMapper);
}, [data]);
const columns = useMemo(() => getMappersColumns(), []);
let content: JSX.Element;
if (!isLoading && isError) {
content = (
<div
className={styles.tableEmpty}
data-testid={`mappers-error-${group.localId}`}
>
Failed to load mappings. Please try again.
</div>
);
} else if (!isLoading && 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={mappers}
columns={columns}
isLoading={isLoading}
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}
</motion.div>
);
}
export default MappersTable;

View File

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

View File

@@ -0,0 +1,108 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import cx from 'classnames';
import { DraftMapper } from '../../../../types';
import styles from './tableConfig.module.scss';
const MAX_VISIBLE_SOURCES = 3;
// Column definitions for the per-group mappers TanStackTable (rendered inside an
// expanded group row). Sorting is off — priority order is positional (top wins)
// and surfaced by the leading index column.
export function getMappersColumns(): 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: 'status',
header: 'Status',
// Opt the trailing column out of the "last column fills 100%" rule so the
// spare width flows into Target / Sources instead of leaving a large empty
// Status column on the right.
width: { min: 120, ignoreLastColumnFill: true },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge color={row.enabled ? 'forest' : 'vanilla'} variant="outline">
{row.enabled ? 'Enabled' : 'Disabled'}
</Badge>
),
},
];
}

View File

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

View File

@@ -0,0 +1,119 @@
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 {
makeGroup,
makeMapper,
makeMappersResponse,
mappersEndpoint,
} from '../../../../__tests__/fixtures';
import { buildDraftGroup } from '../../../../utils';
import MappersTable from '../MappersTable';
const GROUP = buildDraftGroup(makeGroup({ id: 'group-1' }), []);
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={GROUP} />);
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={GROUP} />);
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 = { ...GROUP, serverId: null };
render(<MappersTable group={draftGroup} />);
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={GROUP} />);
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.getByText('Enabled')).toBeInTheDocument();
});
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={GROUP} />);
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={GROUP} />);
await waitFor(() =>
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
);
});
});

View File

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

View File

@@ -0,0 +1,89 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { 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([]);
});
});

View File

@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
import { DraftGroup } from '../../types';
import { buildDraftGroup } from '../../utils';
export interface AttributeMappingStore {
groups: DraftGroup[];
isLoading: boolean;
isError: boolean;
}
// Read-only store for the listing view: loads the server groups only. Each
// group's mappers are fetched lazily when its row is expanded (see
// MappersTable), so page load is a single request instead of an N+1 fan-out
// across every group. Editing (draft mutations, save/discard) is layered on in
// a later PR.
export function useAttributeMappingStore(): AttributeMappingStore {
const groupsQuery = useListSpanMapperGroups();
const groups = useMemo<DraftGroup[]>(() => {
const serverGroups: SpantypesSpanMapperGroupDTO[] =
groupsQuery.data?.data?.items ?? [];
// Mappers load lazily per group, so seed the tree with empty mappers.
return serverGroups.map((group) => buildDraftGroup(group, []));
}, [groupsQuery.data]);
return {
groups,
isLoading: groupsQuery.isLoading,
isError: groupsQuery.isError,
};
}

View File

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

View File

@@ -0,0 +1,6 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}

View File

@@ -0,0 +1,46 @@
import { Tabs } from '@signozhq/ui/tabs';
import AttributeMappingHeader from './components/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
function LLMObservabilityAttributeMapping(): JSX.Element {
const tabItems = [
{
key: 'attribute-mappings',
label: 'Attribute mappings',
children: <AttributeMappingsTab />,
},
{
key: 'test',
label: 'Test',
disabled: true,
disabledReason: 'Coming soon',
children: null,
},
];
return (
<div
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={noop}
onSave={noop}
/>
<Tabs
testId="attribute-mapping-tabs"
defaultValue="attribute-mappings"
items={tabItems}
/>
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -0,0 +1,67 @@
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
function setupGroups(): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
}
describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupGroups();
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page shell', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('shows the attribute-mappings and test sub-tab labels', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByRole('tab', { name: 'Attribute mappings' }),
).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
});
it('activates the attribute-mappings tab by default and renders its content', async () => {
render(<LLMObservabilityAttributeMapping />);
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute mappings',
});
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('disables the test tab', () => {
render(<LLMObservabilityAttributeMapping />);
expect(screen.getByRole('tab', { name: 'Test' })).toBeDisabled();
});
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,66 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
// 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,75 @@
import {
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
ConditionFilter,
DraftGroup,
DraftMapper,
SourceConfig,
} from './types';
// 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: SpantypesSpanMapperDTO,
): 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,
}));
}
// ---- working-copy (draft tree) helpers ----
export function buildDraftMapper(mapper: SpantypesSpanMapperDTO): DraftMapper {
return {
localId: mapper.id,
serverId: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
export function buildDraftGroup(
group: SpantypesSpanMapperGroupDTO,
mappers: SpantypesSpanMapperDTO[],
): 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),
};
}

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

@@ -203,8 +203,9 @@ export const routesToSkip = [
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_BASE,
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -1,7 +0,0 @@
import LLMObservabilityModelPricing from 'container/LLMObservability/Settings/ModelPricing/LLMObservabilityModelPricing';
function LLMObservabilityModelPricingPage(): JSX.Element {
return <LLMObservabilityModelPricing />;
}
export default LLMObservabilityModelPricingPage;

View File

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