diff --git a/packages/webapp/src/hooks/usePlan.tsx b/packages/webapp/src/hooks/usePlan.tsx index 74a5380d92..1083c4b134 100644 --- a/packages/webapp/src/hooks/usePlan.tsx +++ b/packages/webapp/src/hooks/usePlan.tsx @@ -278,6 +278,43 @@ export function useApiGetBillingUsageTopDimensionValues( }); } +/** + * 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>( + 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 => { + 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 diff --git a/packages/webapp/src/pages/Team/Billing/components/Usage.tsx b/packages/webapp/src/pages/Team/Billing/components/Usage.tsx index 78dd70f6d7..349c4ca029 100644 --- a/packages/webapp/src/pages/Team/Billing/components/Usage.tsx +++ b/packages/webapp/src/pages/Team/Billing/components/Usage.tsx @@ -46,7 +46,7 @@ export const Usage: React.FC = ({ 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 ; @@ -83,7 +83,7 @@ export const Usage: React.FC = ({ selectedMonth }) => { env={env} timeframe={timeframe} isDivergingFromGlobal={isDivergingFromGlobal} - onApplyToAll={applyToAll} + onApplyToAll={(selection) => void applyToAll(selection)} /> ))} diff --git a/packages/webapp/src/pages/Team/Billing/components/UsageChartCard.tsx b/packages/webapp/src/pages/Team/Billing/components/UsageChartCard.tsx index 8361d14542..afe0cb5892 100644 --- a/packages/webapp/src/pages/Team/Billing/components/UsageChartCard.tsx +++ b/packages/webapp/src/pages/Team/Billing/components/UsageChartCard.tsx @@ -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'; @@ -61,11 +61,26 @@ export const UsageChartCard: React.FC = ({ 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(() => { 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 = () => { @@ -95,7 +110,7 @@ export const UsageChartCard: React.FC = ({ 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)' }; } diff --git a/packages/webapp/src/pages/Team/Billing/usageChartSeries.ts b/packages/webapp/src/pages/Team/Billing/usageChartSeries.ts index 227df50ae8..a1e0bd7262 100644 --- a/packages/webapp/src/pages/Team/Billing/usageChartSeries.ts +++ b/packages/webapp/src/pages/Team/Billing/usageChartSeries.ts @@ -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) => { diff --git a/packages/webapp/src/pages/Team/Billing/usageChartSeries.unit.test.ts b/packages/webapp/src/pages/Team/Billing/usageChartSeries.unit.test.ts index 68750011f0..e6a786ba7d 100644 --- a/packages/webapp/src/pages/Team/Billing/usageChartSeries.unit.test.ts +++ b/packages/webapp/src/pages/Team/Billing/usageChartSeries.unit.test.ts @@ -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 + }); }); diff --git a/packages/webapp/src/pages/Team/Billing/useGlobalGroupFilter.ts b/packages/webapp/src/pages/Team/Billing/useGlobalGroupFilter.ts index 4a8db2438e..1cea7a3d94 100644 --- a/packages/webapp/src/pages/Team/Billing/useGlobalGroupFilter.ts +++ b/packages/webapp/src/pages/Team/Billing/useGlobalGroupFilter.ts @@ -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'; @@ -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 = {}; for (const m of metrics) { @@ -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. @@ -46,7 +51,7 @@ export function useGlobalGroupFilter(metrics: readonly UsageMetric[]) { ); const applyToAll = useCallback( - (sel: GroupFilterSelection) => { + async (sel: GroupFilterSelection) => { const updates: Record = {}; // Group: copy to supporting metrics when set; clear on every metric when null. if (sel.group !== null) { @@ -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( @@ -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 };