diff --git a/eslint.config.js b/eslint.config.js index d3852e38ecd8..7d95356e7f1d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -85,6 +85,7 @@ export default [ "web-common/src/components/modal/*.js", "web-common/src/features/dashboards/url-state/filters/expression.js", // generated by nearley "web-common/src/features/dashboards/url-state/time-ranges/rill-time.js", // generated by nearley + "web-common/src/features/dashboards/ephemeral-measures/measure-expression.js", // generated by nearley "web-local/build/*", "web-local/playwright-report/*", "web-local/playwright/*", diff --git a/proto/rill/ui/v1/dashboard.proto b/proto/rill/ui/v1/dashboard.proto index 6a7bf6e9319f..f05187d8f302 100644 --- a/proto/rill/ui/v1/dashboard.proto +++ b/proto/rill/ui/v1/dashboard.proto @@ -159,6 +159,9 @@ message DashboardState { // Per-measure conditional formatting (heatmap / data bar) for pivot cells. repeated PivotConditionalFormat pivot_conditional_formatting = 44; + + // Ephemeral measures defined for the explore. + repeated EphemeralMeasure ephemeral_measures = 45; } message DashboardTimeRange { @@ -184,6 +187,19 @@ message PivotElement { } } +// An ephemeral measure defined ad-hoc for an explore dashboard, +// derived from existing metrics view measures via an arithmetic expression. +message EphemeralMeasure { + // Query alias, e.g. "profit". Must not collide with metrics view field names. + string name = 1; + // Display name shown in the UI, e.g. "Profit". + string display_name = 2; + // Arithmetic expression over existing measure names, e.g. "revenue - cost". + string expression = 3; + // Optional format preset for rendering values. + string format_preset = 4; +} + // Conditional formatting applied to a measure's cells in a pivot table. message PivotConditionalFormat { string measure = 1; diff --git a/runtime/canvas/component.go b/runtime/canvas/component.go index fa215d16f17c..368605cbd672 100644 --- a/runtime/canvas/component.go +++ b/runtime/canvas/component.go @@ -592,28 +592,28 @@ func isEncodedTimeDimension(mv *runtimev1.MetricsViewSpec, fieldName string) boo return ok && v != int32(runtimev1.TimeGrain_TIME_GRAIN_UNSPECIFIED) } -// ephemeralMeasureNames extracts and validates the optional "ephemeral_measures" renderer property. +// ephemeralMeasureNames extracts and validates the optional "adhoc_measures" renderer property. // Each entry defines an ephemeral measure derived from existing measures via an arithmetic expression; // the returned set contains the names that may be referenced alongside the metrics view's own measures. func ephemeralMeasureNames(props map[string]any, mvn string, mv *runtimev1.MetricsViewSpec) (map[string]bool, error) { - raw, ok := props["ephemeral_measures"] + raw, ok := props["adhoc_measures"] if !ok || raw == nil { return nil, nil } list, ok := raw.([]any) if !ok { - return nil, errors.New("renderer property 'ephemeral_measures' must be an array") + return nil, errors.New("renderer property 'adhoc_measures' must be an array") } names := make(map[string]bool, len(list)) for _, item := range list { entry, ok := item.(map[string]any) if !ok { - return nil, errors.New("entries in 'ephemeral_measures' must be objects with 'name' and 'expression'") + return nil, errors.New("entries in 'adhoc_measures' must be objects with 'name' and 'expression'") } name, _ := entry["name"].(string) expression, _ := entry["expression"].(string) if name == "" || expression == "" { - return nil, errors.New("entries in 'ephemeral_measures' must have a non-empty 'name' and 'expression'") + return nil, errors.New("entries in 'adhoc_measures' must have a non-empty 'name' and 'expression'") } // Mirror metricsview.AST.checkNameForComputedField, which also rejects the time dimension. // It is often absent from mv.Dimensions, so checking it here surfaces the collision at parse time rather than at query time. diff --git a/runtime/canvas/component_test.go b/runtime/canvas/component_test.go index e9eef9fd509a..5d6b6662f20c 100644 --- a/runtime/canvas/component_test.go +++ b/runtime/canvas/component_test.go @@ -979,7 +979,7 @@ type: component kpi_grid: metrics_view: mv1 measures: [y, profit] - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - z @@ -995,7 +995,7 @@ leaderboard: metrics_view: mv1 measures: [profit] dimensions: [foo] - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - z @@ -1010,7 +1010,7 @@ type: component table: metrics_view: mv1 columns: [foo, y, profit] - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - z @@ -1026,7 +1026,7 @@ pivot: metrics_view: mv1 measures: [profit] row_dimensions: [foo] - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - z @@ -1047,7 +1047,7 @@ bar_chart: field: profit type: quantitative fields: [y, profit] - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - z @@ -1067,7 +1067,7 @@ pie_chart: color: field: foo type: nominal - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - z @@ -1087,7 +1087,7 @@ heatmap: color: field: profit type: quantitative - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - z @@ -1119,7 +1119,7 @@ type: component kpi_grid: metrics_view: mv1 measures: [profit] - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: sum(y) @@ -1135,7 +1135,7 @@ type: component kpi_grid: metrics_view: mv1 measures: [profit] - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - unknown @@ -1151,7 +1151,7 @@ type: component kpi_grid: metrics_view: mv1 measures: [missing] - ephemeral_measures: + adhoc_measures: - name: profit display_name: Profit expression: y - z diff --git a/runtime/drivers/clickhouse/dialect.go b/runtime/drivers/clickhouse/dialect.go index b32f9622624b..baa47eaa8565 100644 --- a/runtime/drivers/clickhouse/dialect.go +++ b/runtime/drivers/clickhouse/dialect.go @@ -93,6 +93,26 @@ func (d *dialect) CastToDataType(typ runtimev1.Type_Code) (string, error) { } } +// OrderByExpression and OrderByAliasExpression place NULLs last regardless of the sort direction, +// matching the behavior of the DuckDB dialect. +func (d *dialect) OrderByExpression(name string, desc bool) string { + res := d.EscapeIdentifier(name) + if desc { + res += " DESC" + } + res += " NULLS LAST" + return res +} + +func (d *dialect) OrderByAliasExpression(name string, desc bool) string { + res := d.EscapeAlias(name) + if desc { + res += " DESC" + } + res += " NULLS LAST" + return res +} + func (d *dialect) JoinOnExpression(lhs, rhs string) string { return fmt.Sprintf("isNotDistinctFrom(%s, %s)", lhs, rhs) } diff --git a/runtime/drivers/dialect.go b/runtime/drivers/dialect.go index 445f2ee5323a..d7153c6c5140 100644 --- a/runtime/drivers/dialect.go +++ b/runtime/drivers/dialect.go @@ -275,8 +275,9 @@ func (b *BaseDialect) CastToDataType(typ runtimev1.Type_Code) (string, error) { } } +// SafeDivideExpression returns a division that yields NULL instead of an error, infinity or NaN when the denominator is zero. func (b *BaseDialect) SafeDivideExpression(numExpr, denExpr string) string { - return fmt.Sprintf("(%s)/CAST(%s AS DOUBLE)", numExpr, denExpr) + return fmt.Sprintf("(%s)/NULLIF(CAST(%s AS DOUBLE), 0)", numExpr, denExpr) } func (b *BaseDialect) OrderByExpression(name string, desc bool) string { diff --git a/runtime/metricsview/ast.go b/runtime/metricsview/ast.go index 49b823dde57c..67fd5b9c9496 100644 --- a/runtime/metricsview/ast.go +++ b/runtime/metricsview/ast.go @@ -493,7 +493,7 @@ func (a *AST) ResolveMeasure(qm Measure, visible bool) (*runtimev1.MetricsViewSp } if qm.Compute.ComparisonValue != nil { - m, err := a.LookupMeasure(qm.Compute.ComparisonValue.Measure, visible) + m, err := a.resolveReferencedMeasure(qm.Compute.ComparisonValue.Measure, visible) if err != nil { return nil, err } @@ -513,7 +513,7 @@ func (a *AST) ResolveMeasure(qm Measure, visible bool) (*runtimev1.MetricsViewSp } if qm.Compute.ComparisonDelta != nil { - m, err := a.LookupMeasure(qm.Compute.ComparisonDelta.Measure, visible) + m, err := a.resolveReferencedMeasure(qm.Compute.ComparisonDelta.Measure, visible) if err != nil { return nil, err } @@ -533,7 +533,7 @@ func (a *AST) ResolveMeasure(qm Measure, visible bool) (*runtimev1.MetricsViewSp } if qm.Compute.ComparisonRatio != nil { - m, err := a.LookupMeasure(qm.Compute.ComparisonRatio.Measure, visible) + m, err := a.resolveReferencedMeasure(qm.Compute.ComparisonRatio.Measure, visible) if err != nil { return nil, err } @@ -559,7 +559,7 @@ func (a *AST) ResolveMeasure(qm Measure, visible bool) (*runtimev1.MetricsViewSp return nil, fmt.Errorf("totals not computed for %s", qm.Name) } - m, err := a.LookupMeasure(qm.Compute.PercentOfTotal.Measure, visible) + m, err := a.resolveReferencedMeasure(qm.Compute.PercentOfTotal.Measure, visible) if err != nil { return nil, err } @@ -731,6 +731,27 @@ func (a *AST) LookupMeasure(name string, visible bool) (*runtimev1.MetricsViewSp return nil, fmt.Errorf("measure %q not found", name) } +// resolveReferencedMeasure resolves a measure referenced by name from another measure, +// such as the base measure of a comparison delta or a measure referenced by a derived measure. +// The referenced measure is either a measure in the metrics view or an expression measure defined in the same query, +// which lets ad-hoc measures support comparisons without being declared in the metrics view. +// Expression measures may only reference metrics view measures, so the recursion is at most one level deep. +func (a *AST) resolveReferencedMeasure(name string, visible bool) (*runtimev1.MetricsViewSpec_Measure, error) { + for _, m := range a.MetricsView.Measures { + if m.Name == name { + return a.LookupMeasure(name, visible) + } + } + + for _, qm := range a.Query.Measures { + if qm.Name == name && qm.Compute != nil && qm.Compute.Expression != nil { + return a.ResolveMeasure(qm, visible) + } + } + + return nil, fmt.Errorf("measure %q not found", name) +} + // GenerateIdentifier generates a unique table identifier for use in the AST. func (a *AST) GenerateIdentifier() string { tmp := fmt.Sprintf("t%d", a.nextIdentifier) @@ -1065,7 +1086,7 @@ func (a *AST) addReferencedMeasuresToScope(n *SelectNode, referencedMeasures []s for _, rm := range referencedMeasures { // Note we pass visible==false because the measure won't be projected into the current node's SELECT list, only brought into scope for derived measures. - m, err := a.LookupMeasure(rm, false) + m, err := a.resolveReferencedMeasure(rm, false) if err != nil { return err } diff --git a/runtime/metricsview/executor/executor_rewrite_percent_of_totals.go b/runtime/metricsview/executor/executor_rewrite_percent_of_totals.go index c21d027b78e8..acfee9498861 100644 --- a/runtime/metricsview/executor/executor_rewrite_percent_of_totals.go +++ b/runtime/metricsview/executor/executor_rewrite_percent_of_totals.go @@ -14,9 +14,13 @@ func (e *Executor) rewritePercentOfTotals(ctx context.Context, qry *metricsview. var measureIndices []int for i, measure := range qry.Measures { if measure.Compute != nil && measure.Compute.PercentOfTotal != nil { - measures = append(measures, metricsview.Measure{ - Name: measure.Compute.PercentOfTotal.Measure, - }) + // The referenced measure is usually a metrics view measure, but it may also be an expression measure defined in the same query, + // in which case the totals query must carry its expression compute. + totalOf := metricsview.Measure{Name: measure.Compute.PercentOfTotal.Measure} + if qm, ok := queryExpressionMeasure(qry, totalOf.Name); ok { + totalOf = qm + } + measures = append(measures, totalOf) measureIndices = append(measureIndices, i) } } diff --git a/runtime/metricsview/executor/executor_rewrite_rollup.go b/runtime/metricsview/executor/executor_rewrite_rollup.go index 5e04e2695a33..b2e9fc60bc5d 100644 --- a/runtime/metricsview/executor/executor_rewrite_rollup.go +++ b/runtime/metricsview/executor/executor_rewrite_rollup.go @@ -408,6 +408,12 @@ func rollupEligible(rollup *runtimev1.MetricsViewSpec_Rollup, qry *metricsview.Q return false, rejectComputedMeasure, nil } for _, refName := range refNames { + // A comparison compute may reference an expression measure defined in the same query. + // That measure is not in the rollup, but the measures it references may be, so check those instead. + // Its own rollup safety is checked when the loop reaches it. + if _, ok := queryExpressionMeasure(qry, refName); ok { + continue + } if !rollupMeasures[strings.ToLower(refName)] { return false, rejectMeasureMissing, nil } @@ -583,6 +589,16 @@ func rollupSafeReferencedMeasures(c *metricsview.MeasureCompute) ([]string, bool return nil, false } +// queryExpressionMeasure returns the expression measure with the given name defined in the query, if any. +func queryExpressionMeasure(qry *metricsview.Query, name string) (metricsview.Measure, bool) { + for _, qm := range qry.Measures { + if qm.Name == name && qm.Compute != nil && qm.Compute.Expression != nil { + return qm, true + } + } + return metricsview.Measure{}, false +} + // normalizeTimezone validates and normalizes a timezone string for comparison. // It normalizes UTC variants (empty, "UTC", "Etc/UTC") to "UTC". // Note: Go's time.LoadLocation preserves the input name, so aliases like "US/Eastern" diff --git a/runtime/queries/metricsview_aggregation_expression_test.go b/runtime/queries/metricsview_aggregation_expression_test.go index 3d6fc66868ce..dd72c6f93fb5 100644 --- a/runtime/queries/metricsview_aggregation_expression_test.go +++ b/runtime/queries/metricsview_aggregation_expression_test.go @@ -2,6 +2,7 @@ package queries_test import ( "context" + "math" "testing" "time" @@ -11,6 +12,7 @@ import ( "github.com/rilldata/rill/runtime/queries" "github.com/rilldata/rill/runtime/testruntime" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -348,3 +350,221 @@ func TestMetricsViewTimeSeries_expression_rejects_other_computes(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "only the `expression` compute is supported") } + +func comparisonDeltaMeasure(name, measure string) *runtimev1.MetricsViewAggregationMeasure { + return &runtimev1.MetricsViewAggregationMeasure{ + Name: name, + Compute: &runtimev1.MetricsViewAggregationMeasure_ComparisonDelta{ + ComparisonDelta: &runtimev1.MetricsViewAggregationMeasureComputeComparisonDelta{Measure: measure}, + }, + } +} + +func comparisonRatioMeasure(name, measure string) *runtimev1.MetricsViewAggregationMeasure { + return &runtimev1.MetricsViewAggregationMeasure{ + Name: name, + Compute: &runtimev1.MetricsViewAggregationMeasure_ComparisonRatio{ + ComparisonRatio: &runtimev1.MetricsViewAggregationMeasureComputeComparisonRatio{Measure: measure}, + }, + } +} + +func comparisonValueMeasure(name, measure string) *runtimev1.MetricsViewAggregationMeasure { + return &runtimev1.MetricsViewAggregationMeasure{ + Name: name, + Compute: &runtimev1.MetricsViewAggregationMeasure_ComparisonValue{ + ComparisonValue: &runtimev1.MetricsViewAggregationMeasureComputeComparisonValue{Measure: measure}, + }, + } +} + +func percentOfTotalMeasure(name, measure string) *runtimev1.MetricsViewAggregationMeasure { + return &runtimev1.MetricsViewAggregationMeasure{ + Name: name, + Compute: &runtimev1.MetricsViewAggregationMeasure_PercentOfTotal{ + PercentOfTotal: &runtimev1.MetricsViewAggregationMeasureComputePercentOfTotal{Measure: measure}, + }, + } +} + +// Comparison computes may reference an expression measure defined in the same query. +// "doubled" is 2 * measure_1, so its comparison value and delta are twice those of measure_1 and its ratio is the same. +func TestMetricsViewsAggregation_expression_comparison_of_expression(t *testing.T) { + rt, instanceID := testruntime.NewInstanceForProject(t, "ad_bids") + + limit := int64(10) + q := &queries.MetricsViewAggregation{ + MetricsViewName: "ad_bids_metrics", + Dimensions: []*runtimev1.MetricsViewAggregationDimension{ + {Name: "pub"}, + }, + Measures: []*runtimev1.MetricsViewAggregationMeasure{ + {Name: "measure_1"}, + comparisonValueMeasure("measure_1__prev", "measure_1"), + comparisonDeltaMeasure("measure_1__delta", "measure_1"), + comparisonRatioMeasure("measure_1__ratio", "measure_1"), + expressionMeasure("doubled", "measure_1 * 2"), + comparisonValueMeasure("doubled__prev", "doubled"), + comparisonDeltaMeasure("doubled__delta", "doubled"), + comparisonRatioMeasure("doubled__ratio", "doubled"), + }, + Sort: []*runtimev1.MetricsViewAggregationSort{ + {Name: "doubled__delta", Desc: true}, + }, + TimeRange: &runtimev1.TimeRange{ + Start: timestamppb.New(time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)), + End: timestamppb.New(time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + ComparisonTimeRange: &runtimev1.TimeRange{ + Start: timestamppb.New(time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC)), + End: timestamppb.New(time.Date(2022, 1, 3, 0, 0, 0, 0, time.UTC)), + }, + Limit: &limit, + SecurityClaims: testClaims(), + } + err := q.Resolve(context.Background(), rt, instanceID, 0) + require.NoError(t, err) + require.NotEmpty(t, q.Result.Data) + + checked := 0 + for _, row := range q.Result.Data { + f := row.Fields + if _, isNull := f["measure_1__delta"].GetKind().(*structpb.Value_NullValue); isNull { + // Rows present in only one of the time ranges have no comparison. Skip them. + continue + } + checked++ + require.InDelta(t, 2*f["measure_1"].GetNumberValue(), f["doubled"].GetNumberValue(), 1e-9) + require.InDelta(t, 2*f["measure_1__prev"].GetNumberValue(), f["doubled__prev"].GetNumberValue(), 1e-9) + require.InDelta(t, 2*f["measure_1__delta"].GetNumberValue(), f["doubled__delta"].GetNumberValue(), 1e-9) + require.InDelta(t, f["measure_1__ratio"].GetNumberValue(), f["doubled__ratio"].GetNumberValue(), 1e-9) + } + require.NotZero(t, checked) + + // The rows are sorted by the expression measure's delta. + prev := math.Inf(1) + for _, row := range q.Result.Data { + v := row.Fields["doubled__delta"] + if _, isNull := v.GetKind().(*structpb.Value_NullValue); isNull { + continue + } + require.LessOrEqual(t, v.GetNumberValue(), prev) + prev = v.GetNumberValue() + } +} + +// Percent of total may reference an expression measure defined in the same query. +// The totals query must carry the expression too. +func TestMetricsViewsAggregation_expression_percent_of_total_of_expression(t *testing.T) { + rt, instanceID := testruntime.NewInstanceForProject(t, "ad_bids_2rows") + + q := &queries.MetricsViewAggregation{ + MetricsViewName: "ad_bids_metrics", + Dimensions: []*runtimev1.MetricsViewAggregationDimension{ + {Name: "domain"}, + }, + Measures: []*runtimev1.MetricsViewAggregationMeasure{ + {Name: "measure_2"}, + percentOfTotalMeasure("measure_2__pot", "measure_2"), + expressionMeasure("tripled", "measure_2 * 3"), + percentOfTotalMeasure("tripled__pot", "tripled"), + }, + Sort: []*runtimev1.MetricsViewAggregationSort{ + {Name: "domain"}, + }, + SecurityClaims: testClaims(), + } + err := q.Resolve(context.Background(), rt, instanceID, 0) + require.NoError(t, err) + require.Len(t, q.Result.Data, 2) + for _, row := range q.Result.Data { + f := row.Fields + require.InDelta(t, f["measure_2__pot"].GetNumberValue(), f["tripled__pot"].GetNumberValue(), 1e-9) + } + // msn.com has 2 of 3 impressions, yahoo.com has 1 of 3. + require.InDelta(t, 2.0/3.0, q.Result.Data[0].Fields["tripled__pot"].GetNumberValue(), 1e-9) + require.InDelta(t, 1.0/3.0, q.Result.Data[1].Fields["tripled__pot"].GetNumberValue(), 1e-9) +} + +func TestMetricsViewsAggregation_expression_comparison_errors(t *testing.T) { + rt, instanceID := testruntime.NewInstanceForProject(t, "ad_bids_2rows") + + cases := []struct { + name string + measures []*runtimev1.MetricsViewAggregationMeasure + errContains string + }{ + { + name: "unknown measure", + measures: []*runtimev1.MetricsViewAggregationMeasure{comparisonDeltaMeasure("x", "unknown_measure")}, + errContains: "not found", + }, + { + name: "expression measure not in the query", + measures: []*runtimev1.MetricsViewAggregationMeasure{comparisonDeltaMeasure("x", "profit")}, + errContains: `measure "profit" not found`, + }, + { + name: "expression measure with an invalid expression", + measures: []*runtimev1.MetricsViewAggregationMeasure{ + expressionMeasure("profit", "sum(measure_1)"), + comparisonRatioMeasure("x", "profit"), + }, + errContains: "aggregate function", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + q := &queries.MetricsViewAggregation{ + MetricsViewName: "ad_bids_metrics", + Measures: c.measures, + TimeRange: &runtimev1.TimeRange{ + Start: timestamppb.New(time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)), + End: timestamppb.New(time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + ComparisonTimeRange: &runtimev1.TimeRange{ + Start: timestamppb.New(time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC)), + End: timestamppb.New(time.Date(2022, 1, 3, 0, 0, 0, 0, time.UTC)), + }, + SecurityClaims: testClaims(), + } + err := q.Resolve(context.Background(), rt, instanceID, 0) + require.Error(t, err) + require.Contains(t, err.Error(), c.errContains) + }) + } +} + +// A comparison of an expression measure is subject to the same security policy as the expression itself. +func TestMetricsViewsAggregation_expression_comparison_security(t *testing.T) { + rt, instanceID := testruntime.NewInstanceForProject(t, "ad_bids") + + newQuery := func() *queries.MetricsViewAggregation { + return &queries.MetricsViewAggregation{ + MetricsViewName: "ad_bids_mini_metrics_with_policy", + Measures: []*runtimev1.MetricsViewAggregationMeasure{ + expressionMeasure("net", `"total impressions" - "total volume"`), + comparisonDeltaMeasure("net__delta", "net"), + }, + TimeRange: &runtimev1.TimeRange{ + Start: timestamppb.New(time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)), + End: timestamppb.New(time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + ComparisonTimeRange: &runtimev1.TimeRange{ + Start: timestamppb.New(time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC)), + End: timestamppb.New(time.Date(2022, 1, 3, 0, 0, 0, 0, time.UTC)), + }, + } + } + + q := newQuery() + q.SecurityClaims = &runtime.SecurityClaims{UserAttributes: map[string]any{"domain": "yahoo.com", "email": "user@yahoo.com"}} + err := q.Resolve(context.Background(), rt, instanceID, 0) + require.Error(t, err) + require.ErrorContains(t, err, "total volume") + + q = newQuery() + q.SecurityClaims = &runtime.SecurityClaims{UserAttributes: map[string]any{"domain": "msn.com", "email": "user@msn.com"}} + err = q.Resolve(context.Background(), rt, instanceID, 0) + require.NoError(t, err) +} diff --git a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte index 4dc7d436f99e..008d4f47bff5 100644 --- a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte +++ b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte @@ -82,9 +82,13 @@ dashboardKind, expressionFilterManager, ); - let { fields, sanitizedState, queryTimeStart, queryTimeEnd } = $derived( - $sanitisedFilterState, - ); + let { + fields, + sanitizedState, + droppedEphemeralMeasures, + queryTimeStart, + queryTimeEnd, + } = $derived($sanitisedFilterState); const formId = "create-public-url-form"; @@ -259,6 +263,24 @@ {/if} {/if} + {#if droppedEphemeralMeasures.length > 0} +
+ {m.public_url_adhoc_measures_omitted()} +
+