mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-01 12:20:38 +01:00
Compare commits
1 Commits
nv/layout-
...
feat/noz-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38286e38dd |
@@ -61,7 +61,5 @@
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
}
|
||||
|
||||
@@ -86,7 +86,5 @@
|
||||
"ROLE_EDIT": "SigNoz | Edit Role",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
}
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
.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(--accent-cherry) 8%, transparent);
|
||||
color: var(--accent-cherry);
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
|
||||
color: var(--text-cherry-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
@@ -1,164 +1,52 @@
|
||||
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 useComponentPermission from 'hooks/useComponentPermission';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
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 { useModelCostDelete } from './hooks/useModelCostDelete';
|
||||
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';
|
||||
|
||||
// "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, setPage } = useTableParams(
|
||||
const { page, limit } = useTableParams(
|
||||
{ page: PAGE_KEY, limit: LIMIT_KEY },
|
||||
{ page: 1, limit: PAGE_SIZE },
|
||||
);
|
||||
|
||||
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];
|
||||
|
||||
// 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,
|
||||
...(debouncedSearch ? { q: debouncedSearch } : {}),
|
||||
...(isOverride !== undefined ? { isOverride } : {}),
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams, {
|
||||
query: {
|
||||
enabled: search === debouncedSearch,
|
||||
},
|
||||
});
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [canManagePricing] = useComponentPermission(
|
||||
['manage_llm_pricing'],
|
||||
user.role,
|
||||
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
|
||||
() => data?.data?.items || [],
|
||||
[data],
|
||||
);
|
||||
|
||||
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={drawer.selectedRuleId}
|
||||
canManage={canManagePricing}
|
||||
onEdit={drawer.openForEdit}
|
||||
onDelete={deletion.requestDelete}
|
||||
selectedRuleId={null}
|
||||
canManage={false}
|
||||
onEdit={(): void => undefined}
|
||||
onDelete={(): void => undefined}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
@@ -166,29 +54,6 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './DeleteConfirmDialog';
|
||||
@@ -1,58 +0,0 @@
|
||||
.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%;
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
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;
|
||||
@@ -1,69 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './ExtraPricingBuckets';
|
||||
@@ -1,49 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './PatternEditor';
|
||||
@@ -1,31 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './PricingFields';
|
||||
@@ -1,115 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './SourceSelector';
|
||||
@@ -1,100 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './ModelCostDrawer';
|
||||
export { useModelCostDrawer } from './hooks/useModelCostDrawer';
|
||||
@@ -1,59 +0,0 @@
|
||||
/* 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%;
|
||||
}
|
||||
@@ -15,6 +15,6 @@
|
||||
justify-content: center;
|
||||
margin-top: var(--spacing-8);
|
||||
min-height: 400px;
|
||||
color: var(--l3-foreground);
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,68 +1,6 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,39 +1,4 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
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 {
|
||||
DrawerDraft,
|
||||
DrawerMode,
|
||||
ExtraBucket,
|
||||
PricingRule,
|
||||
} from './types';
|
||||
import type { ExtraBucket } from './types';
|
||||
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -24,19 +13,6 @@ 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 => {
|
||||
@@ -47,117 +23,38 @@ export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
|
||||
export const getExtraBuckets = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (hasCacheValue(cache.read)) {
|
||||
if (typeof cache.read === 'number' && cache.read > 0) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (hasCacheValue(cache.write)) {
|
||||
if (typeof cache.write === 'number' && cache.write > 0) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
|
||||
rule.isOverride ? 'User override' : 'Auto';
|
||||
export const getSourceLabel = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
|
||||
|
||||
export const getRelativeLastSeen = (rule: PricingRule): string =>
|
||||
getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
|
||||
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: PricingRule): string => {
|
||||
export const getCanonicalId = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): 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;
|
||||
};
|
||||
|
||||
@@ -26,8 +26,8 @@ export default function AIAssistantPage(): JSX.Element {
|
||||
|
||||
// Skip the mount-time Opened fire when the user expanded an already-open
|
||||
// drawer/modal — that surface already emitted Opened with the right source.
|
||||
// Router state (vs a module flag) survives StrictMode double-mount and
|
||||
// aborted navigations.
|
||||
// Router state (vs a module flag) survives page remounts and aborted
|
||||
// navigations.
|
||||
const fromInApp = location.state?.fromInApp === true;
|
||||
useEffect(() => {
|
||||
if (fromInApp) {
|
||||
@@ -52,18 +52,34 @@ export default function AIAssistantPage(): JSX.Element {
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
// Keep a ref so the effect can read latest conversations without re-firing
|
||||
// when startNewConversation mutates the store mid-effect.
|
||||
// Keep refs so the effect can read the latest store state without re-firing
|
||||
// when it mutates the store mid-effect (it only depends on the URL param).
|
||||
const conversationsRef = useRef(conversations);
|
||||
conversationsRef.current = conversations;
|
||||
const activeConversationIdRef = useRef(activeConversationId);
|
||||
activeConversationIdRef.current = activeConversationId;
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationsRef.current[conversationId]) {
|
||||
// URL points at a known conversation → just activate it.
|
||||
if (conversationId && conversationsRef.current[conversationId]) {
|
||||
setActiveConversation(conversationId);
|
||||
} else {
|
||||
const newId = startNewConversation();
|
||||
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
|
||||
return;
|
||||
}
|
||||
|
||||
// The URL has no usable conversation id (bare `/ai-assistant`, or a stale
|
||||
// param). Prefer resuming the active conversation — including the
|
||||
// rehydrating placeholder for the persisted thread — over minting a new
|
||||
// one. This is what stops a throwaway blank chat from flashing as a
|
||||
// second thread during load, and stops a duplicate when the page
|
||||
// remounts during startup route churn (the active id is already set, so
|
||||
// we resume instead of create). Starting fresh is the last resort, only
|
||||
// when there is genuinely nothing to resume.
|
||||
const activeId = activeConversationIdRef.current;
|
||||
const resumeId =
|
||||
activeId && conversationsRef.current[activeId]
|
||||
? activeId
|
||||
: startNewConversation();
|
||||
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', resumeId));
|
||||
// Only re-run when the URL param changes, not when conversations mutates.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversationId]);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { render } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/ConversationView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="conversation-view" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/components/ConversationsList', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="conversations-list" />,
|
||||
}));
|
||||
|
||||
jest.mock('components/Noz/Noz', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="noz" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
normalizePage: (page: string): string => page,
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({ mode: 'page' }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import AIAssistantPage from '../AIAssistantPage';
|
||||
|
||||
function renderAt(entry: string): { unmount: () => void } {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Route
|
||||
exact
|
||||
path={[ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT]}
|
||||
component={AIAssistantPage}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderAtBase(): { unmount: () => void } {
|
||||
return renderAt(ROUTES.AI_ASSISTANT_BASE);
|
||||
}
|
||||
|
||||
function conversationCount(): number {
|
||||
return Object.keys(useAIAssistantStore.getState().conversations).length;
|
||||
}
|
||||
|
||||
function conversationIds(): string[] {
|
||||
return Object.keys(useAIAssistantStore.getState().conversations);
|
||||
}
|
||||
|
||||
function activeId(): string | null {
|
||||
return useAIAssistantStore.getState().activeConversationId;
|
||||
}
|
||||
|
||||
describe('AIAssistantPage', () => {
|
||||
beforeEach(() => {
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {},
|
||||
streams: {},
|
||||
activeConversationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens exactly one conversation when navigating to /ai-assistant', () => {
|
||||
const { unmount } = renderAtBase();
|
||||
|
||||
expect(conversationCount()).toBe(1);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not stack a second conversation when the page remounts at the bare URL (route churn)', () => {
|
||||
// First mount at `/ai-assistant` creates one blank conversation and
|
||||
// redirects to `/ai-assistant/:id`.
|
||||
const { unmount } = renderAtBase();
|
||||
expect(conversationCount()).toBe(1);
|
||||
const firstId = conversationIds()[0];
|
||||
|
||||
// Startup route-list churn unmounts and remounts the page while the URL
|
||||
// is momentarily back at the bare `/ai-assistant`. This previously
|
||||
// created a second blank conversation — now it reuses the first.
|
||||
unmount();
|
||||
const { unmount: unmount2 } = renderAtBase();
|
||||
|
||||
expect(conversationCount()).toBe(1);
|
||||
// The surviving conversation is the original one, resumed — not a fresh mint.
|
||||
expect(conversationIds()).toStrictEqual([firstId]);
|
||||
expect(activeId()).toBe(firstId);
|
||||
|
||||
unmount2();
|
||||
});
|
||||
|
||||
it('activates the conversation named in the URL without creating a new one', () => {
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {
|
||||
existing: {
|
||||
id: 'existing',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
streams: {},
|
||||
activeConversationId: null,
|
||||
});
|
||||
|
||||
const { unmount } = renderAt(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', 'existing'),
|
||||
);
|
||||
|
||||
expect(conversationCount()).toBe(1);
|
||||
expect(activeId()).toBe('existing');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('resumes the active conversation on /ai-assistant/new instead of minting a new one', () => {
|
||||
// The sidenav only routes to `/ai-assistant/new` as a fallback, but if an
|
||||
// active conversation exists the page must resume it rather than spawn a
|
||||
// throwaway blank thread for the unknown "new" param.
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {
|
||||
active: {
|
||||
id: 'active',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
streams: {},
|
||||
activeConversationId: 'active',
|
||||
});
|
||||
|
||||
const { unmount } = renderAt(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', 'new'),
|
||||
);
|
||||
|
||||
expect(conversationCount()).toBe(1);
|
||||
expect(conversationIds()).toStrictEqual(['active']);
|
||||
expect(activeId()).toBe('active');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('resumes the persisted (hydrating) conversation during load instead of creating a second', () => {
|
||||
// Simulates `onRehydrateStorage` priming the persisted active
|
||||
// conversation as a hydrating placeholder before `fetchThreads` resolves.
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {
|
||||
persisted: {
|
||||
id: 'persisted',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
isHydrating: true,
|
||||
},
|
||||
},
|
||||
streams: {},
|
||||
activeConversationId: 'persisted',
|
||||
});
|
||||
|
||||
const { unmount } = renderAtBase();
|
||||
|
||||
// Opening the bare URL must resume the persisted conversation, not mint a
|
||||
// throwaway blank alongside it (which flashed as a 2nd thread during load).
|
||||
expect(conversationCount()).toBe(1);
|
||||
expect(
|
||||
Object.keys(useAIAssistantStore.getState().conversations),
|
||||
).toStrictEqual(['persisted']);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,7 @@ export type ComponentTypes =
|
||||
| 'add_panel'
|
||||
| 'page_pipelines'
|
||||
| 'edit_locked_dashboard'
|
||||
| 'add_panel_locked_dashboard'
|
||||
| 'manage_llm_pricing';
|
||||
| 'add_panel_locked_dashboard';
|
||||
|
||||
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
current_org_settings: ['ADMIN'],
|
||||
@@ -43,7 +42,6 @@ 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[]> = {
|
||||
|
||||
@@ -138,33 +138,14 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
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.
|
||||
// validateLayouts rejects grid items referencing a panel that doesn't exist.
|
||||
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 {
|
||||
@@ -177,10 +158,6 @@ 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
|
||||
|
||||
@@ -299,22 +299,19 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
// Layout edits
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
// 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"}`)
|
||||
// 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"}`)
|
||||
})
|
||||
|
||||
t.Run("resize panel by editing layout width", func(t *testing.T) {
|
||||
// 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)
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"width":3`)
|
||||
assert.Contains(t, raw, `"width":12`)
|
||||
})
|
||||
|
||||
t.Run("rename layout row title", func(t *testing.T) {
|
||||
@@ -324,12 +321,11 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("append layout item", func(t *testing.T) {
|
||||
// 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)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
// Item count went 2 → 3.
|
||||
raw := jsonOf(t, out)
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
@@ -1443,123 +1442,3 @@ 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")
|
||||
}
|
||||
|
||||
@@ -322,55 +322,6 @@ 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)},
|
||||
|
||||
@@ -173,125 +173,6 @@ 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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user