diff --git a/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx b/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx index 3b321e3674..a09fd42c4a 100644 --- a/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx @@ -114,6 +114,17 @@ const setupUsageSpies = (opts?: { spyOn(API, "getChatCostSummary").mockResolvedValue(mockCostSummary); }; +const getChatCostUsersCalls = () => + ( + API.getChatCostUsers as typeof API.getChatCostUsers & { + mock: { + calls: Array<[Parameters[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( + (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", diff --git a/site/src/pages/AgentsPage/SettingsPageContent.tsx b/site/src/pages/AgentsPage/SettingsPageContent.tsx index d169be4b10..f93c700634 100644 --- a/site/src/pages/AgentsPage/SettingsPageContent.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.tsx @@ -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 = ({ 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 = ({ 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 = ({ now }) => { } badge={} action={ - - {dateRange.rangeLabel} - + } /> ); @@ -266,7 +341,7 @@ const UsageContent: FC = ({ now }) => { />
User ID: {selectedUser.id}
-
{dateRange.rangeLabel}
+
{dateRangeLabel}
= ({ now }) => { )} - {usersQuery.data && - (usersQuery.data.users.length === 0 ? ( -

- No usage data for this period. -

- ) : ( - <> -
- - - - User - - Total Cost - - - Messages - - - Chats - - - Input Tokens - - - Output Tokens - - - Cache Read - - - Cache Write - - - - - {usersQuery.data.users.map((user) => ( - { - setSearchParams({ user: u.user_id }); - }} - /> - ))} - -
+ {usersQuery.data && ( +
+ {usersQuery.isFetching && !usersQuery.isLoading && ( +
+
- - - ))} + )} + {usersQuery.data.users.length === 0 ? ( +

+ No usage data for this period. +

+ ) : ( + <> +
+ + + + User + + Total Cost + + + Messages + + + Chats + + + Input Tokens + + + Output Tokens + + + Cache Read + + + Cache Write + + + + + {usersQuery.data.users.map((user) => ( + { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set("user", u.user_id); + return next; + }); + }} + /> + ))} + +
+
+ + + )} +
+ )}
); }; diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx index 347f29bf85..df6e75866b 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx @@ -35,6 +35,7 @@ interface DateRangeProps { export const DateRange: FC = ({ value, onChange }) => { const selectionStatusRef = useRef<"idle" | "selecting">("idle"); + const selectionSourceRef = useRef<"preset" | null>(null); const [ranges, setRanges] = useState([ { ...value, @@ -43,6 +44,20 @@ export const DateRange: FC = ({ 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 ( @@ -53,74 +68,77 @@ export const DateRange: FC = ({ value, onChange }) => { - { - 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); +
{ + 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(), - }), - }, - ])} - /> + > + { + 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(), + }), + }, + ])} + /> +
);