From 3322f247834b2727c16eb8834b39e55360d5bda5 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Sat, 25 Jul 2026 10:49:03 -0700 Subject: [PATCH 1/2] fix(cost-optimization): replace savings methodology Collapse with per-card info popovers Swap the antd Collapse "How savings are calculated" panel for click-triggered shadcn Popovers on each SummaryCard, so the explanation sits next to the metric it describes instead of in one combined block. (cherry picked from commit 3c287576b2e6c130454e4255101e347e32d1c28b) --- .../_components/UsageTab.tsx | 67 +++++++------------ 1 file changed, 26 insertions(+), 41 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index 6551cf0e6d7..0d9c1042470 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -1,11 +1,12 @@ "use client"; import React, { useEffect, useMemo, useState } from "react"; -import { Collapse } from "antd"; +import { Info } from "lucide-react"; import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP } from "@/components/shared/charts"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { getToolSpend, ToolSpendResponse } from "@/components/networking"; import { SpendMetrics } from "@/components/UsagePage/types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; @@ -33,45 +34,24 @@ const compressionOf = (m: SpendMetrics): number => m.compression_savings_spend ? const cachingOf = (m: SpendMetrics): number => m.prompt_caching_savings_spend ?? 0; const savedTokensOf = (m: SpendMetrics): number => m.compression_saved_tokens ?? 0; -const MethodologyNote = () => ( - How savings are calculated, - children: ( -
-

- Savings are computed for each request when it is logged, using the provider's reported usage and the - model's pricing, then summed into a daily rollup. Totals below are read from that rollup over the - selected date range, so the numbers never require a scan of raw request logs. -

-

- Compression savings are the tokens Headroom removed before the call, priced at the model's input - rate: compression_saved_tokens * input_cost_per_token -

-

- Prompt caching savings are the tokens the provider served from cache (Anthropic{" "} - cache_read_input_tokens, or OpenAI-style prompt_tokens_details.cached_tokens), - priced at the discount between the normal input rate and the cache-read rate:{" "} - cache_read_input_tokens * max(input_cost_per_token - cache_read_input_token_cost, 0) -

-

- Total saved is the sum of both drivers. Models without a separate cache-read price in the pricing map - contribute zero caching savings rather than erroring. -

-
- ), - }, - ]} - /> -); - -const SummaryCard = ({ label, value, hint }: { label: string; value: string; hint?: string }) => ( +const SummaryCard = ({ label, value, hint, info }: { label: string; value: string; hint?: string; info?: string }) => ( - + {label} + {info && ( + + + + + + {info} + + + )}

{value}

@@ -150,8 +130,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { return (
-
- +
@@ -165,8 +144,14 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { label="Compression savings" value={usd(compressionTotal)} hint={`${formatNumberWithCommas(savedTokensTotal)} tokens compressed`} + info="Tokens Headroom removed before the call, priced at the model's input rate." + /> + -
From 99618fb392c843dc2ceaa5feecb6de90aefa9f31 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 24 Jul 2026 20:09:23 -0700 Subject: [PATCH 2/2] feat(cost-optimization): anchor the savings line at a $0 range start The "Savings over time" chart plotted a single floating dot for short ranges: the daily rollup keys spend by YYYY-MM-DD, so a one-day range is one point by construction. Rather than stand up an hourly SpendLogs data source, read that same daily rollup and make the cumulative line legible. - Cumulative | Per day toggle. Cumulative accumulates within the range; Per day shows the raw stacked bars. - Cumulative prepends a synthetic $0 point at the range start (withStartAnchor) so the line rises from zero to the running total instead of floating. An empty series is left untouched so the chart's own "No data" state shows. - Order the daily series oldest-first (the rollup arrives newest-first) so the axis reads left to right and the total accumulates forward. - Header legend, dots on small series, and a "No data" guard on BarChart. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 1fa40bd168f768d6145dead965e989e22f6f0702) --- .../_components/UsageTab.test.tsx | 108 ++++++++++++++++-- .../_components/UsageTab.tsx | 98 +++++++++++++--- .../_components/costOptimizationUtils.test.ts | 87 +++++++++++++- .../_components/costOptimizationUtils.ts | 60 ++++++++++ .../shared/charts/area_chart.test.tsx | 8 ++ .../components/shared/charts/area_chart.tsx | 4 +- .../shared/charts/bar_chart.test.tsx | 7 ++ .../components/shared/charts/bar_chart.tsx | 11 ++ 8 files changed, 355 insertions(+), 28 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index b157ab5dd0d..5c26ac30477 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -1,5 +1,6 @@ import { render } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ToolSpendResponse } from "@/components/networking"; import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; @@ -80,13 +81,20 @@ const day = (date: string, metrics: Partial): DailyData => ({ }, }); -const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => { +interface RenderOptions { + toolSpend?: ToolSpendResponse; + from?: Date; + to?: Date; +} + +const renderWith = (results: DailyData[], options: RenderOptions = {}) => { + const { toolSpend = emptyToolSpend, from = new Date(2026, 6, 1), to = new Date(2026, 6, 14) } = options; mockGetToolSpend.mockResolvedValue(toolSpend); return render( { ); }; +const readSeries = (element: HTMLElement) => JSON.parse(element.getAttribute("data-series") ?? "[]"); + describe("UsageTab", () => { + beforeEach(() => { + mockGetToolSpend.mockReset(); + }); + it("sums compression and caching dollars across days into the summary cards", () => { const { getByText } = renderWith([ day("2026-07-12", { @@ -117,16 +131,88 @@ describe("UsageTab", () => { expect(getByText("140,000 tokens compressed")).toBeInTheDocument(); }); - it("builds a per-day time series and per-driver donut from the daily rows", () => { - const { getByTestId } = renderWith([ - day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }), - day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }), - ]); + const twoDays = () => [ + day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }), + day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }), + ]; - const series = JSON.parse(getByTestId("area-chart").getAttribute("data-series") ?? "[]"); + it("opens on a running total anchored at $0 at the start of the range", () => { + const { getByTestId } = renderWith(twoDays()); + + // Cumulative prepends a synthetic $0 point at the range start (Jul 1) so the + // line rises from zero rather than floating; the daily running totals follow. + const series = readSeries(getByTestId("area-chart")); + expect(series).toHaveLength(3); + expect(series[0]).toMatchObject({ date: "Jul 1", Compression: 0, "Prompt caching": 0 }); + expect(series[1]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 }); + expect(series[2].Compression).toBeCloseTo(0.14, 5); + expect(series[2]["Prompt caching"]).toBeCloseTo(0.016, 5); + }); + + it("rises from $0 to the day's cumulative total for a single-day range", () => { + // The original complaint: a one-day range plotted a single floating dot. The + // synthetic start anchor gives the line a zero origin to climb from. + const oneDay = new Date(2026, 6, 24); + const { getByTestId } = renderWith( + [day("2026-07-24", { compression_savings_spend: 0.2, prompt_caching_savings_spend: 0.05 })], + { from: oneDay, to: oneDay }, + ); + + const series = readSeries(getByTestId("area-chart")); + expect(series).toHaveLength(2); + expect(series[0]).toMatchObject({ date: "Jul 24", Compression: 0, "Prompt caching": 0 }); + expect(series[1]).toMatchObject({ date: "Jul 24", Compression: 0.2, "Prompt caching": 0.05 }); + }); + + it("plots the daily series oldest first even though the rollup arrives newest first", async () => { + // The daily activity endpoint returns days newest first; the chart must + // still read left to right in time, and the running total must climb toward + // the newest day, not fall away from it. + const newestFirst = [ + day("2026-07-13", { prompt_caching_savings_spend: 0.1 }), + day("2026-07-12", { prompt_caching_savings_spend: 0.04 }), + ]; + const { getByTestId, getByRole } = renderWith(newestFirst); + + // The $0 anchor leads, then the days climb oldest to newest. + const cumulative = readSeries(getByTestId("area-chart")); + expect(cumulative.map((p: { date: string }) => p.date)).toEqual(["Jul 1", "Jul 12", "Jul 13"]); + expect(cumulative[1]["Prompt caching"]).toBeCloseTo(0.04, 5); + expect(cumulative[2]["Prompt caching"]).toBeCloseTo(0.14, 5); + expect(cumulative[2]["Prompt caching"]).toBeGreaterThan(cumulative[1]["Prompt caching"]); + + await userEvent.click(getByRole("tab", { name: "Per day" })); + const perDay = readSeries(getByTestId("bar-chart")); + expect(perDay.map((p: { date: string }) => p.date)).toEqual(["Jul 12", "Jul 13"]); + }); + + it("draws bars of the raw per-interval readings on the other tab", async () => { + const { getByRole, getByTestId, queryByTestId } = renderWith(twoDays()); + + // Cumulative opens on the area line. + expect(getByTestId("area-chart")).toBeInTheDocument(); + + await userEvent.click(getByRole("tab", { name: "Per day" })); + + // Per day switches to a bar chart of the unaccumulated daily savings, with no + // synthetic anchor prepended. + expect(queryByTestId("area-chart")).toBeNull(); + const series = readSeries(getByTestId("bar-chart")); expect(series).toHaveLength(2); expect(series[0]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 }); expect(series[1]).toMatchObject({ Compression: 0.1, "Prompt caching": 0.01 }); + }); + + it("says what the line means and over what range", async () => { + const { getByText, getByRole } = renderWith(twoDays()); + + expect(getByText("Running total saved · Jul 1 – Jul 14")).toBeInTheDocument(); + await userEvent.click(getByRole("tab", { name: "Per day" })); + expect(getByText("Saved per day · Jul 1 – Jul 14")).toBeInTheDocument(); + }); + + it("builds the per-driver donut from the range totals, not the running total", () => { + const { getByTestId } = renderWith(twoDays()); const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]"); expect(slices).toEqual([ @@ -152,7 +238,7 @@ describe("UsageTab", () => { start_date: "2026-07-12", end_date: "2026-07-12", }; - const { findAllByTestId } = renderWith([day("2026-07-12", {})], toolSpend); + const { findAllByTestId } = renderWith([day("2026-07-12", {})], { toolSpend }); const bars = await findAllByTestId("bar-chart"); const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]"); @@ -172,7 +258,7 @@ describe("UsageTab", () => { start_date: "2026-07-12", end_date: "2026-07-12", }; - const { findAllByTestId, getAllByTestId } = renderWith([day("2026-07-12", {})], toolSpend); + const { findAllByTestId, getAllByTestId } = renderWith([day("2026-07-12", {})], { toolSpend }); const bars = await findAllByTestId("bar-chart"); const [totalByTool, dailyByTool] = bars.slice(-2); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index 0d9c1042470..ec37418e0b5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -7,10 +7,23 @@ import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP } import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getToolSpend, ToolSpendResponse } from "@/components/networking"; import { SpendMetrics } from "@/components/UsagePage/types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { buildDailyToolSeries, topToolsBySpend, usd } from "./costOptimizationUtils"; +import { + buildDailyToolSeries, + formatRangeLabel, + localIsoDay, + MAX_POINTS_WITH_DOTS, + SAVINGS_SERIES, + SavingsAccumulation, + SavingsPoint, + toCumulative, + topToolsBySpend, + usd, + withStartAnchor, +} from "./costOptimizationUtils"; import { DailyActivityRange } from "./useDailyActivityRange"; interface UsageTabProps { @@ -25,6 +38,8 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = { end_date: null, }; +const SAVINGS_COLORS = ["emerald", "blue"] as const; + const shortDate = (iso: string): string => new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" }); @@ -93,16 +108,41 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { const savedTokensTotal = useMemo(() => results.reduce((sum, d) => sum + savedTokensOf(d.metrics), 0), [results]); const totalSaved = compressionTotal + cachingTotal; - const overTime = useMemo( + const [accumulation, setAccumulation] = useState("cumulative"); + + // The daily rollup arrives newest first; sort on the raw ISO date so the axis + // reads oldest to newest and the running total accumulates forward in time + // rather than backward. Sort here, before shortDate() drops the year and makes + // the labels unsortable. + const perInterval = useMemo( () => - results.map((d) => ({ - date: shortDate(d.date), - Compression: compressionOf(d.metrics), - "Prompt caching": cachingOf(d.metrics), - })), + [...results] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((d) => ({ + date: shortDate(d.date), + Compression: compressionOf(d.metrics), + "Prompt caching": cachingOf(d.metrics), + })), [results], ); + // Cumulative anchors on a synthetic $0 point at the range start so a short + // range (down to a single day) rises from zero instead of floating as one dot. + const overTime = useMemo(() => { + if (accumulation !== "cumulative") return perInterval; + const startLabel = startTime ? shortDate(localIsoDay(startTime)) : ""; + return withStartAnchor(toCumulative(perInterval), startLabel); + }, [accumulation, perInterval, startTime]); + + const intervalLabel = "Per day"; + const rangeLabel = formatRangeLabel(startTime ?? undefined, endTime ?? undefined); + const savingsSubtitle = [ + accumulation === "cumulative" ? "Running total saved" : `Saved ${intervalLabel.toLowerCase()}`, + rangeLabel, + ] + .filter(Boolean) + .join(" \u00b7 "); + const byDriver = useMemo( () => [ @@ -157,16 +197,44 @@ const UsageTab: React.FC = ({ accessToken, activity }) => {
- Savings over time +
+
+ Savings +

{savingsSubtitle}

+
+
+ + setAccumulation(value as SavingsAccumulation)}> + + Cumulative + {intervalLabel} + + +
+
- + {accumulation === "cumulative" ? ( + + ) : ( + + )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts index 2f1558465d5..dc08799a3c4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest"; import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; import type { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking"; -import { buildDailyToolSeries, computeCacheLeakage, isAnthropicModel, topToolsBySpend } from "./costOptimizationUtils"; +import { + buildDailyToolSeries, + computeCacheLeakage, + formatRangeLabel, + isAnthropicModel, + localIsoDay, + toCumulative, + topToolsBySpend, + withStartAnchor, +} from "./costOptimizationUtils"; const metrics = (overrides: Partial): SpendMetrics => ({ spend: 0, @@ -221,3 +230,79 @@ describe("topToolsBySpend", () => { expect(topToolsBySpend(byTool, 2).map((t) => t.tool_name)).toEqual(["b", "c"]); }); }); + +describe("localIsoDay", () => { + it("reads the date off the viewer's clock rather than shifting it to UTC", () => { + expect(localIsoDay(new Date(2026, 6, 23, 23, 30))).toBe("2026-07-23"); + expect(localIsoDay(new Date(2026, 0, 5, 0, 30))).toBe("2026-01-05"); + }); +}); + +describe("toCumulative", () => { + const point = (date: string, compression: number, caching: number) => ({ + date, + Compression: compression, + "Prompt caching": caching, + }); + + it("turns each reading into everything saved up to that point", () => { + const running = toCumulative([point("Jul 1", 1, 10), point("Jul 2", 2, 20), point("Jul 3", 3, 30)]); + expect(running.map((p) => p.Compression)).toEqual([1, 3, 6]); + expect(running.map((p) => p["Prompt caching"])).toEqual([10, 30, 60]); + }); + + it("accumulates each driver on its own, so one flat series cannot lift the other", () => { + const running = toCumulative([point("Jul 1", 0, 5), point("Jul 2", 0, 5)]); + expect(running.map((p) => p.Compression)).toEqual([0, 0]); + expect(running.map((p) => p["Prompt caching"])).toEqual([5, 10]); + }); + + it("never falls, even across a quiet interval", () => { + const running = toCumulative([point("Jul 1", 4, 0), point("Jul 2", 0, 0), point("Jul 3", 1, 0)]); + expect(running.map((p) => p.Compression)).toEqual([4, 4, 5]); + }); + + it("keeps the labels and length of the readings it was given", () => { + const running = toCumulative([point("9am", 1, 1), point("10am", 1, 1)]); + expect(running.map((p) => p.date)).toEqual(["9am", "10am"]); + expect(toCumulative([])).toEqual([]); + }); +}); + +describe("withStartAnchor", () => { + const point = (date: string, compression: number, caching: number) => ({ + date, + Compression: compression, + "Prompt caching": caching, + }); + + it("lifts a single-day cumulative off a floating dot by prepending a $0 origin", () => { + const anchored = withStartAnchor([point("Jul 24", 12, 30)], "Jul 24"); + expect(anchored).toEqual([point("Jul 24", 0, 0), point("Jul 24", 12, 30)]); + }); + + it("starts the range at zero without disturbing the running totals that follow", () => { + const anchored = withStartAnchor([point("Jul 16", 5, 1), point("Jul 17", 9, 4)], "Jul 16"); + expect(anchored.map((p) => p.Compression)).toEqual([0, 5, 9]); + expect(anchored.map((p) => p["Prompt caching"])).toEqual([0, 1, 4]); + }); + + it("leaves an empty series alone so the chart's own no-data state can show", () => { + expect(withStartAnchor([], "Jul 24")).toEqual([]); + }); +}); + +describe("formatRangeLabel", () => { + it("reads as a range across days", () => { + expect(formatRangeLabel(new Date(2026, 6, 16), new Date(2026, 6, 23))).toBe("Jul 16 \u2013 Jul 23"); + }); + + it("collapses to one date when both ends are the same day", () => { + expect(formatRangeLabel(new Date(2026, 6, 23), new Date(2026, 6, 23))).toBe("Jul 23"); + }); + + it("is empty until both ends are picked", () => { + expect(formatRangeLabel(undefined, new Date(2026, 6, 23))).toBe(""); + expect(formatRangeLabel(new Date(2026, 6, 23), undefined)).toBe(""); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts index 30f851bfeca..32eb6ae198d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts @@ -149,3 +149,63 @@ const seedPoint = (date: string, toolNames: readonly string[]): DailyToolSpendPo export const topToolsBySpend = (byTool: readonly ToolSpendEntry[], limit = 8): ToolSpendEntry[] => [...byTool].sort((a, b) => b.spend - a.spend).slice(0, limit); + +export const localIsoDay = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +export type SavingsAccumulation = "cumulative" | "per-interval"; + +// A type alias, not an interface: only aliases get the implicit index signature +// that the chart wrappers' `Record` datum bound requires. +export type SavingsPoint = { + date: string; + Compression: number; + "Prompt caching": number; +}; + +export const SAVINGS_SERIES = ["Compression", "Prompt caching"] as const; + +/** + * Running total of each series across the selected window. The total restarts + * at the beginning of the range rather than carrying in earlier spend, which is + * what "running total saved, " claims on the card. + */ +export const toCumulative = (points: readonly SavingsPoint[]): SavingsPoint[] => + points.reduce((acc, point) => { + const previous = acc[acc.length - 1]; + return [ + ...acc, + { + date: point.date, + Compression: (previous?.Compression ?? 0) + point.Compression, + "Prompt caching": (previous?.["Prompt caching"] ?? 0) + point["Prompt caching"], + }, + ]; + }, []); + +/** + * Prepend a synthetic $0 point at the start of the range so the cumulative line + * rises from zero instead of floating as a single dot. The daily rollup only + * resolves whole days, so a one-day range would otherwise be one point; with the + * anchor it reads as "start of range $0 climbing to the range's running total". + * An empty series is left untouched so the chart's own "No data" state shows. + */ +export const withStartAnchor = (cumulative: readonly SavingsPoint[], startLabel: string): SavingsPoint[] => + cumulative.length === 0 + ? [...cumulative] + : [{ date: startLabel, Compression: 0, "Prompt caching": 0 }, ...cumulative]; + +/** "Jul 16 – Jul 23", collapsing to a single date when the range is one day. */ +export const formatRangeLabel = (from: Date | undefined, to: Date | undefined): string => { + if (!from || !to) return ""; + const short = (d: Date) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const start = short(from); + const end = short(to); + return start === end ? start : `${start} – ${end}`; +}; + +/** + * Dots mark each reading, as in the design. Past this many readings they crowd + * into a solid band and stop being readable, so the line carries it alone. + */ +export const MAX_POINTS_WITH_DOTS = 31; diff --git a/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx index f48674b88ee..f3ef72392b0 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx @@ -40,4 +40,12 @@ describe("AreaChart", () => { expect(area.getAttribute("fill")).toMatch(/^url\(#fill-/); } }); + + it("marks each reading with a dot only when asked", () => { + const withoutDots = render(); + expect(withoutDots.container.querySelectorAll("circle.recharts-dot")).toHaveLength(0); + + const withDots = render(); + expect(withDots.container.querySelectorAll("circle.recharts-dot").length).toBeGreaterThanOrEqual(data.length); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx index 35278930294..eba9f6b1d82 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx @@ -17,6 +17,7 @@ export type AreaChartProps> = { showLegend?: boolean; showGridLines?: boolean; showTooltip?: boolean; + showDots?: boolean; customTooltip?: ChartTooltipComponent; className?: string; style?: React.CSSProperties; @@ -32,6 +33,7 @@ export function AreaChart>({ showLegend = true, showGridLines = true, showTooltip = true, + showDots = false, customTooltip, className, style, @@ -94,7 +96,7 @@ export function AreaChart>({ strokeWidth={2} fill={`url(#fill-${gradientId}-${i})`} fillOpacity={1} - dot={false} + dot={showDots ? { r: 3.5, strokeWidth: 2, stroke: fills[i], fill: "var(--background, #fff)" } : false} isAnimationActive={false} /> ))} diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx index 7c949f6596f..cb0d5c603a4 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx @@ -20,6 +20,13 @@ describe("BarChart", () => { expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"])); }); + it("renders the No data placeholder instead of a chart when data is empty", () => { + const { container, getByText } = render(); + + expect(getByText("No data")).toBeTruthy(); + expect(container.querySelector('[data-slot="chart"]')).toBeNull(); + }); + it("falls back to the tremor default color cycle when no colors are passed", () => { const { container } = render(); diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx index 612487bd9d3..ab2cc66eaf4 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx @@ -50,6 +50,17 @@ export function BarChart>({ className, style, }: BarChartProps) { + if (data.length === 0) { + return ( +
+

No data

+
+ ); + } + const fills = categoryFills(colorByDatum ? data.length : categories.length, colors); const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }])); const vertical = layout === "vertical";