Compare commits

..

5 Commits

Author SHA1 Message Date
Naman Verma
d104e150e8 Merge branch 'main' into nv/layout-validations 2026-07-01 12:50:02 +05:30
Naman Verma
c16c4ab863 test: add integratino test for layout validation 2026-07-01 12:49:27 +05:30
Naman Verma
3b73bbc46d chore: validate layout size and positioning in backend 2026-07-01 12:32:08 +05:30
Gaurav Tewari
9317a26337 feat(llm-pricing): search + source filter (4/5) (#11808)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

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

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

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

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

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

* refactor: form in edit / add modal

* chore: update color tokens

* fix: add error handling

* chore: update more self review changes

* chore: self review changes

* chore: self review changes

* fix: minor grammer thing

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* chore: remove comment

* fix: disable isDirty in case of llm pricing

* refactor: number

* feat: add search , dropdown and flag

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

* fix: add isFetchingFeatureFlags

* chore: update flag

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* chore: self review changes

* refactor: types and other things

* refactor: types and other things

* chore: add disable on source id

* empty commit

* chore: empty commit

* fix: add demo side nav on sidenav

* chore: remove demo side nav

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

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

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

* refactor: more changes

* refactor: styling and components

* refactor: styling and components

* chore: add a tooltip on hover

* feat: add delete confirm modal

* fix: update title

* refactor: css variables

* refactor: use signoz button and minor css update

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: minior issues

* fix: llm pricing listing

* refactor: remove extra classes

* refactor: side nav changes

* fix: update missing styles

* chore: update edit and delete options

* chore: remove extra comment

* chore: revert env changes

* chore: add enable check

* chore: remove divider

* refactor: use delete confirm dialog

* chore: remove scss file

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-01 06:31:20 +00:00
Gaurav Tewari
fde817d83c feat(llm-pricing): model cost add/edit drawer (3/5) (#11761)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

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

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

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

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

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

* refactor: form in edit / add modal

* chore: update color tokens

* fix: add error handling

* chore: update more self review changes

* chore: self review changes

* chore: self review changes

* fix: minor grammer thing

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* chore: remove comment

* fix: disable isDirty in case of llm pricing

* refactor: number

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* chore: self review changes

* refactor: types and other things

* refactor: types and other things

* chore: add disable on source id

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

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

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

* refactor: more changes

* refactor: styling and components

* refactor: styling and components

* chore: add a tooltip on hover

* feat: add delete confirm modal

* fix: update title

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: minior issues

* fix: llm pricing listing

* refactor: remove extra classes

* fix: update missing styles

* chore: update edit and delete options

* chore: remove extra comment

* chore: revert env changes

* chore: remove divider

* refactor: use delete confirm dialog

* chore: remove scss file

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-01 05:42:46 +00:00
41 changed files with 2170 additions and 187 deletions

View File

@@ -61,5 +61,7 @@
"ROLE_DETAILS": "SigNoz | Role Details",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}

View File

@@ -86,5 +86,7 @@
"ROLE_EDIT": "SigNoz | Edit Role",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}

View File

@@ -1,7 +1,28 @@
.filtersBar {
display: flex;
gap: var(--spacing-6);
align-items: center;
justify-content: space-between;
}
.filtersBarLeft {
display: flex;
gap: var(--spacing-6);
align-items: center;
}
.filtersBarSearch {
width: 280px;
}
.filtersBarSource {
width: 160px;
}
.pageError {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
color: var(--text-cherry-400);
background: color-mix(in srgb, var(--accent-cherry) 8%, transparent);
color: var(--accent-cherry);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,52 +1,164 @@
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';
import { useTableParams } from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebounce from 'hooks/useDebounce';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SEARCH_DEBOUNCE_MS,
SEARCH_KEY,
SOURCE_FILTER_OPTIONS,
SOURCE_FILTER_TO_IS_OVERRIDE,
SOURCE_KEY,
type SourceFilter,
} from '../constants';
import type { PricingRule } from '../types';
import DeleteConfirmDialog from './components/DeleteConfirmDialog';
import ModelCostDrawer, {
useModelCostDrawer,
} from './components/ModelCostDrawer';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { useModelCostDelete } from './hooks/useModelCostDelete';
import styles from './ModelCostTabPanel.module.scss';
// "Model costs" tab: the priced-model listing, search + source filter, the add/
// edit drawer, and pagination. Page and page size live in the URL (shareable/
// reload-safe) and are owned by TanStackTable via enableQueryParams — this tab
// reads them back through the same useTableParams hook so the two stay in lockstep.
function ModelCostTabPanel(): JSX.Element {
const { page, limit } = useTableParams(
const { page, limit, setPage } = useTableParams(
{ page: PAGE_KEY, limit: LIMIT_KEY },
{ page: 1, limit: PAGE_SIZE },
);
// Search + source filters are intentionally omitted for now — the list API
// doesn't honour them yet. They'll be reintroduced here once it does.
const [search, setSearch] = useQueryState(
SEARCH_KEY,
parseAsString.withDefault(''),
);
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const [source, setSource] = useQueryState(
SOURCE_KEY,
parseAsStringEnum<SourceFilter>(
SOURCE_FILTER_OPTIONS.map((option) => option.value),
).withDefault('all'),
);
const handleSearchChange = (
event: React.ChangeEvent<HTMLInputElement>,
): void => {
void setSearch(event.target.value || null);
setPage(1);
};
const clearSearch = (): void => {
void setSearch(null);
setPage(1);
};
const handleSourceChange = (value: string | string[]): void => {
void setSource(value as SourceFilter);
setPage(1);
};
const isOverride = SOURCE_FILTER_TO_IS_OVERRIDE[source];
const listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
...(debouncedSearch ? { q: debouncedSearch } : {}),
...(isOverride !== undefined ? { isOverride } : {}),
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const { data, isLoading, isError } = useListLLMPricingRules(listParams, {
query: {
enabled: search === debouncedSearch,
},
});
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const drawer = useModelCostDrawer();
const deletion = useModelCostDelete();
return (
<>
<div className={styles.filtersBar}>
<div className={styles.filtersBarLeft}>
<Input
className={styles.filtersBarSearch}
placeholder="Search by model or provider"
value={search}
onChange={handleSearchChange}
prefix={<Search size={14} />}
suffix={
search ? (
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<X size={14} />}
onClick={clearSearch}
aria-label="Clear search"
testId="model-cost-search-clear"
/>
) : undefined
}
testId="model-cost-search"
/>
<SelectSimple
className={styles.filtersBarSource}
items={SOURCE_FILTER_OPTIONS}
value={source}
onChange={handleSourceChange}
testId="source-filter"
/>
</div>
{canManagePricing && (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className={styles.pageError} role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
total={total}
selectedRuleId={null}
canManage={false}
onEdit={(): void => undefined}
onDelete={(): void => undefined}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
onDelete={deletion.requestDelete}
/>
<footer>
@@ -54,6 +166,29 @@ function ModelCostTabPanel(): JSX.Element {
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
initialDraft={drawer.initialDraft}
onClose={drawer.close}
onSave={drawer.save}
isSaving={drawer.isSaving}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
)}
{deletion.pendingDelete && (
<DeleteConfirmDialog
open
modelName={deletion.pendingDelete.modelName}
isDeleting={deletion.isDeleting}
onConfirm={deletion.confirmDelete}
onCancel={deletion.cancelDelete}
/>
)}
</>
);
}

View File

@@ -0,0 +1,64 @@
import { AlertDialog } from '@signozhq/ui/alert-dialog';
import { Button } from '@signozhq/ui/button';
import { Trash2, X } from '@signozhq/icons';
interface DeleteConfirmDialogProps {
open: boolean;
modelName: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Confirmation step before deleting a model cost — deletion is irreversible, so
// the destructive action is gated behind an explicit confirm. AlertDialog blocks
// outside-click dismissal and hides the close button to force an explicit choice.
function DeleteConfirmDialog({
open,
modelName,
isDeleting,
onConfirm,
onCancel,
}: DeleteConfirmDialogProps): JSX.Element {
return (
<AlertDialog
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="narrow"
title="Delete Model Cost Data "
titleIcon={<Trash2 size={16} />}
footer={
<>
<Button
variant="solid"
color="secondary"
onClick={onCancel}
prefix={<X size={12} />}
testId="drawer-delete-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={onConfirm}
prefix={<Trash2 size={12} />}
testId="drawer-delete-confirm-btn"
>
Delete
</Button>
</>
}
>
Are you sure you want to delete <strong>{modelName}</strong>? Once deleted,
this action cannot be undone.
</AlertDialog>
);
}
export default DeleteConfirmDialog;

View File

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

View File

@@ -0,0 +1,58 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.required {
composes: required from './shared.module.scss';
}
.modelCostDrawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
display: flex;
overflow-y: auto;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
padding: var(--spacing-10) var(--spacing-12);
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
p {
margin: var(--spacing-2) 0 0;
color: var(--l3-foreground);
font-size: 12px;
}
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
}

View File

@@ -0,0 +1,238 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Controller, useForm } from 'react-hook-form';
import PatternEditor from './components/PatternEditor';
import PricingFields from './components/PricingFields';
import SourceSelector from './components/SourceSelector';
import { PROVIDER_OPTIONS } from '../../../constants';
import styles from './ModelCostDrawer.module.scss';
import {
validateModelName,
validatePricing,
validateProvider,
} from '../../../utils';
import type { DrawerDraft, DrawerMode } from '../../../types';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
initialDraft: DrawerDraft;
onClose: () => void;
onSave: (draft: DrawerDraft) => void;
isSaving: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
initialDraft,
onClose,
onSave,
isSaving,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Default mode validates on submit, then re-validates on change — so we don't
// flag empty fields before the user has tried to save, but errors clear live
// once they start fixing them.
const {
control,
handleSubmit,
watch,
formState: { isDirty },
} = useForm<DrawerDraft>({
defaultValues: initialDraft,
});
const isOverride = watch('isOverride');
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !isOverride;
// Non-managers can only view (write APIs are Admin-only), so the drawer is a
// read-only "View" rather than "Edit"/"Add".
let drawerTitle = 'Add model cost';
if (!canManage) {
drawerTitle = 'View model cost';
} else if (mode === 'edit') {
drawerTitle = 'Edit model cost';
}
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<Button
variant="solid"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!isDirty}
loading={isSaving}
testId="drawer-save-btn"
>
Save
</Button>
)}
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className={styles.modelCostDrawer}
footer={footer}
title={drawerTitle}
drawerHeaderProps={{ className: styles.title }}
>
<div className={styles.drawerSection}>
<label htmlFor="billing-model-id">
Billing model ID{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Controller
name="modelName"
control={control}
rules={{
validate: (value): true | string => validateModelName(value, mode),
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
required
value={field.value}
disabled={mode === 'edit' || metadataReadOnly}
aria-invalid={!!fieldState.error}
onChange={(e): void => field.onChange(e.target.value)}
testId="drawer-model-id-input"
/>
{fieldState.error && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<label htmlFor="provider-select">Provider</label>
<Controller
name="provider"
control={control}
rules={{ validate: validateProvider }}
render={({ field, fieldState }): JSX.Element => (
<>
<SelectSimple
id="provider-select"
value={field.value}
onChange={(value): void => field.onChange(value as string)}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className={styles.fullWidth}
withPortal={false}
testId="drawer-provider-select"
/>
{fieldState.error && (
<Typography.Text size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<Controller
name="patterns"
control={control}
render={({ field }): JSX.Element => (
<PatternEditor
patterns={field.value}
isReadOnly={metadataReadOnly}
onChange={field.onChange}
/>
)}
/>
{/* Source is auto vs. override — a choice only a manager can make, so
there's nothing to show a read-only viewer. */}
{canManage && (
<Controller
name="isOverride"
control={control}
// Pricing requirements depend on this toggle, so re-validate pricing
// whenever the source changes (clears/sets the pricing error).
rules={{ deps: ['pricing'] }}
render={({ field }): JSX.Element => (
<SourceSelector
isOverride={field.value}
isReadOnly={metadataReadOnly}
disableAuto={mode === 'add' || !initialDraft.sourceId}
onChange={field.onChange}
/>
)}
/>
)}
<Controller
name="pricing"
control={control}
rules={{
validate: (value, values): true | string =>
validatePricing(value, values.isOverride),
}}
render={({ field, fieldState }): JSX.Element => (
<>
<PricingFields
pricing={field.value}
isReadOnly={pricingReadOnly}
onChange={(patch): void => field.onChange({ ...field.value, ...patch })}
/>
{fieldState.error && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
{saveError && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{saveError}
</Typography.Text>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

View File

@@ -0,0 +1,69 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.fullWidth {
width: 100%;
}
.pricingField {
composes: pricingField from '../../shared.module.scss';
}
.cacheModeField {
margin-top: var(--spacing-5);
}
.extraBucketsSection {
margin-top: var(--spacing-7);
gap: var(--spacing-5);
}
.extraBucketsSectionHead {
display: flex;
align-items: center;
justify-content: space-between;
}
.bucketRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
input {
flex: 1 auto auto;
min-width: 0;
}
}
.bucketRowName {
flex: 0 0 110px;
}
.bucketAddBtn {
width: 100%;
}
.bucketPicker {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}
.bucketPickerTitle {
font-size: var(--periscope-font-size-small);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.bucketPickerChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,179 @@
import { useState } 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, Trash2 } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { CACHE_BUCKETS, CACHE_MODE_OPTIONS } from '../../../../../constants';
import styles from './ExtraPricingBuckets.module.scss';
import { parsePricingAmount } from '../../../../../utils';
import type { CacheBucketKey, DrawerDraft } from '../../../../../types';
import { Tooltip } from 'antd';
type Pricing = DrawerDraft['pricing'];
interface ExtraPricingBucketsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function ExtraPricingBuckets({
pricing,
isReadOnly,
onChange,
}: ExtraPricingBucketsProps): JSX.Element {
const [isExtraPricingBucketOpen, setIsExtraPricingBucketOpen] =
useState<boolean>(false);
// Track which buckets are shown separately from their value, so a freshly
// added bucket can start blank (value null) instead of being seeded to 0.
// Seeded from buckets that already carry a value (edit mode).
const [addedKeys, setAddedKeys] = useState<Set<CacheBucketKey>>(
() =>
new Set(
CACHE_BUCKETS.filter((b) => pricing[b.key] !== null).map((b) => b.key),
),
);
const addedBuckets = CACHE_BUCKETS.filter((b) => addedKeys.has(b.key));
const availableBuckets = CACHE_BUCKETS.filter((b) => !addedKeys.has(b.key));
const patchBucket = (key: CacheBucketKey, value: number | null): void => {
const patch: Partial<Pricing> = { [key]: value };
onChange(patch);
};
const addBucket = (key: CacheBucketKey): void => {
// Leave the value null so the field renders blank until the user types.
setAddedKeys((prev) => new Set(prev).add(key));
// Close the picker once nothing is left to add.
if (availableBuckets.length <= 1) {
setIsExtraPricingBucketOpen(false);
}
};
const removeBucket = (key: CacheBucketKey): void => {
setAddedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
patchBucket(key, null);
};
return (
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
<div className={styles.extraBucketsSectionHead}>
<Typography.Text as="span" size="small" color="muted">
Extra pricing buckets
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
optional
</Typography.Text>
</div>
{addedBuckets.map((bucket) => (
<div className={styles.bucketRow} key={bucket.key}>
<Typography.Text as="span" className={styles.bucketRowName}>
{bucket.label}
</Typography.Text>
<Input
type="number"
min={0}
step={0.01}
value={pricing[bucket.key] ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
// Clearing the field is allowed — the row stays mounted because
// presence is tracked in `addedKeys`, not the value. Removal is
// explicit via the trash button.
patchBucket(bucket.key, parsePricingAmount(e.target.value))
}
testId={`drawer-${bucket.testId}-cost`}
/>
<Tooltip title="Pricing per 1M tokens" placement="left">
<Typography.Text size="xs" color="muted">
1M
</Typography.Text>
</Tooltip>
{!isReadOnly && (
<Button
size="icon"
variant="ghost"
color="destructive"
onClick={(): void => removeBucket(bucket.key)}
aria-label={`Remove ${bucket.label}`}
data-testid={`drawer-remove-${bucket.testId}`}
prefix={<Trash2 size={14} />}
/>
)}
</div>
))}
{addedBuckets.length > 0 && (
<div className={cx(styles.pricingField, styles.cacheModeField)}>
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}
items={CACHE_MODE_OPTIONS}
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className={styles.fullWidth}
withPortal={false}
testId="drawer-cache-mode"
/>
</div>
)}
{!isReadOnly && !isExtraPricingBucketOpen && availableBuckets.length > 0 && (
<Button
variant="dashed"
color="secondary"
className={styles.bucketAddBtn}
prefix={<Plus size={14} />}
onClick={(): void => setIsExtraPricingBucketOpen(true)}
testId="drawer-add-bucket-btn"
>
Add pricing bucket
</Button>
)}
{!isReadOnly && isExtraPricingBucketOpen && (
<div className={styles.bucketPicker} data-testid="drawer-bucket-picker">
<div className={styles.bucketPickerTitle}>Add a pricing bucket</div>
<div className={styles.bucketPickerChips}>
{availableBuckets.map((bucket) => (
<Button
key={bucket.key}
variant="outlined"
color="secondary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => addBucket(bucket.key)}
testId={`drawer-add-bucket-${bucket.testId}`}
>
{bucket.label}
</Button>
))}
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={(): void => setIsExtraPricingBucketOpen(false)}
testId="drawer-add-bucket-cancel"
>
Cancel
</Button>
</div>
)}
</div>
);
}
export default ExtraPricingBuckets;

View File

@@ -0,0 +1,49 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.help {
composes: help from '../../shared.module.scss';
}
.patternBox {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.patternChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
min-height: 28px;
}
.patternChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.patternChipRemove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--accent-cherry);
}
}
.patternAdd {
display: flex;
gap: var(--spacing-3);
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import styles from './PatternEditor.module.scss';
interface PatternEditorProps {
patterns: string[];
isReadOnly: boolean;
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
onChange,
}: PatternEditorProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || patterns.includes(next)) {
setPatternInput('');
return;
}
onChange([...patterns, next]);
setPatternInput('');
};
const removePattern = (pattern: string): void => {
onChange(patterns.filter((p) => p !== pattern));
};
return (
<div className={styles.drawerSection}>
<Typography.Text as="span">
Model name patterns{' '}
<Typography.Text as="span" color="muted">
(prefix match)
</Typography.Text>
</Typography.Text>
<div className={styles.patternBox}>
<div className={styles.patternChips}>
{patterns.map((pattern) => (
<Badge
key={pattern}
color="vanilla"
variant="outline"
className={styles.patternChip}
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className={styles.patternChipRemove}
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className={styles.patternAdd}>
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
addPattern();
}
}}
testId="drawer-pattern-input"
/>
<Button
variant="outlined"
color="secondary"
onClick={addPattern}
testId="drawer-pattern-add-btn"
>
+ Add
</Button>
</div>
)}
</div>
<Typography.Text as="p" size="small" color="muted">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
</Typography.Text>
</div>
);
}
export default PatternEditor;

View File

@@ -0,0 +1,31 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from '../../shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from '../../shared.module.scss';
}
.managedLabel {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.pricingField {
composes: pricingField from '../../shared.module.scss';
}
.required {
composes: required from '../../shared.module.scss';
}
.pricingGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
}

View File

@@ -0,0 +1,91 @@
import { Input } from '@signozhq/ui/input';
import { Lock } from '@signozhq/icons';
import cx from 'classnames';
import ExtraPricingBuckets from '../ExtraPricingBuckets';
import styles from './PricingFields.module.scss';
import { parsePricingAmount } from '../../../../../utils';
import type { DrawerDraft } from '../../../../../types';
import { Typography } from '@signozhq/ui/typography';
type Pricing = DrawerDraft['pricing'];
interface PricingFieldsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function PricingFields({
pricing,
isReadOnly,
onChange,
}: PricingFieldsProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text size="base" weight="bold">
Pricing (per 1M tokens, USD)
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
<Lock size={12} />
<Typography.Text color="muted">Read-only</Typography.Text>
</span>
)}
</div>
<div className={styles.pricingGrid}>
<div className={styles.pricingField}>
<label htmlFor="input-cost">
Input cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Input
id="input-cost"
type="number"
step={0.01}
required
value={pricing.input ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ input: parsePricingAmount(e.target.value) })
}
testId="drawer-input-cost"
/>
</div>
<div className={styles.pricingField}>
<label htmlFor="output-cost">
Output cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Input
id="output-cost"
type="number"
step={0.01}
required
value={pricing.output ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ output: parsePricingAmount(e.target.value) })
}
testId="drawer-output-cost"
/>
</div>
</div>
<ExtraPricingBuckets
pricing={pricing}
isReadOnly={isReadOnly}
onChange={onChange}
/>
</div>
);
}
export default PricingFields;

View File

@@ -0,0 +1,115 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from '../../shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from '../../shared.module.scss';
}
.managedLabel {
composes: managedLabel from '../../shared.module.scss';
}
.sourceRadioGroup {
--radio-group-item-border-color: var(--l2-border);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
.sourceRadio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid transparent;
background: var(--l3-background);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with the title
// baseline (margin-top compensates for align-items: flex-start vs the
// title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawerSection label typography leak
// (set earlier in this file) so the title/desc divs use their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.sourceRadioAuto:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&.sourceRadioOverride:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}
}
}
.sourceRadioTitle {
font-weight: var(--font-weight-semibold);
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.sourceRadioDesc {
margin-top: 2px;
font-size: 12px;
color: var(--l3-foreground);
}
.resetConfirm {
margin-top: var(--spacing-6);
padding: var(--spacing-6);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--accent-primary) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, transparent);
p {
margin: 0 0 var(--spacing-5);
font-size: 12px;
}
}
.resetConfirmActions {
display: flex;
gap: var(--spacing-4);
justify-content: flex-end;
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SourceSelector.module.scss';
interface SourceSelectorProps {
isOverride: boolean;
isReadOnly: boolean;
disableAuto?: boolean;
onChange: (isOverride: boolean) => void;
}
// Auto-populated vs user-override selector, with a confirm step before
// discarding custom values back to defaults.
function SourceSelector({
isOverride,
isReadOnly,
disableAuto = false,
onChange,
}: SourceSelectorProps): JSX.Element {
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !isOverride) {
onChange(true);
}
};
const confirmReset = (): void => {
onChange(false);
setShowResetConfirm(false);
};
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Source
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={isOverride ? 'override' : 'auto'}
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
className={styles.sourceRadioGroup}
>
<RadioGroupItem
value="auto"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
testId="drawer-source-auto"
disabled={disableAuto}
>
<div className={styles.sourceRadioTitle}>Auto-populated</div>
<div className={styles.sourceRadioDesc}>
{disableAuto
? 'Available once SigNoz has default pricing for this model.'
: 'Default pricing from SigNoz.'}
</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
testId="drawer-source-override"
>
<div className={styles.sourceRadioTitle}>User override</div>
<div className={styles.sourceRadioDesc}>
Custom pricing. Takes precedence.
</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div className={styles.resetConfirm} aria-label="Reset to default pricing">
<p>
Reset to default pricing? Custom values will be discarded. It might take
24 hours for changes to take effect.
</p>
<div className={styles.resetConfirmActions}>
<Button
variant="outlined"
color="secondary"
onClick={(): void => setShowResetConfirm(false)}
testId="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
variant="solid"
color="primary"
onClick={confirmReset}
testId="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
);
}
export default SourceSelector;

View File

@@ -0,0 +1,100 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import { EMPTY_DRAFT } from '../../../../constants';
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
import { buildRulePayload, draftFromRule } from '../../../../utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
initialDraft: DrawerDraft;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: (draft: DrawerDraft) => Promise<void>;
isSaving: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [initialDraft, setInitialDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((): void => {
setMode('add');
setInitialDraft({
...EMPTY_DRAFT,
modelName: '',
patterns: [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setInitialDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(
async (draft: DrawerDraft): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
},
[createOrUpdate, invalidateList, mode],
);
return {
isOpen,
mode,
initialDraft,
openForAdd,
openForEdit,
close,
save,
isSaving,
saveError,
selectedRuleId,
};
}

View File

@@ -0,0 +1,2 @@
export { default } from './ModelCostDrawer';
export { useModelCostDrawer } from './hooks/useModelCostDrawer';

View File

@@ -0,0 +1,59 @@
/* Shared drawer selectors used by 2+ of the model-cost drawer components. */
/* Components pull these in via CSS-modules `composes` from their own module so */
/* the authored class names in the TSX stay identical. */
/* NOTE: this file is a `composes` target, so it is parsed as plain CSS (no SCSS */
/* preprocessing). Keep it flat — no nesting, no slash-slash comments. */
.drawerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drawerSection .help,
.help {
margin: 0;
}
.help code {
padding: 1px var(--spacing-2);
border-radius: 3px;
background: var(--l3-background);
font-size: 10px;
}
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}
.drawerSurfaceHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.managedLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.required {
color: var(--accent-cherry);
}
.pricingField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.pricingField input {
width: 100%;
}

View File

@@ -15,6 +15,6 @@
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--text-vanilla-400);
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,66 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import type { PricingRule } from '../../types';
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
// and the model name to show in the confirmation copy.
type PendingDelete = Pick<PricingRule, 'id' | 'modelName'>;
interface UseModelCostDeleteResult {
requestDelete: (rule: PendingDelete) => void;
confirmDelete: () => Promise<void>;
cancelDelete: () => void;
pendingDelete: PendingDelete | null;
isDeleting: boolean;
}
// Owns the confirm-then-delete flow for a pricing rule, independent of the
// add/edit drawer — delete is triggered from the table row menu, so this state
// lives at the panel level rather than inside useModelCostDrawer.
export function useModelCostDelete(): UseModelCostDeleteResult {
const queryClient = useQueryClient();
// The rule queued for deletion. Non-null drives the confirm dialog open.
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const requestDelete = useCallback((rule: PendingDelete): void => {
setPendingDelete({ id: rule.id, modelName: rule.modelName });
}, []);
const cancelDelete = useCallback((): void => {
setPendingDelete(null);
}, []);
const confirmDelete = useCallback(async (): Promise<void> => {
if (!pendingDelete) {
return;
}
try {
await deleteRuleApi({ pathParams: { id: pendingDelete.id } });
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
setPendingDelete(null);
toast.success('Model cost deleted');
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
toast.error(message);
}
}, [deleteRuleApi, pendingDelete, queryClient]);
return {
requestDelete,
confirmDelete,
cancelDelete,
pendingDelete,
isDeleting,
};
}

View File

@@ -1,6 +1,68 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { CacheBucketDef, DrawerDraft } from './types';
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SEARCH_KEY = 'search';
export const SEARCH_DEBOUNCE_MS = 300;
export const SOURCE_KEY = 'source';
export type SourceFilter = 'all' | 'override' | 'auto';
export const SOURCE_FILTER_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'All sources' },
{ value: 'override', label: 'User override' },
{ value: 'auto', label: 'Auto' },
];
export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
SourceFilter,
boolean | undefined
> = {
all: undefined,
override: true,
auto: false,
};
// Match the page size so the skeleton reserves the same number of rows the
// loaded page renders — otherwise the table height jumps on load.
export const SKELETON_ROW_COUNT = PAGE_SIZE;
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
// https://app.notion.com/p/signoz/LLM-Tokens-Cost-Calculation-330fcc6bcd19805283ccc841d596358e?source=copy_link#33efcc6bcd1980e6a187e442c6ba5996
{ value: CacheModeDTO.unknown, label: 'Unknown' },
];
export const CACHE_BUCKETS: CacheBucketDef[] = [
{ key: 'cacheRead', label: 'cache_read', testId: 'cache-read' },
{ key: 'cacheWrite', label: 'cache_write', testId: 'cache-write' },
];
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: null,
output: null,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};

View File

@@ -1,4 +1,39 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export type DrawerMode = 'add' | 'edit';
// Optional pricing buckets the user can add/remove. Keyed by the matching
// DrawerDraft['pricing'] field.
export type CacheBucketKey = 'cacheRead' | 'cacheWrite';
export interface CacheBucketDef {
key: CacheBucketKey;
label: string;
testId: string;
}
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number | null;
output: number | null;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}

View File

@@ -1,8 +1,19 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DrawerDraft,
DrawerMode,
ExtraBucket,
PricingRule,
} from './types';
dayjs.extend(relativeTime);
@@ -13,6 +24,19 @@ const getRelativeTime = (
return parsed?.isValid() ? parsed.fromNow() : '—';
};
const hasCacheValue = (value: number | null | undefined): value is number =>
typeof value === 'number' && value > 0;
// ─── Input helpers ───────────────────────────────────────────────────────────
export const parsePricingAmount = (raw: string): number | null => {
if (raw.trim() === '') {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
@@ -23,38 +47,117 @@ export const formatPricePerMillion = (value: number | undefined): string => {
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
if (hasCacheValue(cache.read)) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
if (hasCacheValue(cache.write)) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
export const getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
export const getRelativeLastSeen = (rule: PricingRule): string =>
getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
// are lower-cased so the id is consistently normalised (providers/models can
// arrive with mixed casing).
export const getCanonicalId = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => {
export const getCanonicalId = (rule: PricingRule): string => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
const buildCacheCosts = (
pricing: DrawerDraft['pricing'],
): LlmpricingruletypesLLMPricingCacheCostsDTO | undefined => {
const { cacheMode, cacheRead, cacheWrite } = pricing;
if (!hasCacheValue(cacheRead) && !hasCacheValue(cacheWrite)) {
return undefined;
}
return {
mode: cacheMode,
...(hasCacheValue(cacheRead) && { read: cacheRead }),
...(hasCacheValue(cacheWrite) && { write: cacheWrite }),
};
};
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const cache = buildCacheCosts(draft.pricing);
return {
input: draft.pricing.input ?? 0,
output: draft.pricing.output ?? 0,
...(cache && { cache }),
};
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export const validateModelName = (
modelName: string,
mode: DrawerMode,
): true | string =>
mode === 'add' && !modelName.trim() ? 'Billing model ID is required.' : true;
export const validateProvider = (provider: string): true | string =>
provider.trim() ? true : 'Provider is required.';
export const validatePricing = (
pricing: DrawerDraft['pricing'],
isOverride: boolean,
): true | string => {
if (!isOverride) {
return true;
}
if (pricing.input === null || pricing.input <= 0) {
return 'Input cost must be greater than 0.';
}
if (pricing.output === null || pricing.output <= 0) {
return 'Output cost must be greater than 0.';
}
if ((pricing.cacheRead ?? 0) < 0 || (pricing.cacheWrite ?? 0) < 0) {
return 'Cache costs must be non-negative.';
}
return true;
};

View File

@@ -1,25 +0,0 @@
.container {
// Gutter matches the header/subHeader 16px; bottom gap before the panels.
padding: 0 16px 12px;
}
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
color: inherit;
font-weight: 500;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
}

View File

@@ -1,47 +0,0 @@
import { useState } from 'react';
import { Callout } from '@signozhq/ui/callout';
import { ArrowUpRight } from '@signozhq/icons';
import styles from './MissingSpansBanner.module.scss';
const MISSING_SPANS_DOCS_URL =
'https://signoz.io/docs/userguide/traces/#missing-spans';
function MissingSpansBanner(): JSX.Element | null {
// Session-only dismissal — not persisted, so the banner returns on reload.
const [isDismissed, setIsDismissed] = useState(false);
if (isDismissed) {
return null;
}
// Wrapper owns the gutter: Callout is width:100%, so putting the gutter as a
// margin on it would overflow the parent by the margin width. Pad instead.
return (
<div className={styles.container}>
<Callout
type="info"
size="small"
showIcon
action="dismissible"
onClick={(): void => setIsDismissed(true)}
testId="missing-spans-banner"
title={
<span className={styles.title}>
This trace has missing spans
<a
className={styles.link}
href={MISSING_SPANS_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
Learn More <ArrowUpRight size={14} />
</a>
</span>
}
/>
</div>
);
}
export default MissingSpansBanner;

View File

@@ -31,7 +31,6 @@ import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceStore } from '../stores/traceStore';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
@@ -49,7 +48,6 @@ export interface TraceMetadataForHeader {
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
}
interface TraceDetailsHeaderProps {
@@ -231,8 +229,6 @@ function TraceDetailsHeader({
</div>
)}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}
title="Preview fields"

View File

@@ -41,7 +41,6 @@
:global(.ant-collapse-header) {
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
border-radius: 0 !important;
}
:global(.ant-collapse-content) {
@@ -99,13 +98,6 @@
flex-direction: column;
overflow: hidden;
// The flamegraph's ResizableBox above renders a 1px resize handle at its
// bottom edge; drop the header's own top border so the two don't stack
// into a double border at the flamegraph/waterfall juncture.
:global(.ant-collapse-header) {
border-top: none;
}
:global(.ant-collapse-item) {
flex: 1;
display: flex;

View File

@@ -49,6 +49,59 @@
flex-direction: column;
}
.missingSpans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: rgba(69, 104, 220, 0.1);
}
.leftInfo {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.rightInfo {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
&:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.splitPanel {
flex: 1;
min-height: 0;
@@ -163,12 +216,10 @@
.treeRow:hover,
.treeRow.hoveredSpan {
// Left end of the row band — round only the outer (left) corners so the
// highlight joins the status + timeline segments into one continuous band.
border-radius: 4px 0 0 4px;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 60%,
var(--l3-background) 20%,
transparent
) !important;
@@ -211,22 +262,20 @@
--badge-border-width: 0px;
&.hoveredSpan {
// Middle segment of the row band — square so it butts up against the
// name and timeline segments (no rounded corner at the badge column).
border-radius: 0;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 60%,
var(--l3-background) 20%,
transparent
) !important;
}
&.isInterested,
&.isSelectedNonMatching {
border-radius: 0;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 80%,
var(--l3-background) 40%,
transparent
) !important;
}
@@ -260,21 +309,20 @@
&:hover,
&.hoveredSpan {
// Right end of the row band — round only the outer (right) corners.
border-radius: 0 4px 4px 0;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 60%,
var(--l3-background) 20%,
transparent
) !important;
}
&:has(.isInterested),
&:has(.isSelectedNonMatching) {
border-radius: 0 4px 4px 0;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 80%,
var(--l3-background) 40%,
transparent
) !important;
}
@@ -297,11 +345,10 @@
&.isInterested,
&.isSelectedNonMatching {
// Left end of the row band — outer (left) corners only.
border-radius: 4px 0 0 4px;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 80%,
var(--l3-background) 40%,
transparent
) !important;
}
@@ -424,7 +471,7 @@
padding-left: 8px;
flex-shrink: 0;
height: 100%;
background: linear-gradient(to left, var(--l2-background) 40%, transparent);
background: linear-gradient(to left, var(--l1-background) 60%, transparent);
z-index: 2;
opacity: 0;
pointer-events: none;
@@ -552,18 +599,6 @@
opacity: 0.15;
}
// A dimmed span must still show the full-opacity hover state when hovered.
// These win over `.isDimmed` on specificity so brightness is restored across
// the whole row (name column, status cell, and timeline bar) on hover.
.treeRow:hover .isDimmed,
.treeRow.hoveredSpan .isDimmed,
.timelineRow:hover .isDimmed,
.timelineRow.hoveredSpan .isDimmed,
.statusCell:hover.isDimmed,
.statusCell.hoveredSpan.isDimmed {
opacity: 1;
}
.isHighlighted {
opacity: 1;
}

View File

@@ -33,7 +33,14 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import {
ArrowUpRight,
ChevronDown,
ChevronRight,
CircleAlert,
Link,
ListPlus,
} from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -847,6 +854,28 @@ function Success(props: ISuccessProps): JSX.Element {
return (
<div className={styles.root}>
{traceMetadata.hasMissingSpans && (
<div className={styles.missingSpans}>
<section className={styles.leftInfo}>
<CircleAlert size={14} />
<span className={styles.text}>This trace has missing spans</span>
</section>
<Button
variant="ghost"
color="secondary"
className={styles.rightInfo}
suffix={<ArrowUpRight size={14} />}
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/userguide/traces/#missing-spans',
'_blank',
)
}
>
Learn More
</Button>
</div>
)}
{isFetching && <div className={styles.loadingBar} />}
<div className={styles.splitPanel} ref={scrollContainerRef}>
{/* Sticky header row */}
@@ -965,8 +994,8 @@ function Success(props: ISuccessProps): JSX.Element {
transform: `translateY(${virtualRow.start}px)`,
}}
data-span-id={span.span_id}
onMouseEnter={(): void => applyHoverClass(span.span_id)}
onMouseLeave={(): void => applyHoverClass(null)}
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
onMouseLeave={handleRowMouseLeave}
onClick={(): void => handleSpanClick(span)}
>
{span.response_status_code && (

View File

@@ -1,12 +1,7 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
TriangleAlert,
} from '@signozhq/icons';
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
@@ -39,16 +34,6 @@ import cx from 'classnames';
import styles from './TraceDetailsV3.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
@@ -344,7 +329,6 @@ function TraceDetailsV3(): JSX.Element {
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
};
}, [traceData?.payload]);
@@ -404,7 +388,6 @@ function TraceDetailsV3(): JSX.Element {
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
@@ -459,7 +442,6 @@ function TraceDetailsV3(): JSX.Element {
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}

View File

@@ -20,7 +20,8 @@ export type ComponentTypes =
| 'add_panel'
| 'page_pipelines'
| 'edit_locked_dashboard'
| 'add_panel_locked_dashboard';
| 'add_panel_locked_dashboard'
| 'manage_llm_pricing';
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
@@ -42,6 +43,7 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
page_pipelines: ['ADMIN', 'EDITOR'],
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
manage_llm_pricing: ['ADMIN'],
};
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {

View File

@@ -138,14 +138,33 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
return nil
}
// validateLayouts rejects grid items referencing a panel that doesn't exist.
const maxLayoutsPerDashboard = 500
// validateLayouts validates the dashboard's layouts: bounded section count,
// per-item geometry, resolvable panel references, and no panel placed twice.
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
// runs here so its errors can name the layout by index.
func (d *DashboardSpec) validateLayouts() error {
if len(d.Layouts) > maxLayoutsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
}
// Could enforce this but skipping for now: panels in no grid item (orphans)
// are allowed.
// The frontend keys each grid item by its panel id, so placing one panel in
// two grid items collides; reject duplicate references dashboard-wide. Maps
// each referenced panel key to the path of the item that first placed it.
referencedPanels := make(map[string]string, len(d.Panels))
for li, layout := range d.Layouts {
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
if !ok {
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
if err := validateGridLayoutGeometry(grid, li); err != nil {
return err
}
for ii, item := range grid.Items {
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
if item.Content == nil {
@@ -158,6 +177,10 @@ func (d *DashboardSpec) validateLayouts() error {
if _, ok := d.Panels[key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
}
if firstPath, dup := referencedPanels[key]; dup {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: panel %q is already placed by %s", path, key, firstPath)
}
referencedPanels[key] = path
}
}
return nil

View File

@@ -299,19 +299,22 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
t.Run("move panel by editing layout y coordinate", func(t *testing.T) {
// p2 fills the right half of row 0, so p1 can only move to a fresh row
// without tripping overlap validation.
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/y", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
// The first item used to live at y=0, now lives at y=6.
assert.Contains(t, raw, `"x":0,"y":6,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
// p2 sits at x=6, so p1 (at x=0) can only shrink; widening it would overlap.
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 3}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":12`)
assert.Contains(t, raw, `"width":3`)
})
t.Run("rename layout row title", func(t *testing.T) {
@@ -321,11 +324,12 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
})
t.Run("append layout item", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
// Appending needs a not-yet-placed panel, so add one in the same patch;
// re-placing p1 or p2 would be a duplicate reference.
out, err := decode(t, `[
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
@@ -1442,3 +1443,123 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
})
}
}
func TestValidateGridGeometry(t *testing.T) {
tests := []struct {
scenario string
items []dashboard.GridItem
expectErrContain string
}{
{
scenario: "valid side-by-side items",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 6, Y: 0, Width: 6, Height: 6}},
expectErrContain: "",
},
{
scenario: "valid full-width item",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 12, Height: 6}},
expectErrContain: "",
},
{
scenario: "stacked items do not overlap",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 0, Y: 6, Width: 6, Height: 6}},
expectErrContain: "",
},
{
scenario: "zero width",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 0, Height: 6}},
expectErrContain: "width must be at least 1",
},
{
scenario: "zero height",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 0}},
expectErrContain: "height must be at least 1",
},
{
scenario: "negative x",
items: []dashboard.GridItem{{X: -1, Y: 0, Width: 6, Height: 6}},
expectErrContain: "x must not be negative",
},
{
scenario: "negative y",
items: []dashboard.GridItem{{X: 0, Y: -1, Width: 6, Height: 6}},
expectErrContain: "y must not be negative",
},
{
scenario: "width wider than grid",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 13, Height: 6}},
expectErrContain: "width (13) exceeds grid width 12",
},
{
scenario: "x at grid width",
items: []dashboard.GridItem{{X: 12, Y: 0, Width: 1, Height: 6}},
expectErrContain: "x (12) must be less than grid width 12",
},
{
scenario: "x plus width overflows grid",
items: []dashboard.GridItem{{X: 8, Y: 0, Width: 6, Height: 6}},
expectErrContain: "x (8) + width (6) exceeds grid width 12",
},
{
scenario: "overlapping items",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 3, Y: 3, Width: 6, Height: 6}},
expectErrContain: "items[0] and items[1] overlap",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: test.items}, 0)
if test.expectErrContain == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), test.expectErrContain)
})
}
}
func TestValidateGridItemLimit(t *testing.T) {
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, maxItemsPerGridLayout+1)}, 0)
require.Error(t, err)
require.Contains(t, err.Error(), "maximum is")
}
// Both panel refs are valid, so this errors only if geometry validation runs on
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]}}]
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
require.Contains(t, err.Error(), "overlap")
}
// The frontend keys each grid item by its panel id, so the same panel placed by
// two grid items crashes the section; the backend rejects it dashboard-wide. The
// two items are side by side so they clear the overlap check first.
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
]}}]
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
require.Contains(t, err.Error(), "already placed")
// Both offending grid items are named.
require.Contains(t, err.Error(), "spec.layouts[0].spec.items[0].content")
require.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
}

View File

@@ -322,6 +322,55 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
return nil
}
const (
gridColumnCount = 12
maxItemsPerGridLayout = 100
)
// validateGridLayoutGeometry checks a single grid layout's item geometry (size,
// position, and intra-section overlap), which Perses does not. It reads only the
// layout's own items; layoutIndex is supplied by the caller (validateLayouts)
// solely to name the layout in error paths.
func validateGridLayoutGeometry(spec *dashboard.GridLayoutSpec, layoutIndex int) error {
if len(spec.Items) > maxItemsPerGridLayout {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items: has %d items; maximum is %d", layoutIndex, len(spec.Items), maxItemsPerGridLayout)
}
for i, item := range spec.Items {
// The width/x bounds keep x+width small enough not to overflow.
switch {
case item.Width < 1:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width must be at least 1, got %d", layoutIndex, i, item.Width)
case item.Height < 1:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: height must be at least 1, got %d", layoutIndex, i, item.Height)
case item.X < 0:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x must not be negative, got %d", layoutIndex, i, item.X)
case item.Y < 0:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: y must not be negative, got %d", layoutIndex, i, item.Y)
case item.Width > gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width (%d) exceeds grid width %d", layoutIndex, i, item.Width, gridColumnCount)
case item.X >= gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) must be less than grid width %d", layoutIndex, i, item.X, gridColumnCount)
case item.X+item.Width > gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) + width (%d) exceeds grid width %d", layoutIndex, i, item.X, item.Width, gridColumnCount)
}
// Could cap y/height but skipping for now: the grid grows vertically
// without limit (frontend autoSize), so "too big" has no natural bound.
}
// Two items overlap iff their rectangles intersect on both axes.
overlap := func(a, b dashboard.GridItem) bool {
return a.X < b.X+b.Width && b.X < a.X+a.Width &&
a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
}
for i := 0; i < len(spec.Items); i++ {
for j := i + 1; j < len(spec.Items); j++ {
if overlap(spec.Items[i], spec.Items[j]) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d] and items[%d] overlap", layoutIndex, i, j)
}
}
}
return nil
}
func (Layout) JSONSchemaOneOf() []any {
return []any{
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},

View File

@@ -173,6 +173,125 @@ def test_create_rejects_too_many_tags(
assert response.json()["error"]["code"] == "dashboard_invalid_input"
def test_create_rejects_invalid_grid_layout(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def panel(name: str) -> dict:
return {
"kind": "Panel",
"spec": {
"display": {"name": name},
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}],
},
}
},
}
],
},
}
# Two grid items reference valid, distinct panels but share cells, so the
# overlap is the only violation.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-overlap",
"spec": {
"display": {"name": "Rejects Overlap"},
"panels": {"p1": panel("P1"), "p2": panel("P2")},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}},
]
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "overlap" in response.json()["error"]["message"]
# One panel placed by two grid items (side by side, so they clear the overlap
# check first). The frontend keys grid items by panel id, so this is rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-multiref",
"spec": {
"display": {"name": "Rejects Multiref"},
"panels": {"p1": panel("P1")},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
]
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "already placed" in response.json()["error"]["message"]
# More grid items than allowed. The item-count check runs before the
# panel-ref check, so content-less items suffice here.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-too-many-items",
"spec": {
"display": {"name": "Rejects Too Many"},
"layouts": [
{
"kind": "Grid",
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "maximum" in response.json()["error"]["message"]
@pytest.mark.parametrize(
"params",
[