Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/webapp/src/hooks/usePlan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,43 @@ export function useApiGetBillingUsageTopDimensionValues<M extends UsageMetric>(
});
}

/**
* Resolvers for "does (metric, dimension) hold value V", read against the top-dimension-values
* cache. Backs "Apply to all": a filter should only fan out to metrics that actually carry the
* value, and for a non-searchable dimension the first page is the complete value set, so absence
* from it means the metric has no data for the value. `cachedHasValue` reads the warm cache
* synchronously (undefined when nothing is cached yet, so callers stay optimistic until it lands);
* `ensureHasValue` fetches on demand. Both read the no-search (first page) cache the filter popover
* and label lookups already populate.
*/
export function useBillingUsageValueAvailability(env: string, timeframe: { start: string; end: string } | undefined) {
const queryClient = useQueryClient();
const cachedHasValue = useCallback(
(metric: UsageMetric, dimension: string, value: string): boolean | undefined => {
const cached = queryClient.getQueryData<InfiniteData<GetBillingUsageTopDimensionValues['Success']>>(
topDimensionValuesQueryKey(timeframe, metric, dimension, '')
);
return cached ? cached.pages.some((p) => p.data.values.some((v) => v.id === value)) : undefined;
},
[queryClient, timeframe]
);
const ensureHasValue = useCallback(
async (metric: UsageMetric, dimension: string, value: string): Promise<boolean> => {
const data = await queryClient.ensureInfiniteQueryData({
staleTime: TOP_DIMENSION_VALUES_STALE_TIME,
queryKey: topDimensionValuesQueryKey(timeframe, metric, dimension, ''),
queryFn: fetchTopDimensionValuesPage(env, metric, dimension, timeframe, ''),
initialPageParam: 0,
getNextPageParam: (lastPage: GetBillingUsageTopDimensionValues['Success']) =>
lastPage.data.pagination.hasMore ? lastPage.data.pagination.page + 1 : undefined
});
return data.pages.some((p) => p.data.values.some((v) => v.id === value));
},
[queryClient, env, timeframe]
);
return { cachedHasValue, ensureHasValue };
}

/**
* Returns a callback that warms the value cache (first page, no search) for a set of dimensions.
* Call it when the filter popover opens so picking a dimension shows its values instantly instead
Expand Down
4 changes: 2 additions & 2 deletions packages/webapp/src/pages/Team/Billing/components/Usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const Usage: React.FC<UsageProps> = ({ selectedMonth }) => {

const { data: usage, isLoading, error: usageError } = useApiGetBillingUsage(env, timeframe, source);

const { isDivergingFromGlobal, applyToAll } = useGlobalGroupFilter(METRICS);
const { isDivergingFromGlobal, applyToAll } = useGlobalGroupFilter(METRICS, env, timeframe);

if (usageError) {
return <CriticalErrorAlert message="Error loading usage" />;
Expand Down Expand Up @@ -83,7 +83,7 @@ export const Usage: React.FC<UsageProps> = ({ selectedMonth }) => {
env={env}
timeframe={timeframe}
isDivergingFromGlobal={isDivergingFromGlobal}
onApplyToAll={applyToAll}
onApplyToAll={(selection) => void applyToAll(selection)}
/>
))}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useMemo } from 'react';

import { ChartCard } from '@/components/patterns/chart';
import { colorsForValues } from '@/components/patterns/chart/usageChartColors';
import { useApiGetBillingUsageDetail } from '@/hooks/usePlan';
import { useApiGetBillingUsageDetail, useApiGetBillingUsageTopDimensionValues } from '@/hooks/usePlan';
import { BREAKDOWN_DIMENSIONS, DEFAULT_TOP_N, formatDimensionValue, parseFilterParam, resolveBreakdownDimension } from '../usageBreakdown';
import { toChartSeries } from '../usageChartSeries';
import { useBreakdownEnabled } from '../useBreakdownEnabled';
Expand Down Expand Up @@ -61,11 +61,26 @@ export const UsageChartCard: React.FC<UsageChartCardProps> = ({ metric, data, is
const detailQuery = useApiGetBillingUsageDetail(env, timeframe, metric, { dimension, filter }, DEFAULT_TOP_N, { enabled: isDetail });
const detailMetric = detailQuery.data?.data.usage[metric];

// `environment_id` values are stored/returned as raw env ids; only top-dimension-values carries
// the display name. Fetch it when an env grouping or filter is active (shares the cache the filter
// chip already warms) and resolve ids → names for the chart's series labels, matching the chip.
const envInvolved = dimension === 'environment_id' || filter?.dimension === 'environment_id';
// No search term (''): environment_id's full set fits the first page (it's a non-searchable dim).
const envValuesQuery = useApiGetBillingUsageTopDimensionValues(env, metric, envInvolved ? 'environment_id' : null, timeframe, '', {
enabled: envInvolved
});
// The display label for a raw dim value: env ids resolve to names, everything else formats as-is.
const labelForValue = useMemo(() => {
const values = envValuesQuery.data?.pages.flatMap((p) => p.data.values) ?? [];
const byId = new Map(values.map((v) => [v.id, v.label] as const));
return (dim: AnyBreakdownDimension, value: string) => (dim === 'environment_id' ? (byId.get(value) ?? value) : formatDimensionValue(dim, value));
}, [envValuesQuery.data]);

const breakdownEntries = detailMetric?.breakdown;
const breakdownSeries = useMemo<ChartSeries[] | undefined>(() => {
if (!inBreakdownMode || dimension === null) return undefined;
return breakdownEntries ? toChartSeries(breakdownEntries, dimension) : [];
}, [inBreakdownMode, dimension, breakdownEntries]);
return breakdownEntries ? toChartSeries(breakdownEntries, dimension, (value) => labelForValue(dimension, value)) : [];
}, [inBreakdownMode, dimension, breakdownEntries, labelForValue]);

// Group and filter are independent slots: clearing the filter leaves the grouping untouched.
const clearFilter = () => {
Expand Down Expand Up @@ -95,7 +110,7 @@ export const UsageChartCard: React.FC<UsageChartCardProps> = ({ metric, data, is
// a one-row legend. When also grouped, the breakdown series own the colours and legend.
let singleSeries: { label: string; color: string } | undefined;
if (inFilterMode && !inBreakdownMode && filter) {
const label = formatDimensionValue(filter.dimension, filter.value);
const label = labelForValue(filter.dimension, filter.value);
singleSeries = { label, color: colorsForValues([label], filter.dimension).get(label) ?? 'var(--ds-color-brand-500)' };
}

Expand Down
15 changes: 12 additions & 3 deletions packages/webapp/src/pages/Team/Billing/usageChartSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ import type { ChartSeries } from '../../../components/patterns/chart/types';
import type { AnyBreakdownDimension } from './usageBreakdown';
import type { BillingUsageMetric } from '@nangohq/types';

/** Map breakdown entries to stacked chart series: largest usage first, with the 'rest' rollup last. */
export function toChartSeries(entries: BillingUsageMetric[], dimension: AnyBreakdownDimension): ChartSeries[] {
/**
* Map breakdown entries to stacked chart series: largest usage first, with the 'rest' rollup last.
* `labelForValue` turns a raw dim value into its display label; it defaults to `formatDimensionValue`
* but callers pass a resolver for dimensions whose stored value isn't the label (e.g. `environment_id`,
* whose value is an id resolved to a name via top-dimension-values).
*/
export function toChartSeries(
entries: BillingUsageMetric[],
dimension: AnyBreakdownDimension,
labelForValue: (value: string) => string = (value) => formatDimensionValue(dimension, value)
): ChartSeries[] {
const ranked = entries.filter((e) => !e.isRest).sort((a, b) => b.total - a.total);
const labels = ranked.map((entry) => (entry.group ? formatDimensionValue(dimension, entry.group.value) : '—'));
const labels = ranked.map((entry) => (entry.group ? labelForValue(entry.group.value) : '—'));
// Resolve all of this chart's colors at once so no two series share a color (see colorsForValues).
const colors = colorsForValues(labels, dimension);
const series: ChartSeries[] = ranked.map((entry, i) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,11 @@ describe('toChartSeries', () => {
const e = entry({ value: 'a', total: 8 });
expect(toChartSeries([e], 'integration_id')[0].usage).toBe(e.usage);
});

it('resolves labels via labelForValue while keeping the raw value for drill-in', () => {
const names = new Map([['105', 'dev']]);
const series = toChartSeries([entry({ value: '105', total: 7 })], 'environment_id', (v) => names.get(v) ?? v);
expect(series[0].label).toBe('dev'); // env id resolved to its name for display
expect(series[0].value).toBe('105'); // raw id preserved so a drill-in builds a valid filter param
});
});
51 changes: 41 additions & 10 deletions packages/webapp/src/pages/Team/Billing/useGlobalGroupFilter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { parseAsString, useQueryStates } from 'nuqs';
import { useCallback, useMemo } from 'react';

import { metricsSupportingDimension } from './usageBreakdown';
import { useBillingUsageValueAvailability } from '@/hooks/usePlan';
import { isSearchableDimension, metricsSupportingDimension } from './usageBreakdown';

import type { AnyBreakdownDimension } from './usageBreakdown';
import type { UsageMetric } from '@nangohq/types';
Expand All @@ -23,7 +24,7 @@ export interface GroupFilterSelection {
* `isDivergingFromGlobal` reports whether applying would change at least one other
* applicable panel — the signal for showing the button.
*/
export function useGlobalGroupFilter(metrics: readonly UsageMetric[]) {
export function useGlobalGroupFilter(metrics: readonly UsageMetric[], env: string, timeframe: { start: string; end: string }) {
const params = useMemo(() => {
const p: Record<string, typeof breakdownParam> = {};
for (const m of metrics) {
Expand All @@ -34,6 +35,10 @@ export function useGlobalGroupFilter(metrics: readonly UsageMetric[]) {
}, [metrics]);
const [values, setValues] = useQueryStates(params);

// Backs the "skip metrics with no data for the filter value" behaviour below. Keyed on the same
// top-N the filter popover uses so it reads the cache that's already warm from picking the value.
const { cachedHasValue, ensureHasValue } = useBillingUsageValueAvailability(env, timeframe);

// Metrics this hook manages (i.e. on screen) that support `dim`. Intersecting with
// `metrics` stops metrics that aren't displayed — and so never appear in the URL —
// from reading as a permanent divergence.
Expand All @@ -46,7 +51,7 @@ export function useGlobalGroupFilter(metrics: readonly UsageMetric[]) {
);

const applyToAll = useCallback(
(sel: GroupFilterSelection) => {
async (sel: GroupFilterSelection) => {
const updates: Record<string, string | null> = {};
// Group: copy to supporting metrics when set; clear on every metric when null.
if (sel.group !== null) {
Expand All @@ -56,14 +61,27 @@ export function useGlobalGroupFilter(metrics: readonly UsageMetric[]) {
}
// Filter: copy to supporting metrics when set; clear on every metric when null.
if (sel.filter !== null) {
const value = `${sel.filter.dimension}:${sel.filter.value}`;
for (const m of supporting(sel.filter.dimension)) updates[`${m}.filter`] = value;
const { dimension, value } = sel.filter;
const filterValue = `${dimension}:${value}`;
const targets = supporting(dimension);
// Non-searchable dims have a small, fully-listed value set (the first page is
// complete), so a value's absence from a metric's list reliably means "no data".
if (!isSearchableDimension(dimension)) {
// Only fan the filter out to metrics that actually have data for this value, and
// clear it on the rest — so a metric with no data for the value falls back to its
// unfiltered chart instead of showing the raw value with "No data". On a lookup
// failure, default to applying (optimistic) rather than silently dropping it.
const availability = await Promise.all(targets.map((m) => ensureHasValue(m, dimension, value).catch(() => true)));
targets.forEach((m, i) => (updates[`${m}.filter`] = availability[i] ? filterValue : null));
} else {
for (const m of targets) updates[`${m}.filter`] = filterValue;
}
} else {
for (const m of metrics) updates[`${m}.filter`] = null;
}
if (Object.keys(updates).length > 0) void setValues(updates);
},
[setValues, supporting, metrics]
[setValues, supporting, metrics, ensureHasValue]
);

const isDivergingFromGlobal = useCallback(
Expand All @@ -79,12 +97,25 @@ export function useGlobalGroupFilter(metrics: readonly UsageMetric[]) {
const groupTargets = sel.group !== null ? supporting(sel.group) : metrics;
const groupTarget = sel.group ?? 'none';
const groupDiverges = others(groupTargets).some((m) => (values[`${m}.breakdown`] ?? 'none') !== groupTarget);
const filterTargets = sel.filter !== null ? supporting(sel.filter.dimension) : metrics;
const filterTarget = sel.filter ? `${sel.filter.dimension}:${sel.filter.value}` : '';
const filterDiverges = others(filterTargets).some((m) => (values[`${m}.filter`] ?? '') !== filterTarget);
let filterDiverges: boolean;
if (sel.filter === null) {
filterDiverges = others(metrics).some((m) => (values[`${m}.filter`] ?? '') !== '');
} else {
const { dimension, value } = sel.filter;
const filterValue = `${dimension}:${value}`;
// A non-searchable filter targets a metric to unfiltered ('') when the cache says it
// has no data for the value — matching what applyToAll writes — so those panels don't
// read as a permanent divergence and keep the button up. Stay optimistic (target the
// value) while the cache is still cold.
const fullyListed = !isSearchableDimension(dimension);
filterDiverges = others(supporting(dimension)).some((m) => {
const target = fullyListed && cachedHasValue(m, dimension, value) === false ? '' : filterValue;
return (values[`${m}.filter`] ?? '') !== target;
});
}
return groupDiverges || filterDiverges;
},
[values, supporting, metrics]
[values, supporting, metrics, cachedHasValue]
);

return { isDivergingFromGlobal, applyToAll };
Expand Down
Loading