diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
index 97289a7ca46..27261768b8d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
@@ -18,6 +18,7 @@ vi.mock("@/components/shared/charts", () => ({
AreaChart: () =>
,
DonutChart: () => ,
BarChart: () => ,
+ CustomLegend: () => ,
DEFAULT_COLOR_CYCLE: ["emerald"],
}));
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 ab1cd146adc..f84167f5a82 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";
@@ -25,6 +26,9 @@ vi.mock("@/components/shared/charts", () => ({
BarChart: ({ data, categories }: { data: unknown; categories: string[] }) => (
),
+ CustomLegend: ({ categories }: { categories: readonly string[] }) => (
+ {categories.join(",")}
+ ),
DEFAULT_COLOR_CYCLE: ["emerald", "blue", "violet", "amber"],
}));
@@ -58,13 +62,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", {
@@ -95,16 +112,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([
@@ -131,7 +220,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") ?? "[]");
@@ -146,7 +235,7 @@ describe("UsageTab", () => {
start_date: "2026-07-05",
end_date: "2026-07-14",
};
- const { findByText } = renderWith([day("2026-07-12", {})], toolSpend);
+ const { findByText } = renderWith([day("2026-07-12", {})], { toolSpend });
expect(await findByText(/capped at 30 days before the end of the selected range/)).toBeInTheDocument();
});
@@ -159,7 +248,7 @@ describe("UsageTab", () => {
start_date: "2026-07-01",
end_date: "2026-07-14",
};
- const { findAllByTestId, queryByText } = renderWith([day("2026-07-12", {})], toolSpend);
+ const { findAllByTestId, queryByText } = renderWith([day("2026-07-12", {})], { toolSpend });
await findAllByTestId("bar-chart");
expect(queryByText(/capped at 30 days before the end of the selected range/)).not.toBeInTheDocument();
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 de5243096fb..15ce84b8445 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
@@ -3,14 +3,27 @@
import React, { useEffect, useMemo, useState } from "react";
import { Info } from "lucide-react";
-import { AreaChart, BarChart, DonutChart, DEFAULT_COLOR_CYCLE } from "@/components/shared/charts";
+import { AreaChart, BarChart, CustomLegend, DonutChart, DEFAULT_COLOR_CYCLE } 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 { 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 {
@@ -26,6 +39,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" });
@@ -95,16 +110,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(
() =>
[
@@ -159,16 +199,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 d5253c86c6f..b30a252659f 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 6ee3319dc10..7069ececb70 100644
--- a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx
+++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx
@@ -46,6 +46,17 @@ export function BarChart>({
className,
style,
}: BarChartProps) {
+ if (data.length === 0) {
+ return (
+
+ );
+ }
+
const fills = categoryFills(categories.length, colors);
const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }]));
const vertical = layout === "vertical";