feat(site): add date filtering to settings usage page (#23381)

## What

Replace the hardcoded 30-day date window on the Agents Settings Usage
page (`/agents/settings/usage`) with an interactive date-range picker.

## Why

The usage page previously showed a static 30-day lookback with no way
for admins to adjust the time window. The backend API already supports
`start_date`/`end_date` parameters — only the frontend was missing the
controls.

## How

- Reuse the existing `DateRange` picker component from Template Insights
- Store selected dates in URL search params (`startDate`/`endDate`) for
persistence across navigation
- Default to last 30 days when no params are present
- Memoize date range for stable React Query keys
- Both the user list and per-user drill-in views respect the selected
range
- Normalize exclusive end-date boundaries for display
- Preset clicks (Last 7 days, etc.) apply immediately with a single
click
- Semi-transparent loading overlay during data refetch

## Changes

- `site/src/pages/AgentsPage/SettingsPageContent.tsx` — Replace
hardcoded range with interactive picker, URL param state, memoized
params, refetch overlay
- `site/src/pages/AgentsPage/SettingsPageContent.stories.tsx` — Add
stories for date filter interaction, preset single-click, and refetch
overlay
- `site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx` —
Detect preset clicks and apply immediately (single-click) instead of
requiring two clicks

## Validation

- TypeScript ✅
- Biome lint ✅
- Storybook tests 13/13 ✅
- Visual verification via Storybook ✅
This commit is contained in:
Michael Suchacz
2026-03-20 23:38:43 +01:00
committed by GitHub
parent a85800c90b
commit cf24c59b56
3 changed files with 378 additions and 144 deletions
@@ -114,6 +114,17 @@ const setupUsageSpies = (opts?: {
spyOn(API, "getChatCostSummary").mockResolvedValue(mockCostSummary);
};
const getChatCostUsersCalls = () =>
(
API.getChatCostUsers as typeof API.getChatCostUsers & {
mock: {
calls: Array<[Parameters<typeof API.getChatCostUsers>[0]]>;
};
}
).mock.calls;
const fixedNow = dayjs("2026-03-12T00:00:00Z");
// ── Meta ───────────────────────────────────────────────────────
const meta = {
@@ -124,7 +135,7 @@ const meta = {
activeSection: "behavior",
canManageChatModelConfigs: false,
canSetSystemPrompt: true,
now: dayjs("2026-03-12T00:00:00Z"),
now: fixedNow,
},
parameters: {
user: MockUserOwner,
@@ -320,6 +331,120 @@ export const UsageUserList: Story = {
},
};
export const UsageDateFilter: Story = {
args: {
activeSection: "usage",
canManageChatModelConfigs: true,
},
beforeEach: () => {
setupUsageSpies();
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const defaultStartDate = fixedNow.subtract(30, "day").toISOString();
const defaultStartLabel = fixedNow
.subtract(30, "day")
.format("MMM D, YYYY");
const defaultEndLabel = fixedNow.format("MMM D, YYYY");
await waitFor(() => {
expect(API.getChatCostUsers).toHaveBeenCalled();
});
const initialCallCount = getChatCostUsersCalls().length;
const dateRangeTrigger = await canvas.findByRole("button", {
name: new RegExp(`${defaultStartLabel}.*${defaultEndLabel}`),
});
await userEvent.click(dateRangeTrigger);
const last7Days = await body.findByRole("button", {
name: "Last 7 days",
});
await userEvent.click(last7Days);
await waitFor(() => {
expect(body.queryByRole("button", { name: "Last 7 days" })).toBeNull();
const calls = getChatCostUsersCalls();
expect(calls.length).toBeGreaterThan(initialCallCount);
const latestCall = calls.at(-1)?.[0];
expect(latestCall).toBeDefined();
if (!latestCall) {
throw new Error("Expected getChatCostUsers to be called with params.");
}
expect(latestCall.start_date).not.toBe(defaultStartDate);
});
},
};
export const UsageDateFilterRefetchOverlay: Story = {
args: {
activeSection: "usage",
canManageChatModelConfigs: true,
},
beforeEach: () => {
let requestCount = 0;
let resolveRefetch:
| ((value: TypesGen.ChatCostUsersResponse) => void)
| undefined;
const refetchPromise = new Promise<TypesGen.ChatCostUsersResponse>(
(resolve) => {
resolveRefetch = resolve;
},
);
spyOn(API, "getChatCostUsers").mockImplementation(async () => {
requestCount += 1;
if (requestCount === 1) {
return mockUsersResponse;
}
return refetchPromise;
});
spyOn(API, "getUser").mockResolvedValue(mockUserProfile);
spyOn(API, "getChatCostSummary").mockResolvedValue(mockCostSummary);
return () => {
resolveRefetch?.({
...mockUsersResponse,
start_date: "2026-03-06T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
});
};
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const defaultStartLabel = fixedNow
.subtract(30, "day")
.format("MMM D, YYYY");
const defaultEndLabel = fixedNow.format("MMM D, YYYY");
await canvas.findByText("Alice Liddell");
await step(
"show a refetch overlay after changing the date range",
async () => {
const dateRangeTrigger = await canvas.findByRole("button", {
name: new RegExp(`${defaultStartLabel}.*${defaultEndLabel}`),
});
await userEvent.click(dateRangeTrigger);
await userEvent.click(
await body.findByRole("button", { name: "Last 7 days" }),
);
await expect(
await canvas.findByRole("status", { name: "Refreshing usage" }),
).toBeInTheDocument();
},
);
},
};
export const UsageEmpty: Story = {
args: {
activeSection: "usage",
+167 -76
View File
@@ -52,6 +52,10 @@ import TextareaAutosize from "react-textarea-autosize";
import { formatTokenCount } from "utils/analytics";
import { formatCostMicros } from "utils/currency";
import { humanDuration } from "utils/time";
import {
DateRange,
type DateRangeValue,
} from "../TemplatePage/TemplateInsightsPage/DateRange";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
import { InsightsContent } from "./InsightsContent";
@@ -77,6 +81,37 @@ const AdminBadge: FC = () => (
const pageSize = 10;
const usageStartDateSearchParam = "startDate";
const usageEndDateSearchParam = "endDate";
const getDefaultUsageDateRange = (now?: dayjs.Dayjs): DateRangeValue => {
const end = now ?? dayjs();
return {
startDate: end.subtract(30, "day").toDate(),
endDate: end.toDate(),
};
};
const formatUsageDateRange = (
value: DateRangeValue,
options?: {
endDateIsExclusive?: boolean;
},
) => {
// Custom ranges keep the raw API end boundary, which can be midnight on
// the following day for full-day selections. Show the inclusive day in
// the drill-in label without changing the query params.
const displayEndDate =
options?.endDateIsExclusive &&
dayjs(value.endDate).isSame(dayjs(value.endDate).startOf("day"))
? dayjs(value.endDate).subtract(1, "day")
: dayjs(value.endDate);
return `${dayjs(value.startDate).format("MMM D")} – ${displayEndDate.format(
"MMM D, YYYY",
)}`;
};
const UserRow: FC<{
user: TypesGen.ChatCostUserRollup;
onSelect: (user: TypesGen.ChatCostUserRollup) => void;
@@ -132,21 +167,66 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
const [searchFilter, setSearchFilter] = useState("");
const debouncedSearch = useDebouncedValue(searchFilter, 300);
const [page, setPage] = useState(1);
const dateRange = (() => {
const end = now ?? dayjs();
const start = end.subtract(30, "day");
return {
startDate: start.toISOString(),
endDate: end.toISOString(),
rangeLabel: `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`,
};
})();
const startDateParam =
searchParams.get(usageStartDateSearchParam)?.trim() ?? "";
const endDateParam = searchParams.get(usageEndDateSearchParam)?.trim() ?? "";
const [defaultDateRange] = useState(() => getDefaultUsageDateRange(now));
let dateRange = defaultDateRange;
let hasExplicitDateRange = false;
if (startDateParam && endDateParam) {
const parsedStartDate = new Date(startDateParam);
const parsedEndDate = new Date(endDateParam);
if (
!Number.isNaN(parsedStartDate.getTime()) &&
!Number.isNaN(parsedEndDate.getTime()) &&
parsedStartDate.getTime() <= parsedEndDate.getTime()
) {
dateRange = {
startDate: parsedStartDate,
endDate: parsedEndDate,
};
hasExplicitDateRange = true;
}
}
const dateRangeParams = {
start_date: dateRange.startDate.toISOString(),
end_date: dateRange.endDate.toISOString(),
};
const { endDate } = dateRange;
const isExclusiveMidnightEnd =
hasExplicitDateRange &&
endDate.getHours() === 0 &&
endDate.getMinutes() === 0 &&
endDate.getSeconds() === 0 &&
endDate.getMilliseconds() === 0;
const displayDateRange = isExclusiveMidnightEnd
? {
startDate: dateRange.startDate,
endDate: new Date(endDate.getTime() - 1),
}
: dateRange;
const dateRangeLabel = formatUsageDateRange(dateRange, {
endDateIsExclusive: hasExplicitDateRange,
});
const offset = (page - 1) * pageSize;
const onDateRangeChange = (value: DateRangeValue) => {
// Reset pagination but preserve user selection and other params.
setPage(1);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set(usageStartDateSearchParam, value.startDate.toISOString());
next.set(usageEndDateSearchParam, value.endDate.toISOString());
return next;
});
};
const usersQuery = useQuery({
...chatCostUsers({
start_date: dateRange.startDate,
end_date: dateRange.endDate,
...dateRangeParams,
username: debouncedSearch || undefined,
limit: pageSize,
offset,
@@ -162,10 +242,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
const selectedUser = selectedUserQuery.data ?? null;
const summaryQuery = useQuery({
...chatCostSummary(selectedUserId ?? "me", {
start_date: dateRange.startDate,
end_date: dateRange.endDate,
}),
...chatCostSummary(selectedUserId ?? "me", dateRangeParams),
enabled: selectedUserId !== null,
});
const totalCount = usersQuery.data?.count ?? 0;
@@ -182,9 +259,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
}
badge={<AdminBadge />}
action={
<span className="text-xs text-content-secondary">
{dateRange.rangeLabel}
</span>
<DateRange value={displayDateRange} onChange={onDateRangeChange} />
}
/>
);
@@ -266,7 +341,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
/>
<div className="min-w-0 text-xs text-content-secondary">
<div>User ID: {selectedUser.id}</div>
<div>{dateRange.rangeLabel}</div>
<div>{dateRangeLabel}</div>
</div>
</div>
<ChatCostSummaryView
@@ -331,64 +406,80 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
</div>
)}
{usersQuery.data &&
(usersQuery.data.users.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
No usage data for this period.
</p>
) : (
<>
<div className="overflow-hidden rounded-lg border border-border-default">
<Table>
<TableHeader>
<TableRow className="text-left text-xs uppercase tracking-wide text-content-secondary">
<TableHead className="px-4 py-3">User</TableHead>
<TableHead className="px-4 py-3 text-right">
Total Cost
</TableHead>
<TableHead className="px-4 py-3 text-right">
Messages
</TableHead>
<TableHead className="px-4 py-3 text-right">
Chats
</TableHead>
<TableHead className="px-4 py-3 text-right">
Input Tokens
</TableHead>
<TableHead className="px-4 py-3 text-right">
Output Tokens
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Read
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Write
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.data.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={(u) => {
setSearchParams({ user: u.user_id });
}}
/>
))}
</TableBody>
</Table>
{usersQuery.data && (
<div className="relative">
{usersQuery.isFetching && !usersQuery.isLoading && (
<div
role="status"
aria-label="Refreshing usage"
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-primary/50"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
<PaginationWidgetBase
totalRecords={usersQuery.data.count}
currentPage={page}
pageSize={pageSize}
onPageChange={setPage}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
/>
</>
))}
)}
{usersQuery.data.users.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
No usage data for this period.
</p>
) : (
<>
<div className="overflow-hidden rounded-lg border border-border-default">
<Table>
<TableHeader>
<TableRow className="text-left text-xs uppercase tracking-wide text-content-secondary">
<TableHead className="px-4 py-3">User</TableHead>
<TableHead className="px-4 py-3 text-right">
Total Cost
</TableHead>
<TableHead className="px-4 py-3 text-right">
Messages
</TableHead>
<TableHead className="px-4 py-3 text-right">
Chats
</TableHead>
<TableHead className="px-4 py-3 text-right">
Input Tokens
</TableHead>
<TableHead className="px-4 py-3 text-right">
Output Tokens
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Read
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Write
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.data.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={(u) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("user", u.user_id);
return next;
});
}}
/>
))}
</TableBody>
</Table>
</div>
<PaginationWidgetBase
totalRecords={usersQuery.data.count}
currentPage={page}
pageSize={pageSize}
onPageChange={setPage}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
/>
</>
)}
</div>
)}
</div>
);
};
@@ -35,6 +35,7 @@ interface DateRangeProps {
export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
const selectionStatusRef = useRef<"idle" | "selecting">("idle");
const selectionSourceRef = useRef<"preset" | null>(null);
const [ranges, setRanges] = useState<RangesState>([
{
...value,
@@ -43,6 +44,20 @@ export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
]);
const [open, setOpen] = useState(false);
const applyRangeSelection = (range: RangesState[number]) => {
selectionStatusRef.current = "idle";
const startDate = range.startDate as Date;
const endDate = range.endDate as Date;
const now = new Date();
onChange({
startDate: dayjs(startDate).startOf("day").toDate(),
endDate: dayjs(endDate).isSame(dayjs(), "day")
? dayjs(now).startOf("hour").add(1, "hour").toDate()
: dayjs(endDate).startOf("day").add(1, "day").toDate(),
});
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -53,74 +68,77 @@ export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 overflow-x-hidden">
<DateRangePicker
css={styles.wrapper}
onChange={(item) => {
const range = item.selection;
setRanges([range]);
// When it is the first selection, we don't want to close the popover
// We have to do that ourselves because the library doesn't provide a way to do it
if (selectionStatusRef.current === "idle") {
selectionStatusRef.current = "selecting";
return;
}
selectionStatusRef.current = "idle";
const startDate = range.startDate as Date;
const endDate = range.endDate as Date;
const now = new Date();
onChange({
startDate: dayjs(startDate).startOf("day").toDate(),
endDate: dayjs(endDate).isSame(dayjs(), "day")
? dayjs(now).startOf("hour").add(1, "hour").toDate()
: dayjs(endDate).startOf("day").add(1, "day").toDate(),
});
setOpen(false);
<div
onClickCapture={(event) => {
selectionSourceRef.current =
event.target instanceof HTMLElement &&
event.target.closest(".rdrStaticRange")
? "preset"
: null;
}}
moveRangeOnFirstSelection={false}
months={2}
ranges={ranges}
maxDate={new Date()}
direction="horizontal"
staticRanges={createStaticRanges([
{
label: "Today",
range: () => ({
startDate: new Date(),
endDate: new Date(),
}),
},
{
label: "Yesterday",
range: () => ({
startDate: dayjs().subtract(1, "day").toDate(),
endDate: dayjs().subtract(1, "day").toDate(),
}),
},
{
label: "Last 7 days",
range: () => ({
startDate: dayjs().subtract(6, "day").toDate(),
endDate: new Date(),
}),
},
{
label: "Last 14 days",
range: () => ({
startDate: dayjs().subtract(13, "day").toDate(),
endDate: new Date(),
}),
},
{
label: "Last 30 days",
range: () => ({
startDate: dayjs().subtract(29, "day").toDate(),
endDate: new Date(),
}),
},
])}
/>
>
<DateRangePicker
css={styles.wrapper}
onChange={(item) => {
const range = item.selection;
const isPresetSelection = selectionSourceRef.current === "preset";
selectionSourceRef.current = null;
setRanges([range]);
// When it is the first calendar selection, we don't want to
// close the popover. Presets already provide a complete range,
// so apply them immediately.
if (selectionStatusRef.current === "idle" && !isPresetSelection) {
selectionStatusRef.current = "selecting";
return;
}
applyRangeSelection(range);
}}
moveRangeOnFirstSelection={false}
months={2}
ranges={ranges}
maxDate={new Date()}
direction="horizontal"
staticRanges={createStaticRanges([
{
label: "Today",
range: () => ({
startDate: new Date(),
endDate: new Date(),
}),
},
{
label: "Yesterday",
range: () => ({
startDate: dayjs().subtract(1, "day").toDate(),
endDate: dayjs().subtract(1, "day").toDate(),
}),
},
{
label: "Last 7 days",
range: () => ({
startDate: dayjs().subtract(6, "day").toDate(),
endDate: new Date(),
}),
},
{
label: "Last 14 days",
range: () => ({
startDate: dayjs().subtract(13, "day").toDate(),
endDate: new Date(),
}),
},
{
label: "Last 30 days",
range: () => ({
startDate: dayjs().subtract(29, "day").toDate(),
endDate: new Date(),
}),
},
])}
/>
</div>
</PopoverContent>
</Popover>
);