Skip to content

Commit 80a9ed6

Browse files
authored
Merge pull request #1076 from DiabolusGX/develop
feat(features): add Custom Expression toggle to meter creation
2 parents 1831793 + 41a7061 commit 80a9ed6

5 files changed

Lines changed: 116 additions & 19 deletions

File tree

src/i18n/locales/ar/catalog.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,11 @@
740740
"aggregationFieldPh": "tokens",
741741
"aggregationFieldHelp": "حدد الخاصية في بيانات الحدث التي ستُجمَّع، مثل tokens وmessages_sent وstorage_used.",
742742
"aggregationMultiplier": "مضاعف التجميع*",
743+
"customExpressionButton": "تعبير مخصص",
744+
"aggregationFieldButton": "حقل التجميع",
745+
"customExpression": "تعبير مخصص*",
746+
"customExpressionPh": "ceil(duration_ms / 1000)",
747+
"customExpressionHelp": "اكتب صيغة CEL تُحسب لكل حدث لحساب الكمية. المتغيرات هي أسماء الخصائص ذات المستوى الأعلى في الأحداث (مثل duration_ms وtokens). الدوال المتاحة: max(a, b)، min(a, b)، abs(x)، ceil(x)، floor(x)، round(x)، pow(x, y)، sqrt(x)، log(x). أمثلة: \"input_tokens + output_tokens\"، \"ceil(duration_ms / 1000)\"، \"min(usage, quota)\". متاح فقط لـ SUM وAVG وMAX وLATEST.",
743748
"aggregationMultiplierPh": "1",
744749
"aggregationMultiplierHelp": "حدد المضاعف للتجميع، مثل 1.5 أو 0.25 أو 1000.",
745750
"bucketSizeButton": "حجم الدفعة",

src/i18n/locales/en/catalog.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,11 @@
739739
"aggregationField": "Aggregation Field*",
740740
"aggregationFieldPh": "tokens",
741741
"aggregationFieldHelp": "Specify the property in the event data that will be aggregated. e.g. tokens, messages_sent, storage_used.",
742+
"customExpressionButton": "Custom expression",
743+
"aggregationFieldButton": "Aggregation field",
744+
"customExpression": "Custom Expression*",
745+
"customExpressionPh": "ceil(duration_ms / 1000)",
746+
"customExpressionHelp": "Write a CEL formula evaluated per event to compute the quantity. Variables are top-level property names on your events (e.g. duration_ms, tokens). Available functions: max(a, b), min(a, b), abs(x), ceil(x), floor(x), round(x), pow(x, y), sqrt(x), log(x). Examples: \"input_tokens + output_tokens\", \"ceil(duration_ms / 1000)\", \"min(usage, quota)\", \"max(cores * hours * rate, 0.01)\". Only available for SUM, AVG, MAX and LATEST.",
742747
"aggregationMultiplier": "Aggregation Multiplier*",
743748
"aggregationMultiplierPh": "1",
744749
"aggregationMultiplierHelp": "Specify the multiplier for the aggregation. e.g. 1.5, 0.25, or 1000.",

src/models/Meter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BaseModel } from './base';
33
export interface Meter extends BaseModel {
44
readonly aggregation: {
55
field: string;
6+
expression?: string;
67
type: METER_AGGREGATION_TYPE;
78
multiplier?: number;
89
bucket_size?: BUCKET_SIZE;

src/pages/product-catalog/features/AddFeature.tsx

Lines changed: 102 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ const AGGREGATION_OPTIONS: SelectOption[] = [
111111
},
112112
];
113113

114+
// Aggregation types that accept a CEL expression in place of a field.
115+
// Mirrors the backend's AggregationType.SupportsExpression() classifier
116+
// (internal/types/aggregation.go).
117+
const EXPRESSION_SUPPORTED_TYPES: METER_AGGREGATION_TYPE[] = [
118+
METER_AGGREGATION_TYPE.SUM,
119+
METER_AGGREGATION_TYPE.AVG,
120+
METER_AGGREGATION_TYPE.MAX,
121+
METER_AGGREGATION_TYPE.LATEST,
122+
];
123+
114124
const BUCKET_SIZE_OPTIONS: SelectOption[] = [
115125
{
116126
label: 'Minute',
@@ -183,10 +193,13 @@ interface FeatureFormState {
183193
showEventFilters: boolean;
184194
showBucketSize: boolean;
185195
showGroupBy: boolean;
196+
showCustomExpression: boolean;
186197
}
187198

188199
type FeatureErrors = Partial<Record<keyof CreateFeatureRequest, string>>;
189-
type MeterErrors = Partial<Record<keyof CreateMeterRequest | 'aggregation_type' | 'aggregation_field' | 'aggregation_multiplier', string>>;
200+
type MeterErrors = Partial<
201+
Record<keyof CreateMeterRequest | 'aggregation_type' | 'aggregation_field' | 'aggregation_expression' | 'aggregation_multiplier', string>
202+
>;
190203

191204
// Custom hook for feature form logic
192205
const useFeatureForm = () => {
@@ -201,6 +214,7 @@ const useFeatureForm = () => {
201214
showEventFilters: false,
202215
showBucketSize: false,
203216
showGroupBy: false,
217+
showCustomExpression: false,
204218
});
205219

206220
const updateFeatureData = useCallback((updates: Partial<FeatureFormData>) => {
@@ -230,7 +244,7 @@ const useFeatureForm = () => {
230244
return true;
231245
}, []);
232246

233-
const validateMeter = useCallback((meterData: Partial<CreateMeterRequest> | undefined): boolean => {
247+
const validateMeter = useCallback((meterData: Partial<CreateMeterRequest> | undefined, formState: FeatureFormState): boolean => {
234248
if (!meterData) return false;
235249

236250
const errors: Record<string, string> = {};
@@ -243,9 +257,15 @@ const useFeatureForm = () => {
243257
errors.aggregation_type = 'Aggregation type is required';
244258
}
245259

246-
// Only validate field if aggregation type is not COUNT
260+
// COUNT needs no per-event field/expression. For everything else, the
261+
// user is either in "Aggregation Field" mode (default) or "Custom
262+
// Expression" mode (toggled). Validate whichever input is currently shown.
247263
if (meterData.aggregation?.type !== METER_AGGREGATION_TYPE.COUNT) {
248-
if (!meterData.aggregation?.field?.trim()) {
264+
if (formState.showCustomExpression) {
265+
if (!meterData.aggregation?.expression?.trim()) {
266+
errors.aggregation_expression = 'Custom expression is required';
267+
}
268+
} else if (!meterData.aggregation?.field?.trim()) {
249269
errors.aggregation_field = 'Aggregation field is required for this aggregation type';
250270
}
251271
}
@@ -698,18 +718,26 @@ const AggregationSection = ({
698718
const { t } = useTranslation(['catalog', 'common']);
699719
const handleAggregationTypeChange = useCallback(
700720
(type: string) => {
721+
const newType = type as METER_AGGREGATION_TYPE;
722+
const stillSupportsExpression = EXPRESSION_SUPPORTED_TYPES.includes(newType);
701723
onUpdateFeature({
702724
meter: {
703725
...meter,
704726
aggregation: {
705727
...meter?.aggregation,
706-
type: type as METER_AGGREGATION_TYPE,
728+
type: newType,
707729
field: meter?.aggregation?.field ?? '',
730+
// Drop expression when switching to a type that can't carry one
731+
// (e.g. COUNT, COUNT_UNIQUE, SUM_WITH_MULTIPLIER, WEIGHTED_SUM).
732+
expression: stillSupportsExpression ? meter?.aggregation?.expression : '',
708733
},
709734
},
710735
});
736+
if (!stillSupportsExpression && formState.showCustomExpression) {
737+
onUpdateFormState({ showCustomExpression: false });
738+
}
711739
},
712-
[onUpdateFeature, meter],
740+
[onUpdateFeature, onUpdateFormState, meter, formState.showCustomExpression],
713741
);
714742

715743
const handleAggregationFieldChange = useCallback(
@@ -728,6 +756,39 @@ const AggregationSection = ({
728756
[onUpdateFeature, meter],
729757
);
730758

759+
const handleAggregationExpressionChange = useCallback(
760+
(expression: string) => {
761+
onUpdateFeature({
762+
meter: {
763+
...meter,
764+
aggregation: {
765+
...meter?.aggregation,
766+
type: meter?.aggregation?.type || METER_AGGREGATION_TYPE.SUM,
767+
expression,
768+
},
769+
},
770+
});
771+
},
772+
[onUpdateFeature, meter],
773+
);
774+
775+
// Toggling the Custom Expression / Aggregation Field button enforces XOR
776+
// in the UI: the inactive side's value is cleared so we never POST both.
777+
const toggleCustomExpression = useCallback(() => {
778+
const next = !formState.showCustomExpression;
779+
onUpdateFeature({
780+
meter: {
781+
...meter,
782+
aggregation: {
783+
...(meter?.aggregation ?? { type: METER_AGGREGATION_TYPE.SUM }),
784+
field: next ? '' : (meter?.aggregation?.field ?? ''),
785+
expression: next ? (meter?.aggregation?.expression ?? '') : '',
786+
},
787+
},
788+
});
789+
onUpdateFormState({ showCustomExpression: next });
790+
}, [onUpdateFeature, onUpdateFormState, meter, formState.showCustomExpression]);
791+
731792
const [multiplierInput, setMultiplierInput] = useState(meter?.aggregation?.multiplier?.toString() || '');
732793

733794
useEffect(() => {
@@ -790,8 +851,11 @@ const AggregationSection = ({
790851
[onUpdateFeature, meter],
791852
);
792853

793-
const showFieldInput = meter?.aggregation?.type !== METER_AGGREGATION_TYPE.COUNT;
794-
const showMultiplierInput = meter?.aggregation?.type === METER_AGGREGATION_TYPE.SUM_WITH_MULTIPLIER;
854+
const aggType = meter?.aggregation?.type;
855+
const supportsExpression = aggType ? EXPRESSION_SUPPORTED_TYPES.includes(aggType) : false;
856+
const showExpressionInput = supportsExpression && formState.showCustomExpression;
857+
const showFieldInput = aggType !== METER_AGGREGATION_TYPE.COUNT && !showExpressionInput;
858+
const showMultiplierInput = aggType === METER_AGGREGATION_TYPE.SUM_WITH_MULTIPLIER;
795859

796860
return (
797861
<>
@@ -819,6 +883,17 @@ const AggregationSection = ({
819883
/>
820884
)}
821885

886+
{showExpressionInput && (
887+
<Input
888+
value={meter?.aggregation?.expression || ''}
889+
onChange={handleAggregationExpressionChange}
890+
label={t('catalog:features.form.customExpression')}
891+
placeholder={t('catalog:features.form.customExpressionPh')}
892+
description={t('catalog:features.form.customExpressionHelp')}
893+
error={meterErrors.aggregation_expression}
894+
/>
895+
)}
896+
822897
{showMultiplierInput && (
823898
<Input
824899
value={multiplierInput}
@@ -841,6 +916,16 @@ const AggregationSection = ({
841916
{meter?.aggregation?.type === METER_AGGREGATION_TYPE.MAX && !formState.showGroupBy ? (
842917
<AddChargesButton label={t('catalog:features.form.groupByButton')} onClick={() => onUpdateFormState({ showGroupBy: true })} />
843918
) : null}
919+
{supportsExpression ? (
920+
<AddChargesButton
921+
label={t(
922+
formState.showCustomExpression
923+
? 'catalog:features.form.aggregationFieldButton'
924+
: 'catalog:features.form.customExpressionButton',
925+
)}
926+
onClick={toggleCustomExpression}
927+
/>
928+
) : null}
844929
</div>
845930
{formState.showBucketSize ? (
846931
<Select
@@ -998,24 +1083,22 @@ const AddFeaturePage = () => {
9981083

9991084
// If type is metered, validate meter data
10001085
if (data.type === FEATURE_TYPE.METERED) {
1001-
if (!validateMeter(data.meter)) {
1086+
if (!validateMeter(data.meter, formState)) {
10021087
return;
10031088
}
10041089
}
10051090

10061091
createFeature(data);
1007-
}, [data, validateFeature, validateMeter, createFeature]);
1092+
}, [data, formState, validateFeature, validateMeter, createFeature]);
10081093

10091094
const isCtaDisabled = useMemo(() => {
1010-
return (
1011-
!data.name ||
1012-
!data.type ||
1013-
isPending ||
1014-
(data.type === FEATURE_TYPE.METERED &&
1015-
(!data.meter?.event_name ||
1016-
!data.meter?.aggregation?.type ||
1017-
(data.meter.aggregation.type !== METER_AGGREGATION_TYPE.COUNT && !data.meter.aggregation?.field)))
1018-
);
1095+
const agg = data.meter?.aggregation;
1096+
const meteredButMissingValue =
1097+
data.type === FEATURE_TYPE.METERED &&
1098+
(!data.meter?.event_name ||
1099+
!agg?.type ||
1100+
(agg.type !== METER_AGGREGATION_TYPE.COUNT && !agg?.field?.trim() && !agg?.expression?.trim()));
1101+
return !data.name || !data.type || isPending || meteredButMissingValue;
10191102
}, [data.name, data.type, data.meter, isPending]);
10201103

10211104
const isMeteredType = data.type === FEATURE_TYPE.METERED;

src/types/dto/Meter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export interface MeterFilter {
1212
export interface MeterAggregation {
1313
type: METER_AGGREGATION_TYPE;
1414
field?: string;
15+
// CEL expression evaluated per event to compute the quantity. Mutually
16+
// exclusive with `field`; only allowed for SUM, AVG, MAX, LATEST.
17+
expression?: string;
1518
multiplier?: number;
1619
bucket_size?: BUCKET_SIZE;
1720
group_by?: string;

0 commit comments

Comments
 (0)