Compare commits

..

4 Commits

Author SHA1 Message Date
Abhi kumar
7d8a00ab8c fix(uplotV2): tooltip list clips last row and over-scrolls (#11883)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
The Virtuoso scroller height was set to min(totalListHeight, 300), but the
scroll viewport adds 8px top + 8px bottom padding that totalListHeight does
not account for. The box was therefore ~16px shorter than its content,
clipping the last row and showing a scrollbar even when every row would fit.

Add the viewport's vertical padding back into the computed height so it only
scrolls once content genuinely exceeds the max height.
2026-06-29 17:58:01 +00:00
Gaurav Tewari
348fca1b62 feat(llm-pricing): listing page + table (2/5) (#11760)
* 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

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* refactor: types and other things

* refactor: types and other things

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: llm pricing listing

* fix: update missing styles

* chore: update edit and delete options

* chore: remove divider

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-29 16:19:33 +00:00
Yunus M
83e0e974fe fix: include query metadata in alert edit and new contexts (#11891) 2026-06-29 15:37:07 +00:00
Himanshu RW
10217274b8 fix(render): add missing Content-Type header in Error() response (#11890) 2026-06-29 15:03:37 +00:00
27 changed files with 634 additions and 328 deletions

View File

@@ -32,13 +32,10 @@ export function useRoles(): {
};
}
export function getRoleOptions(
roles: AuthtypesRoleDTO[],
valueField: 'id' | 'name' = 'id',
): RoleOption[] {
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
return roles.map((role) => ({
label: role.name ?? '',
value: role[valueField] ?? '',
value: role.id ?? '',
}));
}
@@ -85,7 +82,6 @@ interface BaseProps {
error?: APIError;
onRefetch?: () => void;
disabled?: boolean;
valueField?: 'id' | 'name';
}
interface SingleProps extends BaseProps {
@@ -117,7 +113,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
});
const roles = externalRoles ?? data?.data ?? [];
const options = getRoleOptions(roles, props.valueField);
const options = getRoleOptions(roles);
const {
mode,

View File

@@ -48,6 +48,48 @@ describe('getAutoContexts', () => {
]);
});
it('includes the query in alert edit context', () => {
const ruleId = 'rule-edit';
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: {
page: 'alert_edit',
ruleId,
query,
},
},
]);
});
it('includes the query in alert new context (no ruleId)', () => {
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: {
page: 'alert_new',
query,
},
},
]);
});
it('returns triggered alerts context on alert history without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');

View File

@@ -124,7 +124,9 @@ export function getAutoContexts(
}
}
// Alert edit — `/alerts/edit?ruleId=…`.
// Alert edit — `/alerts/edit?ruleId=…`. The form syncs its query-builder
// state to the URL (`useShareBuilderUrl`), so shared metadata carries the
// alert's query + time range, mirroring the dashboard panel editor.
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
const ruleId = params.get(QueryParams.ruleId);
if (ruleId) {
@@ -133,19 +135,21 @@ export function getAutoContexts(
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit', ruleId },
metadata: { page: 'alert_edit', ruleId, ...sharedMetadata },
},
];
}
}
// Alert new — `/alerts/new`. No rule id yet (draft), but the query-builder
// state is on the URL, so shared metadata carries the in-progress query.
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
return [
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: { page: 'alert_new' },
metadata: { page: 'alert_new', ...sharedMetadata },
},
];
}

View File

@@ -13,6 +13,9 @@
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.title {
margin: 0;
font-size: var(--font-size-xl);

View File

@@ -1,3 +1,7 @@
import { Tabs } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import ModelCostTabPanel from './ModelCostTabPanel';
import styles from './LLMObservabilityModelPricing.module.scss';
function LLMObservabilityModelPricing(): JSX.Element {
@@ -8,12 +12,34 @@ function LLMObservabilityModelPricing(): JSX.Element {
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Configuration</h1>
<p className={styles.subtitle}>
<Typography.Text as="h1" size="large" weight="semibold">
Configuration
</Typography.Text>
<Typography.Text color="muted">
Model pricing and cost estimation settings
</p>
</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.
defaultValue="model-costs"
items={[
{
key: 'model-costs',
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,7 @@
.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);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
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 { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
function ModelCostTabPanel(): JSX.Element {
const { page, limit } = 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 listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
);
const total = data?.data?.total ?? 0;
return (
<>
{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}
/>
<footer>
<Typography.Text color="muted" size="small">
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
</>
);
}
export default ModelCostTabPanel;

View File

@@ -0,0 +1,8 @@
.actionButton {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './ModelCostActionsMenu.module.scss';
interface ModelCostActionsMenuProps {
rule: LlmpricingruletypesLLMPricingRuleDTO;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Per-row kebab menu for the model-costs table. Only manage users get actions
// (Edit + Delete); view-only users have nothing to act on, so the cell stays
// empty rather than showing a single-item menu.
function ModelCostActionsMenu({
rule,
canManage,
onEdit,
onDelete,
}: ModelCostActionsMenuProps): JSX.Element | null {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
onClick: (): void => onEdit(rule),
},
{
key: 'delete',
label: 'Delete',
danger: true,
onClick: (): void => onDelete(rule),
},
],
[onEdit, onDelete, rule],
);
if (!canManage) {
return null;
}
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.actionButton}
testId={`model-cost-actions-${rule.id}`}
>
<Ellipsis size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default ModelCostActionsMenu;

View File

@@ -0,0 +1,20 @@
.modelCostsTable {
margin-top: var(--spacing-8);
--tanstack-table-row-height: 48px;
height: calc(100vh - 250px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.modelCostsEmpty {
display: flex;
align-items: center;
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,73 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SKELETON_ROW_COUNT,
} from '../../../constants';
import styles from './ModelCostsTable.module.scss';
import { getModelCostsColumns } from './TableConfig';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
interface ModelCostsTableProps {
rules: LlmpricingruletypesLLMPricingRuleDTO[];
isLoading: boolean;
total: number;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// The table owns its own pagination URL state (page/limit) via enableQueryParams;
// ModelCostsTab reads the same keys to build the list request. Virtual scroll is
// disabled: a plain table renders fine at our page sizes (up to 100 rows) and the
// fixed-height scroll viewport (.modelCostsTable) keeps large pages scrolling
// inside the table.
function ModelCostsTable({
rules,
isLoading,
total,
selectedRuleId,
canManage,
onEdit,
onDelete,
}: ModelCostsTableProps): JSX.Element {
const columns = useMemo(
() => getModelCostsColumns({ canManage, onEdit, onDelete }),
[canManage, onEdit, onDelete],
);
if (!isLoading && rules.length === 0) {
return (
<div className={styles.modelCostsEmpty} data-testid="model-costs-empty">
No model costs yet.
</div>
);
}
return (
<TanStackTable<LlmpricingruletypesLLMPricingRuleDTO>
className={styles.modelCostsTable}
data={rules}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.id}
isRowActive={(row): boolean => row.id === selectedRuleId}
disableVirtualScroll
testId="model-costs-table"
enableQueryParams={{ page: PAGE_KEY, limit: LIMIT_KEY }}
pagination={{
total,
defaultLimit: PAGE_SIZE,
showTotalCount: true,
totalCountLabel: 'models',
}}
/>
);
}
export default ModelCostsTable;

View File

@@ -0,0 +1 @@
export { getModelCostsColumns } from './table.config';

View File

@@ -0,0 +1,161 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import { startCase } from 'lodash-es';
import styles from './tableConfig.module.scss';
import ModelCostActionsMenu from '../ModelCostActionsMenu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from '../../../../utils';
interface ColumnsConfig {
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Column definitions for the model-costs TanStackTable. Sorting is intentionally
// off across the board — the list API only accepts offset/limit, so there's no
// server-side ordering to back a sortable header yet.
export function getModelCostsColumns({
canManage,
onEdit,
onDelete,
}: ColumnsConfig): TableColumnDef<LlmpricingruletypesLLMPricingRuleDTO>[] {
return [
{
id: 'model',
header: 'Model',
accessorFn: (row): string => row.modelName ?? '',
// Flexes to absorb spare width alongside Extra buckets so the row fills
// the container instead of leaving a gap on the right.
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.modelCell}>
<Typography.Text
weight="semibold"
truncate={1}
testId={`model-cell-name-${row.id}`}
>
{row.modelName}
</Typography.Text>
<Typography.Text truncate={1}>{getCanonicalId(row)}</Typography.Text>
</div>
),
},
{
id: 'provider',
header: 'Provider',
accessorKey: 'provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider ?? '',
},
{
id: 'input',
header: 'Input / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.input)}
</Typography.Text>
),
},
{
id: 'output',
header: 'Output / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.output)}
</Typography.Text>
),
},
{
id: 'extraBuckets',
header: 'Extra buckets',
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
const buckets = getExtraBuckets(row);
if (buckets.length === 0) {
return (
<Typography.Text color="muted" as="span">
</Typography.Text>
);
}
return (
<div className={styles.extraBuckets}>
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className={styles.extraBucketsChip}
>
<Typography.Text as="span" size="small">
{startCase(bucket.key)}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(bucket.pricePerMillion)}
</Typography.Text>
</Badge>
))}
</div>
);
},
},
{
id: 'source',
header: 'Source',
width: { min: 130 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color={row.isOverride ? 'amber' : 'robin'}
variant="outline"
className={styles.sourceBadge}
data-testid={`source-badge-${row.id}`}
>
{getSourceLabel(row)}
</Badge>
),
},
{
id: 'lastSeen',
header: 'Last seen',
width: { min: 120 },
enableMove: false,
cell: ({ row }): string => getRelativeLastSeen(row),
},
{
id: 'actions',
header: '',
width: { fixed: '56px', ignoreLastColumnFill: true },
pin: 'right',
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element | null => (
<ModelCostActionsMenu
rule={row}
canManage={canManage}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
];
}

View File

@@ -0,0 +1,26 @@
.modelCell {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.extraBuckets {
display: flex;
// Keep chips on a single line so the row stays at the table's fixed row
// height; the column flexes to 100% so there's room for both.
flex-wrap: nowrap;
gap: var(--spacing-3);
overflow: hidden;
}
.extraBucketsChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
}
.sourceBadge {
margin: 0;
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SKELETON_ROW_COUNT = PAGE_SIZE;

View File

@@ -0,0 +1,4 @@
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}

View File

@@ -0,0 +1,60 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
dayjs.extend(relativeTime);
const getRelativeTime = (
timestamp: string | number | Date | null | undefined,
): string => {
const parsed = timestamp != null ? dayjs(timestamp) : null;
return parsed?.isValid() ? parsed.fromNow() : '—';
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null) {
return '—';
}
// 2dp is enough for per-1M pricing. we can update this later we models have sub-cent pricing.
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
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 getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 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 => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};

View File

@@ -79,7 +79,7 @@
margin-bottom: 12px;
}
input:not(.ant-select-selection-search-input),
input,
textarea {
height: 32px;
background: var(--l2-background) !important;

View File

@@ -111,9 +111,31 @@
&__select {
width: 100%;
.ant-select-selection-search {
inset-inline-start: var(--padding-2) !important;
inset-inline-end: var(--padding-2) !important;
&.ant-select {
.ant-select-selector {
height: 32px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
border-radius: 2px;
color: var(--l2-foreground) !important;
.ant-select-selection-item {
color: var(--l2-foreground) !important;
}
}
&:hover .ant-select-selector {
border-color: var(--l2-border) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--primary) !important;
box-shadow: none !important;
}
.ant-select-arrow {
color: var(--l2-foreground);
}
}
}
@@ -163,7 +185,7 @@
&--role {
flex: 1;
min-width: 180px;
min-width: 120px;
}
}
@@ -250,7 +272,7 @@
}
// todo: https://github.com/SigNoz/components/issues/116
input:not(.ant-select-selection-search-input) {
input {
height: 32px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;

View File

@@ -11,20 +11,23 @@ import {
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import { Collapse, Form, Tooltip } from 'antd';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import { Collapse, Form, Select, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './RoleMappingSection.styles.scss';
const ROLE_OPTIONS = [
{ value: 'VIEWER', label: 'VIEWER' },
{ value: 'EDITOR', label: 'EDITOR' },
{ value: 'ADMIN', label: 'ADMIN' },
];
interface RoleMappingSectionProps {
fieldNamePrefix: string[];
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
}
const SIGNOZ_VIEWER_ROLE = 'signoz-viewer';
function RoleMappingSection({
fieldNamePrefix,
isExpanded,
@@ -35,7 +38,6 @@ function RoleMappingSection({
[...fieldNamePrefix, 'useRoleAttribute'],
form,
);
const { roles, isLoading, isError, error, refetch } = useRoles();
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
@@ -113,19 +115,12 @@ function RoleMappingSection({
<Form.Item
name={[...fieldNamePrefix, 'defaultRole']}
className="role-mapping-section__form-item"
initialValue={SIGNOZ_VIEWER_ROLE}
initialValue="VIEWER"
>
<RolesSelect
<Select
id="default-role"
valueField="name"
roles={roles}
loading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
options={ROLE_OPTIONS}
className="role-mapping-section__select"
allowClear={false}
getPopupContainer={(): HTMLElement => document.body}
/>
</Form.Item>
</div>
@@ -179,17 +174,11 @@ function RoleMappingSection({
name={[field.name, 'role']}
className="role-mapping-section__field role-mapping-section__field--role"
rules={[{ required: true, message: 'Role is required' }]}
initialValue={SIGNOZ_VIEWER_ROLE}
initialValue="VIEWER"
>
<RolesSelect
valueField="name"
roles={roles}
loading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
allowClear={false}
getPopupContainer={(): HTMLElement => document.body}
<Select
options={ROLE_OPTIONS}
className="role-mapping-section__select"
/>
</Form.Item>
@@ -208,9 +197,7 @@ function RoleMappingSection({
<Button
variant="outlined"
color="secondary"
onClick={(): void =>
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
}
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
prefix={<Plus size={14} />}
>
Add Group Mapping

View File

@@ -9,7 +9,6 @@ import {
mockUpdateSuccessResponse,
} from './mocks';
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
// which mocks @signozhq/ui/switch for the same reason.

View File

@@ -1,270 +0,0 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
allRoles,
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithDirectRoleAttribute,
mockSamlAuthDomain,
mockUpdateSuccessResponse,
} from './mocks';
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
// The @signozhq/ui Button uses Radix Slot and has CSS infinite animations that
// prevent form.validateFields() from resolving inside act(). Replacing with a
// simple native button avoids the issue.
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),
Button: ({
children,
onClick,
loading,
disabled,
'aria-label': ariaLabel,
prefix,
suffix,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
loading?: boolean;
disabled?: boolean;
'aria-label'?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{prefix}
{children}
{suffix}
</button>
),
}));
const ROLES_ENDPOINT = '*/api/v1/roles';
type User = ReturnType<typeof userEvent.setup>;
// antd renders pointer-events:none on parts of its Select, so disable the
// userEvent pointer-events guard (mirrors CreateEdit.test.tsx).
const setupUser = (): User => userEvent.setup({ pointerEventsCheck: 0 });
function getRole(name: string): (typeof managedRoles)[number] {
const role = managedRoles.find((r) => r.name === name);
if (!role) {
throw new Error(`missing mock role: ${name}`);
}
return role;
}
const viewerRole = getRole('signoz-viewer');
const editorRole = getRole('signoz-editor');
function mockRoles(
response: Record<string, unknown> = listRolesSuccessResponse,
status = 200,
): { count: () => number } {
let requested = 0;
server.use(
rest.get(ROLES_ENDPOINT, (_req, res, ctx) => {
requested += 1;
return res(ctx.status(status), ctx.json(response));
}),
);
return { count: (): number => requested };
}
function captureUpdatePayload(): { get: () => any } {
let payload: unknown = null;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
payload = await req.json();
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
return { get: (): any => payload };
}
const expandRoleMapping = (user: User): Promise<void> =>
user.click(screen.getByText(/role mapping \(advanced\)/i));
const openDefaultRoleSelect = (user: User): Promise<void> =>
user.click(screen.getByLabelText(/default role/i));
const saveChanges = (user: User): Promise<void> =>
user.click(screen.getByRole('button', { name: /save changes/i }));
describe('CreateEdit — role mapping uses API roles', () => {
afterEach(() => {
server.resetHandlers();
});
it('fetches the roles list from the API when the form mounts', async () => {
const roles = mockRoles();
render(
<CreateEdit
isCreate={false}
record={mockDomainWithDirectRoleAttribute}
onClose={jest.fn()}
/>,
);
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
});
it('renders the default-role options from the API (managed + custom), not the old hardcoded VIEWER/EDITOR/ADMIN', async () => {
const user = setupUser();
mockRoles();
// mockSamlAuthDomain has no stored defaultRole, so nothing stale (e.g.
// "VIEWER") is rendered as a selected tag to pollute the title lookups.
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
// Open the Select and wait for the async roles fetch to populate it.
await openDefaultRoleSelect(user);
await screen.findByTitle(allRoles[0].name);
// Every role returned by the API is offered as an option, including the
// custom (non-managed) roles — the whole point of the refactor. Use
// getAllByTitle: the preselected default role also renders its name on
// the selection item, so a role may legitimately appear more than once.
allRoles.forEach((role) => {
expect(screen.getAllByTitle(role.name).length).toBeGreaterThan(0);
});
// The old hardcoded uppercase role values must NOT appear as options.
expect(screen.queryByTitle('VIEWER')).not.toBeInTheDocument();
expect(screen.queryByTitle('EDITOR')).not.toBeInTheDocument();
expect(screen.queryByTitle('ADMIN')).not.toBeInTheDocument();
});
it('submits the selected role name (not the role id) as defaultRole', async () => {
const user = setupUser();
mockRoles();
const payload = captureUpdatePayload();
render(
<CreateEdit
isCreate={false}
record={mockDomainWithDirectRoleAttribute}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
await openDefaultRoleSelect(user);
await user.click(await screen.findByTitle(editorRole.name));
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
// SSO role mapping matches roles by name, so the payload carries the
// role *name*, not the opaque id.
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
});
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
const user = setupUser();
const roles = mockRoles();
const payload = captureUpdatePayload();
// mockSamlAuthDomain has no roleMapping, so the defaultRole field falls
// back to the Form.Item initialValue (viewerRole.name). That initialValue
// is only applied when the field mounts, so the roles fetch MUST resolve
// before the panel is expanded — otherwise viewerRole is still undefined.
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
// Flush the react-query commit so `useRoles` exposes the loaded roles
// before the collapse panel (and thus the default-role field) mounts.
await screen.findByText(/edit saml authentication/i);
await expandRoleMapping(user);
await screen.findByText(/default role/i);
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
});
it('does not send a defaultRole when the roles list is empty (regression guard)', async () => {
const user = setupUser();
mockRoles({ status: 'success', data: [] });
const payload = captureUpdatePayload();
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
// Section still renders without crashing even though no roles exist.
await expandRoleMapping(user);
await expect(screen.findByText(/default role/i)).resolves.toBeInTheDocument();
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
// No viewer role to fall back to -> roleMapping is omitted entirely.
expect(payload.get().config.roleMapping).toBeUndefined();
});
it('shows an error state in the default-role select when the roles request fails', async () => {
const user = setupUser();
mockRoles(
{ error: { code: 'internal_error', message: 'boom', url: '' } },
500,
);
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
// Open the select and confirm the error UI (with retry) is surfaced
// instead of crashing the form. The error message comes straight from
// the failed request; the Retry affordance is always present.
await openDefaultRoleSelect(user);
await expect(screen.findByTitle('Retry')).resolves.toBeInTheDocument();
expect(screen.getByText('boom')).toBeInTheDocument();
});
});

View File

@@ -177,7 +177,8 @@ describe('Tooltip', () => {
renderTooltip({ uPlotInstance, content });
const list = screen.getByTestId('uplot-tooltip-list');
expect(list).toHaveStyle({ height: '200px' });
// Measured height (200) + the scroll viewport's vertical padding (16)
expect(list).toHaveStyle({ height: '216px' });
});
it('sets tooltip list height based on content length when Virtuoso reports 0 height', () => {
@@ -188,8 +189,8 @@ describe('Tooltip', () => {
renderTooltip({ uPlotInstance, content });
const list = screen.getByTestId('uplot-tooltip-list');
// Falls back to content length: 2 items * 38px = 76px
expect(list).toHaveStyle({ height: '76px' });
// Falls back to content length (2 * 38 = 76) + vertical padding (16) = 92px
expect(list).toHaveStyle({ height: '92px' });
});
});

View File

@@ -13,6 +13,11 @@ import Styles from './TooltipList.module.scss';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
// Vertical padding (spacing-4 top + bottom) the SCSS applies to the scroll
// viewport. Virtuoso's reported height covers only the items, so it must be
// added back — otherwise the box is short by this amount, clipping the last
// row and showing a scrollbar even when every row would fit.
const LIST_VERTICAL_PADDING = 16;
interface TooltipListProps {
id: string;
@@ -30,13 +35,13 @@ export default function TooltipList({
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const height = useMemo(
() =>
totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
[totalListHeight, content.length],
);
const height = useMemo(() => {
const contentHeight =
totalListHeight > 0 ? totalListHeight : content.length * TOOLTIP_ITEM_HEIGHT;
return Math.ceil(
Math.min(contentHeight + LIST_VERTICAL_PADDING, LIST_MAX_HEIGHT),
);
}, [totalListHeight, content.length]);
const handleScroll = useCallback(() => {
if (!isScrollEventTriggered.current) {

View File

@@ -130,6 +130,7 @@ func Error(rw http.ResponseWriter, cause error) {
rw.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(d.Seconds()))))
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(httpCode)
_, _ = rw.Write(body)
}