Skip to content

Commit 2fded49

Browse files
atrakhConvex, Inc.
authored andcommitted
dashboard: support group by project in usage (#41845)
Adds the ability to group usage data by project on the dashboard & fixes / improves some styling and formatting GitOrigin-RevId: cb1c4408b631f12836cc83b2d26ffeb2bfaa67ed
1 parent e72a84a commit 2fded49

File tree

13 files changed

+1136
-481
lines changed

13 files changed

+1136
-481
lines changed

npm-packages/dashboard-common/src/elements/Calendar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function Calendar({ ...props }: CalendarProps) {
3333
"h-8 w-8 p-0 font-normal aria-selected:opacity-100",
3434
"hover:rounded hover:bg-background-primary",
3535
),
36-
day_selected: "bg-background-tertiary rounded border",
36+
day_selected: "bg-background-tertiary border-y",
3737
day_range_start: "rounded-l bg-background-tertiary border",
3838
day_range_end: "rounded-r bg-background-tertiary border",
3939
day_range_middle:

npm-packages/dashboard-common/src/elements/ChartTooltip.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,17 @@ export function ChartTooltip({
2525
className="flex items-center justify-end gap-1 tabular-nums"
2626
>
2727
{showLegend && (
28-
<div
29-
className="h-0.5 w-2.5"
30-
style={{
31-
backgroundColor: dataPoint.stroke,
32-
}}
33-
/>
28+
<svg className="w-3" viewBox="0 0 50 50" aria-hidden>
29+
<circle
30+
cx="20"
31+
cy="20"
32+
r="20"
33+
className={dataPoint.className}
34+
style={{
35+
fill: dataPoint.stroke || dataPoint.fill || undefined,
36+
}}
37+
/>
38+
</svg>
3439
)}
3540

3641
{dataPoint.formattedValue
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { useMemo } from "react";
2+
import { BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
3+
import { useMeasure } from "react-use";
4+
import { ChartTooltip } from "@common/elements/ChartTooltip";
5+
import {
6+
QuantityType,
7+
formatQuantity,
8+
formatQuantityCompact,
9+
} from "./lib/formatQuantity";
10+
11+
// To avoid having a bar displayed too wide, we set a minimum amount of days for the chart's x-axis span.
12+
const MIN_DAY_SPAN = 6;
13+
14+
const MS_IN_DAY = 24 * 60 * 60 * 1000;
15+
16+
export function DailyChart({
17+
data,
18+
showCategoryInTooltip = false,
19+
children,
20+
quantityType,
21+
colorMap,
22+
yAxisWidth = 60,
23+
}: React.PropsWithChildren<{
24+
data: { dateNumeric: number }[];
25+
categoryInTooltip?: boolean;
26+
showCategoryInTooltip?: boolean;
27+
quantityType: QuantityType;
28+
colorMap?: Map<string, string>;
29+
yAxisWidth?: number;
30+
}>) {
31+
const { daysWithValues, minDate, daysCount } = useMemo(() => {
32+
const values = new Set(data.map(({ dateNumeric }) => dateNumeric));
33+
const min = Math.min(...values);
34+
const max = Math.max(...values);
35+
return {
36+
daysWithValues: values,
37+
minDate: min,
38+
daysCount: Math.max(MIN_DAY_SPAN, (max - min) / MS_IN_DAY) + 1,
39+
};
40+
}, [data]);
41+
42+
const [containerRef, { width: containerWidth }] =
43+
useMeasure<HTMLDivElement>();
44+
const ticks = useMemo(() => {
45+
if (containerWidth === 0) {
46+
return [];
47+
}
48+
49+
const graphMargin = 90;
50+
const minBarWidth = 50;
51+
52+
const barsWidth = containerWidth - graphMargin;
53+
const dayWidth = barsWidth / daysCount;
54+
const daysByTick = Math.ceil(minBarWidth / dayWidth);
55+
const ticksCount = Math.ceil(daysCount / daysByTick);
56+
57+
return [...Array(ticksCount).keys()]
58+
.map((i) => minDate + i * daysByTick * MS_IN_DAY)
59+
.filter((day) => daysWithValues.has(day));
60+
}, [containerWidth, daysCount, minDate, daysWithValues]);
61+
62+
return (
63+
<div ref={containerRef} className="h-full animate-fadeInFromLoading">
64+
<ResponsiveContainer width="100%" height="100%">
65+
<BarChart data={data} className="text-xs text-content-primary">
66+
<XAxis
67+
scale="time"
68+
type="number"
69+
domain={[
70+
minDate - MS_IN_DAY / 2,
71+
minDate + (daysCount - 1) * MS_IN_DAY + MS_IN_DAY / 2,
72+
]}
73+
axisLine={false}
74+
tickSize={0}
75+
tick={{
76+
fill: "currentColor",
77+
}}
78+
ticks={ticks}
79+
dataKey="dateNumeric"
80+
padding={{ left: 12 }}
81+
tickFormatter={(dateNumeric) =>
82+
new Date(dateNumeric).toLocaleDateString("en-us", {
83+
month: "short",
84+
day: "numeric",
85+
timeZone: "UTC",
86+
})
87+
}
88+
/>
89+
<YAxis
90+
axisLine={false}
91+
tickSize={0}
92+
tickFormatter={(value) =>
93+
formatQuantityCompact(value, quantityType)
94+
}
95+
padding={{ top: 8, bottom: 8 }}
96+
tick={{
97+
fill: "currentColor",
98+
}}
99+
style={{
100+
fontVariantNumeric: "tabular-nums",
101+
}}
102+
width={yAxisWidth}
103+
/>
104+
<Tooltip
105+
isAnimationActive={false}
106+
cursor={{
107+
fill: undefined, // Set in globals.css
108+
}}
109+
content={({ active, payload, label }) => (
110+
<ChartTooltip
111+
active={active}
112+
payload={payload
113+
?.filter((dataPoint) => {
114+
const value = dataPoint.value as number;
115+
return value > 0;
116+
})
117+
.map((dataPoint) => {
118+
const prefix = showCategoryInTooltip
119+
? `${dataPoint.name}: `
120+
: "";
121+
const value = dataPoint.value as number;
122+
const suffix =
123+
!showCategoryInTooltip && quantityType === "unit"
124+
? ` ${dataPoint.name}`
125+
: "";
126+
const className = colorMap?.get(
127+
dataPoint.dataKey as string,
128+
);
129+
return {
130+
...dataPoint,
131+
...(className && { className }),
132+
formattedValue:
133+
prefix + formatQuantity(value, quantityType) + suffix,
134+
};
135+
})
136+
.reverse()}
137+
label={new Date(label).toLocaleDateString("en-us", {
138+
year: "numeric",
139+
month: "long",
140+
day: "numeric",
141+
timeZone: "UTC",
142+
})}
143+
showLegend={showCategoryInTooltip}
144+
/>
145+
)}
146+
labelClassName="font-semibold"
147+
/>
148+
{children}
149+
</BarChart>
150+
</ResponsiveContainer>
151+
</div>
152+
);
153+
}
Lines changed: 20 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import { Listbox, Transition } from "@headlessui/react";
2-
import { ChevronDownIcon } from "@radix-ui/react-icons";
3-
import classNames from "classnames";
4-
import { Fragment } from "react";
1+
import { Combobox, Option } from "@ui/Combobox";
52

6-
const OPTIONS = [
7-
<>Function Calls</>,
8-
<>Database Bandwidth</>,
9-
<>Action Compute</>,
10-
<>Vector Bandwidth</>,
3+
const OPTIONS: Option<number>[] = [
4+
{ label: "Function Calls", value: 0 },
5+
{ label: "Database Bandwidth", value: 1 },
6+
{ label: "Action Compute", value: 2 },
7+
{ label: "Vector Bandwidth", value: 3 },
118
];
129

1310
export function FunctionBreakdownSelector({
@@ -17,46 +14,20 @@ export function FunctionBreakdownSelector({
1714
value: number;
1815
onChange: (newValue: number) => void;
1916
}) {
20-
const optionClass = ({ active }: { active: boolean }) =>
21-
classNames(
22-
"cursor-pointer w-full items-center rounded-sm p-2 text-left text-sm text-content-primary transition",
23-
active && "bg-background-tertiary",
24-
);
25-
2617
return (
27-
<div className="relative">
28-
<Listbox value={value ?? ""} onChange={onChange}>
29-
<Listbox.Button
30-
className={classNames(
31-
"relative h-full w-full appearance-none rounded-sm border",
32-
"bg-background-secondary py-2 pl-3 pr-10 text-left text-sm font-medium text-content-primary hover:bg-background-tertiary focus:outline-hidden focus-visible:ring-2 focus-visible:ring-background-secondary/75 focus-visible:ring-offset-2 focus-visible:ring-offset-content-accent",
33-
)}
34-
>
35-
{OPTIONS[value]}
36-
</Listbox.Button>
37-
38-
<Transition
39-
as={Fragment}
40-
leave="transition ease-in duration-100"
41-
leaveFrom="opacity-100"
42-
leaveTo="opacity-0"
43-
>
44-
<Listbox.Options className="absolute right-0 z-50 mt-2 max-h-60 w-full min-w-[256px] overflow-auto rounded-sm border bg-background-secondary px-3 py-4 shadow-sm">
45-
{OPTIONS.map((option, index) => (
46-
<Listbox.Option className={optionClass} key={index} value={index}>
47-
{option}
48-
</Listbox.Option>
49-
))}
50-
</Listbox.Options>
51-
</Transition>
52-
</Listbox>
53-
54-
<div className="pointer-events-none absolute top-0 right-0 z-50 flex h-full place-items-center pr-2">
55-
<ChevronDownIcon
56-
className="h-5 w-5 text-content-tertiary"
57-
aria-hidden="true"
58-
/>
59-
</div>
60-
</div>
18+
<Combobox
19+
label="Function Breakdown"
20+
labelHidden
21+
options={OPTIONS}
22+
selectedOption={value}
23+
setSelectedOption={(newValue) => {
24+
if (newValue !== null) {
25+
onChange(newValue);
26+
}
27+
}}
28+
disableSearch
29+
buttonClasses="w-fit"
30+
optionsWidth="fit"
31+
/>
6132
);
6233
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Combobox, Option } from "@ui/Combobox";
2+
3+
export type GroupBy = "byType" | "byProject";
4+
5+
const OPTIONS: Option<GroupBy>[] = [
6+
{ label: "Group by type", value: "byType" },
7+
{ label: "Group by project", value: "byProject" },
8+
];
9+
10+
export function GroupBySelector({
11+
value,
12+
onChange,
13+
disabled = false,
14+
}: {
15+
value: GroupBy;
16+
onChange: (newValue: GroupBy) => void;
17+
disabled?: boolean;
18+
}) {
19+
return (
20+
<Combobox
21+
label="Group by"
22+
labelHidden
23+
options={OPTIONS}
24+
buttonProps={{
25+
tip: disabled
26+
? "You cannot change the grouping while filtered to a specific project."
27+
: undefined,
28+
}}
29+
selectedOption={value}
30+
setSelectedOption={(newValue) => {
31+
if (newValue) {
32+
onChange(newValue);
33+
}
34+
}}
35+
disableSearch
36+
disabled={disabled}
37+
buttonClasses="w-fit"
38+
optionsWidth="fit"
39+
/>
40+
);
41+
}

npm-packages/dashboard/src/components/billing/PlanSummary.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const sections: {
125125
{
126126
metric: "chefTokens",
127127
entitlement: "maxChefTokens",
128-
format: formatNumberCompact,
128+
format: (n: number) => `${formatNumberCompact(n)} Tokens`,
129129
detail: "The number of Chef tokens used",
130130
title: "Chef Tokens",
131131
},
@@ -149,7 +149,10 @@ export function PlanSummaryForTeam({
149149
hasFilter,
150150
}: PlanSummaryForTeamProps) {
151151
return (
152-
<Sheet className="animate-fadeInFromLoading" padding={false}>
152+
<Sheet
153+
className="animate-fadeInFromLoading overflow-hidden"
154+
padding={false}
155+
>
153156
<div className="flex flex-col gap-1 overflow-x-auto">
154157
<div
155158
className={cn(

0 commit comments

Comments
 (0)