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 6551cf0e6d7..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 @@ -1,15 +1,29 @@ "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 { 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 { @@ -24,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" }); @@ -33,45 +49,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}

@@ -113,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( () => [ @@ -150,8 +170,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { return (
-
- +
@@ -165,23 +184,57 @@ 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." + /> + -
- 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";