mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-29 19:40:31 +01:00
Compare commits
4 Commits
refactor/s
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d8a00ab8c | ||
|
|
348fca1b62 | ||
|
|
83e0e974fe | ||
|
|
10217274b8 |
@@ -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, '');
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
.actionButton {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { getModelCostsColumns } from './table.config';
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ModelCostsTable';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ModelCostTabPanel';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
}
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user