From abee9c945ba652f9c971eca8eabfca2600fdbfd1 Mon Sep 17 00:00:00 2001 From: zoveki <349028329+zoveki@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:30:31 -0800 Subject: [PATCH] fix(ui): label every budget reset time in the Admin UI with UTC (#50591) * fix(ui): label every budget reset time in the Admin UI with UTC Budget reset times rendered in the browser's timezone with no label, while the proxy resets budgets on UTC boundaries by default. Every budget reset time and budget period now renders in UTC with a UTC label, and both keys tables' Budget Reset column widens to fit it. * fix(ui): keep the key page's 12-hour clock for the UTC budget reset time * fix(ui): show clock times in a budget period that does not start at midnight UTC * fix(ui): show the UTC clock time in the Teams list reset line when it is not midnight --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../TeamMemberSpendBudgetCell.test.tsx | 2 +- .../TeamsPage/TeamsTable.integration.test.tsx | 2 +- .../VirtualKeysPage/VirtualKeysTable.test.tsx | 23 +++- .../VirtualKeysPage/keyTableColumns.tsx | 8 +- .../shared/BudgetPeriodTooltip.test.tsx | 8 +- .../components/shared/BudgetPeriodTooltip.tsx | 12 ++- .../shared/table_cells/date_cell.test.tsx | 35 +++++- .../shared/table_cells/date_cell.tsx | 62 ++++++++--- .../src/components/team/MyUserTab.test.tsx | 6 +- .../components/team/TeamMemberTab.test.tsx | 10 +- .../team/TeamVirtualKeysTable.test.tsx | 29 ++++- .../components/team/TeamVirtualKeysTable.tsx | 8 +- .../KeyInfoView.handleKeyUpdate.test.tsx | 2 +- .../key_info_view.budget_display.test.tsx | 2 +- .../components/templates/key_info_view.tsx | 10 +- .../src/utils/budgetUtils.test.ts | 102 ++++++++++++------ ui/litellm-dashboard/src/utils/budgetUtils.ts | 30 ++++-- 17 files changed, 263 insertions(+), 88 deletions(-) diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx index 79e3be1750d..016d359273b 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx @@ -23,7 +23,7 @@ describe("TeamMemberSpendBudgetCell", () => { expect(screen.getByText("My Spend")).toBeInTheDocument(); expect(screen.getByText("$50.00")).toBeInTheDocument(); expect(screen.getByText("/ $100.00")).toBeInTheDocument(); - expect(screen.getByText("Resets Oct 20, 2026 UTC")).toBeInTheDocument(); + expect(screen.getByText("Resets Oct 20, 2026 12:00 UTC")).toBeInTheDocument(); expect(screen.getAllByRole("meter")[0]).toHaveAttribute("aria-valuetext", "Team $600.00 of $1,000.00"); expect(screen.getAllByRole("meter")[1]).toHaveAttribute("aria-valuetext", "My Spend $50.00 of $100.00"); }); diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx index 02870bc1571..0c22f488978 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx @@ -133,7 +133,7 @@ it("renders caller membership spend and budget in the team row", async () => { expect(await screen.findByText("My Spend")).toBeInTheDocument(); expect(screen.getByText("$50.00")).toBeInTheDocument(); - expect(screen.getByText("Resets Oct 20, 2026 UTC")).toBeInTheDocument(); + expect(screen.getByText("Resets Oct 20, 2026 12:00 UTC")).toBeInTheDocument(); }); it("renders the Resources cell with member, model, and key counts", () => { diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 47fd554d196..11ac1b7601b 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -1,7 +1,7 @@ import { act, screen, waitFor, within, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; -import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest"; +import { vi, it, expect, beforeEach, afterEach, describe, Mock, MockedFunction } from "vitest"; import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import { VirtualKeysTable } from "./VirtualKeysTable"; import { KEY_TABLE_HIDDEN_COLUMNS, KEY_TABLE_SORT_FIELDS } from "./keyTableColumns"; @@ -585,6 +585,27 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user }); }); +describe("Budget Reset column", () => { + const originalTimezone: string | undefined = process.env.TZ; + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + return; + } + process.env.TZ = originalTimezone; + }); + + it("shows the reset time in UTC with a UTC label for a viewer in another timezone", async () => { + process.env.TZ = "America/Los_Angeles"; + mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, budget_reset_at: "2026-11-01T00:00:00Z" }])); + + renderWithProviders(); + + expect(await screen.findByText("Nov 1, 00:00:00 UTC")).toBeInTheDocument(); + }); +}); + describe("entity links out of the key rows", () => { const keyRow = async () => (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement; diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx index fad64c57040..57ec85a155a 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx @@ -389,11 +389,15 @@ export const getKeyTableColumns = ( header: ({ column }) => ( ), - size: 130, + size: 160, enableSorting: true, sortDescFirst: false, cell: (info) => ( - + ), }, { diff --git a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx index 4d77218a776..fd039d3a824 100644 --- a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx @@ -22,8 +22,8 @@ const noBudget = { spend: null, maxBudget: null, budgetDuration: "30d", budgetRe const noBudgetNoDuration = { spend: 2, maxBudget: null, budgetDuration: null, budgetResetAt: null }; describe("budgetPeriodLines", () => { - it("describes the current period, max budget, interval and spend when a reset is scheduled", () => { - process.env.TZ = "UTC"; + it("describes the current period in UTC, max budget, interval and spend when a reset is scheduled", () => { + process.env.TZ = "America/Los_Angeles"; expect(budgetPeriodLines(scheduled)).toEqual([ "Budget period: Mar 1, 2026 00:00 to Apr 1, 2026 00:00 (UTC)", "Max budget: $100.00", @@ -32,8 +32,8 @@ describe("budgetPeriodLines", () => { ]); }); - it("falls back to the next reset when the duration cannot be parsed", () => { - process.env.TZ = "UTC"; + it("falls back to the next reset in UTC when the duration cannot be parsed", () => { + process.env.TZ = "Asia/Tokyo"; expect(budgetPeriodLines(unparseableDuration)).toEqual([ "Next reset: Apr 1, 2026 00:00 (UTC)", "Max budget: $100.00", diff --git a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx index 39f2e2e4e11..711979d1ad5 100644 --- a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx +++ b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx @@ -1,6 +1,7 @@ "use client"; import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; import { CircleHelp } from "lucide-react"; import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown"; @@ -16,20 +17,21 @@ export interface BudgetPeriodTooltipProps { budgetResetAt: string | null | undefined; } -const formatPeriodDate = (iso: string): string => dayjs(iso).format("MMM D, YYYY HH:mm"); +dayjs.extend(utc); + +const formatPeriodDate = (iso: string): string => dayjs.utc(iso).format("MMM D, YYYY HH:mm"); const describePeriod = (budgetDuration: string, budgetResetAt: string | null | undefined): string | null => { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const period = budgetPeriodRange(budgetDuration, budgetResetAt); if (period) { return i18n.t("shared.budgetPeriodTooltip.budgetPeriod", { start: formatPeriodDate(period.start), end: formatPeriodDate(period.end), - timeZone, + timeZone: "UTC", }); } - if (budgetResetAt && dayjs(budgetResetAt).isValid()) { - return i18n.t("shared.budgetPeriodTooltip.nextReset", { date: formatPeriodDate(budgetResetAt), timeZone }); + if (budgetResetAt && dayjs.utc(budgetResetAt).isValid()) { + return i18n.t("shared.budgetPeriodTooltip.nextReset", { date: formatPeriodDate(budgetResetAt), timeZone: "UTC" }); } return null; }; diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx index 719047e0cfe..2e7fc6dfcbd 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; import { DateCell, formatCellDate, formatFullTimestamp } from "./date_cell"; @@ -55,3 +56,35 @@ describe("DateCell", () => { expect(screen.queryByText(/Invalid/)).not.toBeInTheDocument(); }); }); + +describe("DateCell with timeZone UTC", () => { + const originalTimezone: string | undefined = process.env.TZ; + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + return; + } + process.env.TZ = originalTimezone; + }); + + it("prints the UTC clock time and labels it UTC for a viewer in another zone", async () => { + process.env.TZ = "America/Los_Angeles"; + const user = userEvent.setup(); + render(); + + const cell = screen.getByText("Nov 1, 00:00:00 UTC"); + await user.hover(cell); + expect(await screen.findByText("Nov 1, 2026, 00:00:00 (UTC)")).toBeInTheDocument(); + }); + + it("labels date precision with UTC too", () => { + process.env.TZ = "Asia/Tokyo"; + expect(formatCellDate(new Date("2026-10-31T20:00:00Z"), "date", "UTC")).toBe("Oct 31, 2026 UTC"); + }); + + it("keeps the local default unlabeled for every other column", () => { + process.env.TZ = "America/Los_Angeles"; + expect(formatCellDate(new Date("2026-11-01T00:00:00Z"), "datetime")).toBe("Oct 31, 17:00:00"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx index ae61bbd76af..51c9ae619dd 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx @@ -23,27 +23,65 @@ const MONTHS: readonly ParseKeys[] = [ export type DatePrecision = "datetime" | "date"; +export type DateCellTimeZone = "local" | "UTC"; + interface DateCellProps { value: string | null | undefined; precision?: DatePrecision; fallback?: string; + timeZone?: DateCellTimeZone; +} + +interface DateParts { + year: number; + month: number; + day: number; + hours: number; + minutes: number; + seconds: number; } const pad = (n: number): string => String(n).padStart(2, "0"); -export const formatCellDate = (date: Date, precision: DatePrecision): string => - precision === "date" - ? `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${date.getFullYear()}` - : `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +const dateParts = (date: Date, timeZone: DateCellTimeZone): DateParts => + timeZone === "UTC" + ? { + year: date.getUTCFullYear(), + month: date.getUTCMonth(), + day: date.getUTCDate(), + hours: date.getUTCHours(), + minutes: date.getUTCMinutes(), + seconds: date.getUTCSeconds(), + } + : { + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + }; -export const formatFullTimestamp = (date: Date): string => { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const day = `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${date.getFullYear()}`; - const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; - return `${day}, ${time} (${timeZone})`; +const clockTime = ({ hours, minutes, seconds }: DateParts): string => `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; + +const monthName = ({ month }: DateParts): string => i18n.t(MONTHS[month]); + +export const formatCellDate = (date: Date, precision: DatePrecision, timeZone: DateCellTimeZone = "local"): string => { + const parts = dateParts(date, timeZone); + const formatted = + precision === "date" + ? `${monthName(parts)} ${parts.day}, ${parts.year}` + : `${monthName(parts)} ${parts.day}, ${clockTime(parts)}`; + return timeZone === "UTC" ? `${formatted} UTC` : formatted; }; -export function DateCell({ value, precision = "datetime", fallback = "-" }: DateCellProps) { +export const formatFullTimestamp = (date: Date, timeZone: DateCellTimeZone = "local"): string => { + const parts = dateParts(date, timeZone); + const zoneName = timeZone === "UTC" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone; + return `${monthName(parts)} ${parts.day}, ${parts.year}, ${clockTime(parts)} (${zoneName})`; +}; + +export function DateCell({ value, precision = "datetime", fallback = "-", timeZone = "local" }: DateCellProps) { const date = value ? new Date(value) : null; if (!date || Number.isNaN(date.getTime())) { return {fallback}; @@ -51,8 +89,8 @@ export function DateCell({ value, precision = "datetime", fallback = "-" }: Date return ( {formatCellDate(date, precision)}} + content={formatFullTimestamp(date, timeZone)} + trigger={{formatCellDate(date, precision, timeZone)}} /> ); } diff --git a/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx b/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx index eee9fcdadd1..01fb727089b 100644 --- a/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx @@ -60,7 +60,7 @@ describe("MyUserTab", () => { litellm_budget_table: { max_budget: 100, budget_duration: "30d", - budget_reset_at: new Date(2026, 6, 15, 12, 0, 0).toISOString(), + budget_reset_at: "2026-07-15T12:00:00.000Z", }, }, isLoading: false, @@ -70,7 +70,9 @@ describe("MyUserTab", () => { renderWithProviders(); expect(screen.getByText("Current Period Spend (USD)")).toBeInTheDocument(); - expect(screen.getByText("Jun 15 - Jul 15, 2026 (resets Jul 15, 2026 12:00)")).toBeInTheDocument(); + expect( + screen.getByText("Jun 15, 2026 12:00 - Jul 15, 2026 12:00 UTC (resets Jul 15, 2026 12:00 UTC)"), + ).toBeInTheDocument(); expect(screen.queryByText(/cycle/i)).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index 9350de8a8fc..b0cc1c56cab 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -32,7 +32,7 @@ const mockSetIsAddMemberModalVisible = vi.fn(); const mockOnMemberSpendReset = vi.fn(); const mockOnMemberBudgetReset = vi.fn(); -const budgetResetIso = new Date(2026, 6, 15, 12, 0, 0).toISOString(); +const budgetResetIso = "2026-07-15T12:00:00.000Z"; const createMockTeamData = (overrides: Partial = {}): TeamData => ({ team_id: "team-123", @@ -313,7 +313,7 @@ describe("TeamMembersComponent", () => { />, ); - expect(screen.getByText("Jul 15, 2026 12:00")).toBeInTheDocument(); + expect(screen.getByText("Jul 15, 2026 12:00 UTC")).toBeInTheDocument(); }); it("shows the budget period date range instead of a bare reset date when the duration is known", () => { @@ -334,8 +334,8 @@ describe("TeamMembersComponent", () => { expect(screen.getByRole("columnheader", { name: /^Budget Period/ })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: /^Current Period Spend \(USD\)/ })).toBeInTheDocument(); - expect(screen.getByText("Jun 15 - Jul 15, 2026")).toBeInTheDocument(); - expect(screen.queryByText("Jul 15, 2026 12:00")).not.toBeInTheDocument(); + expect(screen.getByText("Jun 15, 2026 12:00 - Jul 15, 2026 12:00 UTC")).toBeInTheDocument(); + expect(screen.queryByText("Jul 15, 2026 12:00 UTC")).not.toBeInTheDocument(); expect(screen.queryByText(/cycle/i)).not.toBeInTheDocument(); }); @@ -355,7 +355,7 @@ describe("TeamMembersComponent", () => { />, ); - expect(screen.queryByText("Jul 15, 2026 12:00")).not.toBeInTheDocument(); + expect(screen.queryByText("Jul 15, 2026 12:00 UTC")).not.toBeInTheDocument(); expect(screen.getAllByText("Never resets").length).toBeGreaterThan(0); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index c353a3e56be..86769cf7345 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -1,5 +1,5 @@ import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor, within } from "../../../tests/test-utils"; import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; @@ -184,6 +184,33 @@ describe("TeamVirtualKeysTable", () => { expect(screen.getByText("bob_key_team1")).toBeInTheDocument(); }); + describe("Budget Reset column", () => { + const originalTimezone: string | undefined = process.env.TZ; + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + return; + } + process.env.TZ = originalTimezone; + }); + + it("shows the reset time in UTC with a UTC label for a viewer in another timezone", async () => { + process.env.TZ = "America/Los_Angeles"; + const keysResponse: KeysResponse = { + keys: [createMockKey({ budget_reset_at: "2026-11-01T00:00:00Z" })], + total_count: 1, + current_page: 1, + total_pages: 1, + }; + mockUseKeys.mockReturnValue(createKeysQueryResult(keysResponse)); + + renderWithProviders(); + + expect(await screen.findByText("Nov 1, 00:00:00 UTC")).toBeInTheDocument(); + }); + }); + it("shows the team member budget for budgetless keys and keeps each key's own budget", async () => { const keysResponse: KeysResponse = { keys: [ diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 550709edebf..219a2d5f1fd 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -406,11 +406,15 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: ({ column }) => ( ), - size: 130, + size: 160, enableSorting: true, sortDescFirst: false, cell: (info) => ( - + ), }, { diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 9e48c4e3aba..c0770253b28 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -517,7 +517,7 @@ describe("KeyInfoView handleKeyUpdate budget_duration", () => { ); fireEvent.click(screen.getByText("Settings")); - expect(screen.getByTestId("budget-reset-value")).toHaveTextContent("Every 30d"); + expect(screen.getByTestId("budget-reset-value")).toHaveTextContent("Every 30d, next Sep 1, 2026 at 12:00 AM UTC"); fireEvent.click(screen.getByText("Edit Settings")); (globalThis as any).__TEST_FORM_VALUES = { diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx index 965fb3e8054..a0ee41c937b 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx @@ -424,7 +424,7 @@ describe("KeyInfoView budget reset visibility", () => { />, ); await waitFor(() => { - expect(screen.getByText(/^Resets Jul 22, 2026/)).toBeInTheDocument(); + expect(screen.getByText("Resets Jul 22, 2026 at 12:00 PM UTC")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 62293c0c3a2..120770f2001 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -5,6 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings" import { useApplyUserBudgetToTeamKeys } from "@/app/(dashboard)/hooks/uiSettings/useApplyUserBudgetToTeamKeys"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { formatBudgetResetTime } from "@/utils/budgetUtils"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import { ArrowLeft, Info } from "lucide-react"; @@ -579,6 +580,7 @@ export default function KeyInfoView({ return t("keyDetail.keyInfoView.budget.unlimited"); })(); const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser, memberGate); + const budgetResetTime = formatBudgetResetTime(currentKeyData.budget_reset_at); return (
@@ -782,10 +784,10 @@ export default function KeyInfoView({ : t("keyDetail.keyInfoView.overview.ofBudget", { budget: budgetDisplay })}

- {currentKeyData.budget_reset_at && ( + {budgetResetTime && (

{t("keyDetail.keyInfoView.overview.resets")} - {formatTimestamp(currentKeyData.budget_reset_at)} + {budgetResetTime}

)}

@@ -1111,8 +1113,8 @@ export default function KeyInfoView({

{t("keyDetail.keyInfoView.settings.budgetReset")}

- {currentKeyData.budget_reset_at - ? `${currentKeyData.budget_duration ? t("keyDetail.keyInfoView.settings.budgetResetEvery", { duration: currentKeyData.budget_duration }) : ""}${formatTimestamp(currentKeyData.budget_reset_at)}` + {budgetResetTime + ? `${currentKeyData.budget_duration ? t("keyDetail.keyInfoView.settings.budgetResetEvery", { duration: currentKeyData.budget_duration }) : ""}${budgetResetTime}` : t("keyDetail.keyInfoView.settings.never")}

diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts index 11339d08bc5..2d05d7140d4 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts @@ -1,9 +1,14 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { budgetPeriodRange, describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils"; +import { + budgetPeriodRange, + describeBudgetResetSchedule, + formatBudgetResetTime, + formatBudgetResetUtc, +} from "./budgetUtils"; -const localIso = (year: number, monthIndex: number, day: number, hour = 12): string => - new Date(year, monthIndex, day, hour, 0, 0).toISOString(); +const utcIso = (year: number, monthIndex: number, day: number, hour = 12): string => + new Date(Date.UTC(year, monthIndex, day, hour, 0, 0)).toISOString(); const originalTimezone: string | undefined = process.env.TZ; @@ -18,14 +23,36 @@ afterEach(() => { describe("formatBudgetResetUtc", () => { it("formats the reset date in UTC regardless of the local timezone", () => { process.env.TZ = "America/Los_Angeles"; - const resetAt: string = "2026-10-20T00:00:00Z"; + expect(formatBudgetResetUtc("2026-10-20T00:00:00Z")).toBe("Oct 20, 2026"); + }); - expect(formatBudgetResetUtc(resetAt)).toBe("Oct 20, 2026"); - expect(formatBudgetReset(resetAt)).toBe("Oct 19, 2026"); + it("adds the UTC clock time when the reset is not at midnight UTC", () => { + process.env.TZ = "America/Los_Angeles"; + expect(formatBudgetResetUtc("2026-11-30T18:30:00Z")).toBe("Nov 30, 2026 18:30"); + }); +}); + +describe("formatBudgetResetTime", () => { + it.each(["America/Los_Angeles", "Asia/Tokyo", "UTC"])( + "prints the reset instant in UTC and says so for a %s viewer", + (timeZone) => { + process.env.TZ = timeZone; + expect(formatBudgetResetTime("2026-11-01T00:00:00Z")).toBe("Nov 1, 2026 at 12:00 AM UTC"); + expect(formatBudgetResetTime("2026-10-31T17:30:00-07:00")).toBe("Nov 1, 2026 at 12:30 AM UTC"); + expect(formatBudgetResetTime("2026-11-01T13:05:00Z")).toBe("Nov 1, 2026 at 1:05 PM UTC"); + }, + ); + + it.each([null, undefined, "", "not-a-date"])("returns null for %s", (value) => { + expect(formatBudgetResetTime(value)).toBeNull(); }); }); describe("describeBudgetResetSchedule", () => { + beforeEach(() => { + process.env.TZ = "America/Los_Angeles"; + }); + it("reports lifetime spend with no period when the budget has no duration and no scheduled reset", () => { const schedule = describeBudgetResetSchedule(null, null); expect(schedule.label).toBe("Never resets"); @@ -41,62 +68,67 @@ describe("describeBudgetResetSchedule", () => { expect(schedule.tooltip).toContain("every 30d"); }); - it("shows the next reset with its time zone and the rollover carryover note", () => { - const resetAt = new Date(2026, 6, 15, 12, 0, 0); - const schedule = describeBudgetResetSchedule("30d", resetAt.toISOString()); - expect(schedule.label).toBe("Jul 15, 2026 12:00"); - expect(schedule.resetAt).toBe(resetAt.toISOString()); - expect(schedule.tooltip).toContain(Intl.DateTimeFormat().resolvedOptions().timeZone); + it("shows the next reset in UTC with the rollover carryover note", () => { + const resetAt = utcIso(2026, 6, 15); + const schedule = describeBudgetResetSchedule("30d", resetAt); + expect(schedule.label).toBe("Jul 15, 2026 12:00 UTC"); + expect(schedule.resetAt).toBe(resetAt); + expect(schedule.tooltip).toContain("Next reset Jul 15, 2026 12:00 (UTC)."); expect(schedule.tooltip).toContain("carries into the next period"); }); it("treats 30d as one calendar month back from the reset, like the proxy does", () => { - const schedule = describeBudgetResetSchedule("30d", localIso(2026, 10, 1, 0)); - expect(schedule.period).toBe("Oct 1 - Nov 1, 2026"); - expect(schedule.tooltip).toContain("covers Oct 1 - Nov 1, 2026 and resets every 30d"); + const schedule = describeBudgetResetSchedule("30d", utcIso(2026, 10, 1, 0)); + expect(schedule.period).toBe("Oct 1 - Nov 1, 2026 UTC"); + expect(schedule.tooltip).toContain("covers Oct 1 - Nov 1, 2026 UTC and resets every 30d"); }); it("subtracts the exact duration for day and week windows", () => { - expect(describeBudgetResetSchedule("7d", localIso(2026, 6, 15)).period).toBe("Jul 8 - Jul 15, 2026"); - expect(describeBudgetResetSchedule("2w", localIso(2026, 6, 15)).period).toBe("Jul 1 - Jul 15, 2026"); - expect(describeBudgetResetSchedule("weekly", localIso(2026, 6, 15)).period).toBe("Jul 8 - Jul 15, 2026"); + expect(describeBudgetResetSchedule("7d", utcIso(2026, 6, 15, 0)).period).toBe("Jul 8 - Jul 15, 2026 UTC"); + expect(describeBudgetResetSchedule("2w", utcIso(2026, 6, 15, 0)).period).toBe("Jul 1 - Jul 15, 2026 UTC"); + expect(describeBudgetResetSchedule("weekly", utcIso(2026, 6, 15, 0)).period).toBe("Jul 8 - Jul 15, 2026 UTC"); }); - it("walks the month back in UTC so a non-UTC viewer sees the proxy's window", () => { - const originalTz = process.env.TZ; + it("shows the proxy's UTC month window to a viewer whose local date is a day behind", () => { process.env.TZ = "America/New_York"; - try { - const schedule = describeBudgetResetSchedule("30d", "2026-03-01T00:00:00Z"); - expect(schedule.period).toBe("Jan 31 - Feb 28, 2026"); - } finally { - process.env.TZ = originalTz; - } + const schedule = describeBudgetResetSchedule("30d", "2026-03-01T00:00:00Z"); + expect(schedule.period).toBe("Feb 1 - Mar 1, 2026 UTC"); + expect(schedule.label).toBe("Mar 1, 2026 00:00 UTC"); }); it("clamps month windows to the last day of a shorter month", () => { - expect(describeBudgetResetSchedule("1mo", localIso(2026, 2, 31)).period).toBe("Feb 28 - Mar 31, 2026"); + expect(describeBudgetResetSchedule("1mo", utcIso(2026, 2, 31, 0)).period).toBe("Feb 28 - Mar 31, 2026 UTC"); }); it("spells out both years when the period crosses a year boundary", () => { - expect(describeBudgetResetSchedule("30d", localIso(2027, 0, 10)).period).toBe("Dec 10, 2026 - Jan 10, 2027"); + expect(describeBudgetResetSchedule("30d", utcIso(2027, 0, 10, 0)).period).toBe("Dec 10, 2026 - Jan 10, 2027 UTC"); + }); + + it("keeps the clock times when the reset is not at midnight UTC", () => { + expect(describeBudgetResetSchedule("1d", "2026-11-08T18:30:00Z").period).toBe( + "Nov 7, 2026 18:30 - Nov 8, 2026 18:30 UTC", + ); + expect(describeBudgetResetSchedule("30d", "2026-12-01T09:00:00Z").period).toBe( + "Nov 1, 2026 09:00 - Dec 1, 2026 09:00 UTC", + ); }); it("keeps both clock times when an hourly window crosses midnight", () => { - expect(describeBudgetResetSchedule("1h", localIso(2026, 6, 16, 0)).period).toBe( - "Jul 15, 2026 23:00 - Jul 16, 2026 00:00", + expect(describeBudgetResetSchedule("1h", utcIso(2026, 6, 16, 0)).period).toBe( + "Jul 15, 2026 23:00 - Jul 16, 2026 00:00 UTC", ); }); it("shows seconds for a window shorter than a minute", () => { - expect(describeBudgetResetSchedule("30s", localIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:59:30 - 12:00:00"); + expect(describeBudgetResetSchedule("30s", utcIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:59:30 - 12:00:00 UTC"); }); it("shows clock times for a window that starts and ends on the same day", () => { - expect(describeBudgetResetSchedule("1h", localIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:00 - 12:00"); + expect(describeBudgetResetSchedule("1h", utcIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:00 - 12:00 UTC"); }); it("falls back to a one day window for a duration the proxy would not recognise", () => { - expect(describeBudgetResetSchedule("fortnightly", localIso(2026, 6, 15)).period).toBe("Jul 14 - Jul 15, 2026"); + expect(describeBudgetResetSchedule("fortnightly", utcIso(2026, 6, 15, 0)).period).toBe("Jul 14 - Jul 15, 2026 UTC"); }); it("shows no period when the reset date is unparseable", () => { @@ -108,7 +140,7 @@ describe("describeBudgetResetSchedule", () => { it("never describes the window as a cycle", () => { const schedules = [ - describeBudgetResetSchedule("30d", localIso(2026, 6, 15)), + describeBudgetResetSchedule("30d", utcIso(2026, 6, 15)), describeBudgetResetSchedule("30d", null), describeBudgetResetSchedule(null, null), ]; diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.ts b/ui/litellm-dashboard/src/utils/budgetUtils.ts index de144b13dd7..8b87768186d 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.ts @@ -6,18 +6,24 @@ import i18n from "@/i18n/i18n"; dayjs.extend(utc); -export function formatBudgetReset(iso: string | null | undefined): string | null { +export function formatBudgetResetTime(iso: string | null | undefined, t: TFunction = i18n.t.bind(i18n)): string | null { if (!iso) return null; - const resetDate = dayjs(iso); + const resetDate = dayjs.utc(iso); if (!resetDate.isValid()) return null; - return resetDate.format("MMM D, YYYY"); + const timestamp = t("keyDetail.keyInfoView.timestamp", { + date: resetDate.format("MMM D, YYYY"), + time: resetDate.format("h:mm A"), + }); + return `${timestamp} UTC`; } +const isMidnight = (instant: Dayjs): boolean => instant.isSame(instant.startOf("day")); + export function formatBudgetResetUtc(iso: string | null | undefined): string | null { if (!iso) return null; const resetDate = dayjs.utc(iso); if (!resetDate.isValid()) return null; - return resetDate.format("MMM D, YYYY"); + return resetDate.format(isMidnight(resetDate) ? "MMM D, YYYY" : "MMM D, YYYY HH:mm"); } export interface BudgetResetSchedule { @@ -62,7 +68,7 @@ export function getBudgetPeriodStart(budgetDuration: string, resetAt: Dayjs): Da export function formatBudgetPeriod(start: Dayjs, end: Dayjs): string { const windowSeconds = end.diff(start, "second"); - if (windowSeconds < 24 * 60 * 60) { + if (windowSeconds < 24 * 60 * 60 || !isMidnight(start) || !isMidnight(end)) { const time = windowSeconds < 60 ? "HH:mm:ss" : "HH:mm"; const endFormat = start.isSame(end, "day") ? time : `MMM D, YYYY ${time}`; return `${start.format(`MMM D, YYYY ${time}`)} - ${end.format(endFormat)}`; @@ -76,17 +82,21 @@ export function describeBudgetResetSchedule( budgetResetAt: string | null | undefined, t: TFunction = i18n.t.bind(i18n), ): BudgetResetSchedule { - const resetDate = budgetResetAt ? dayjs(budgetResetAt) : null; + const resetDate = budgetResetAt ? dayjs.utc(budgetResetAt) : null; if (resetDate?.isValid()) { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const label = resetDate.format("MMM D, YYYY HH:mm"); + const label = resetDate.format("MMM D, YYYY HH:mm [UTC]"); const period = budgetDuration - ? formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate.utc()).local(), resetDate) + ? `${formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate), resetDate)} UTC` : null; const coverage = period ? t("budgetSchedule.coverageWithPeriod", { period, duration: budgetDuration }) : t("budgetSchedule.coverageWithoutPeriod"); - const tooltipValues = { coverage, label, timeZone, rolloverNote: t("budgetSchedule.rolloverNote") }; + const tooltipValues = { + coverage, + label: resetDate.format("MMM D, YYYY HH:mm"), + timeZone: "UTC", + rolloverNote: t("budgetSchedule.rolloverNote"), + }; return { label, resetAt: resetDate.toISOString(),