feat(ui): add a Home page as the dashboard root with usage summary, recent launches, blog updates and a model leaderboard (#45858)

* feat(ui): add a Home page as the dashboard root with usage summary, recent launches, blog updates and a model leaderboard

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): fall back to spend-log request counts on Home when the gateway has recorded none

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): cover the Home page totals, launches, leaderboard and updates feed

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(ui): render Home Updates as rows and return up to 6 blog posts from /public/litellm_blog_posts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(ui): filter Home Updates by the new-model and api-change blog tags

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(blog): import NotRequired from typing_extensions for py3.10 and test the endpoint passes every fetched post through

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* revert(ui): drop the Updates filter chips, keep Updates as a plain row list

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(ui): restore generated schema.d.ts to main

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(ui): label the Home key button Create New Virtual Key

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(ui): drop the What's new sublabel on Home

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): show an error with retry when Home usage fails to load and move the Home test to the integration tier

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-10 16:57:44 -07:00 • committed by GitHub
parent 705bd84523
commit 9e2aae8490
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 638 additions and 106 deletions

View file

@ -22,6 +22,7 @@ from litellm import verbose_logger
from litellm.types.llms.base import LiteLLMBaseModel
BLOG_POSTS_TTL_SECONDS: Final[int] = 3600 # 1 hour
BLOG_POSTS_MAX: Final[int] = 6
class BlogPost(LiteLLMBaseModel):
@ -40,7 +41,7 @@ class GetBlogPosts:
Fetches, validates, and caches LiteLLM blog posts.
- Fetches RSS feed from docs site with a 5-second timeout
- Parses the XML and extracts the latest blog post
- Parses the XML and extracts the latest BLOG_POSTS_MAX blog posts
- Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour)
- Falls back to the bundled local backup on any failure
"""
@ -66,7 +67,7 @@ class GetBlogPosts:
return response.text
@staticmethod
def parse_rss_to_posts(xml_text: str, max_posts: int = 1) -> list[dict[str, str]]:
def parse_rss_to_posts(xml_text: str, max_posts: int = BLOG_POSTS_MAX) -> list[dict[str, str]]:
"""
Parse RSS XML and return a list of blog post dicts.

View file

@ -490,7 +490,7 @@ async def get_litellm_blog_posts():
verbose_logger.warning("LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e))
posts_data = GetBlogPosts.load_local_blog_posts()
posts: Final = [BlogPost(**p) for p in posts_data[:5]]
posts: Final = [BlogPost(**p) for p in posts_data]
return BlogPostsResponse(posts=posts)

View file

@ -45,8 +45,8 @@ def test_get_blog_posts_returns_response_shape(client):
assert post["url"] == "https://www.litellm.ai/blog/test"
def test_get_blog_posts_limits_to_five(client):
"""Endpoint returns at most 5 posts."""
def test_get_blog_posts_returns_every_fetched_post(client):
"""The parser owns the cap (BLOG_POSTS_MAX), so the endpoint passes every fetched post through."""
many_posts = [
{
"title": f"Post {i}",
@ -64,7 +64,7 @@ def test_get_blog_posts_limits_to_five(client):
response = client.get("/public/litellm_blog_posts")
assert response.status_code == 200
assert len(response.json()["posts"]) == 5
assert [p["title"] for p in response.json()["posts"]] == [f"Post {i}" for i in range(10)]
def test_get_blog_posts_returns_local_backup_on_failure(client):

View file

@ -2,17 +2,18 @@
import time
from unittest.mock import MagicMock, patch
from xml.etree import ElementTree
import pytest
import litellm
from litellm.litellm_core_utils.get_blog_posts import (
BLOG_POSTS_MAX,
BlogPost,
BlogPostsResponse,
GetBlogPosts,
get_blog_posts,
)
from xml.etree import ElementTree
SAMPLE_RSS = """\
<?xml version="1.0" encoding="UTF-8"?>
@ -71,6 +72,18 @@ def test_parse_rss_to_posts_multiple():
assert posts[1]["title"] == "Second Post"
def test_parse_rss_to_posts_default_cap():
items = "".join(
f"<item><title>Post {i}</title><link>https://docs.litellm.ai/blog/{i}</link>"
f"<description>D</description><pubDate>Thu, 0{1 + i % 9} Jan 2026 00:00:00 GMT</pubDate></item>"
for i in range(BLOG_POSTS_MAX + 1)
)
posts = GetBlogPosts.parse_rss_to_posts(f"<rss><channel>{items}</channel></rss>")
assert len(posts) == BLOG_POSTS_MAX
assert posts[0]["title"] == "Post 0"
assert posts[-1]["title"] == f"Post {BLOG_POSTS_MAX - 1}"
def test_parse_rss_to_posts_invalid_xml():
with pytest.raises(ElementTree.ParseError):
GetBlogPosts.parse_rss_to_posts("not xml")
@ -82,9 +95,7 @@ def test_parse_rss_to_posts_missing_channel():
def test_validate_blog_posts_valid():
posts = [
{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}
]
posts = [{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
assert GetBlogPosts.validate_blog_posts(posts) is True
@ -108,7 +119,7 @@ def test_get_blog_posts_success():
):
posts = get_blog_posts(url=litellm.blog_posts_url)
assert len(posts) == 1
assert len(posts) == 2
assert posts[0]["title"] == "Test Post"
@ -163,9 +174,7 @@ def test_get_blog_posts_ttl_cache_not_refetched():
m.raise_for_status = MagicMock()
return m
with patch(
"litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get
):
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get):
posts = get_blog_posts(url=litellm.blog_posts_url)
assert call_count == 0 # cache hit, no fetch
@ -196,7 +205,7 @@ def test_get_blog_posts_ttl_expired_refetches():
posts = get_blog_posts(url=litellm.blog_posts_url)
mock_get.assert_called_once()
assert len(posts) == 1
assert len(posts) == 2
def test_get_blog_posts_local_env_var_skips_remote(monkeypatch):
@ -219,9 +228,5 @@ def test_blog_post_pydantic_model():
def test_blog_posts_response_pydantic_model():
resp = BlogPostsResponse(
posts=[
BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")
]
)
resp = BlogPostsResponse(posts=[BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")])
assert len(resp.posts) == 1

View file

@ -0,0 +1,149 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import { all_admin_roles } from "@/utils/roles";
import HomePage from "./HomePage";
import { WHATS_NEW_ITEMS } from "./homeContent";
const auth = vi.hoisted(() => ({
accessToken: "sk-test",
userRole: "",
userId: "u1",
isViewOnly: false,
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => auth }));
const network = vi.hoisted(() => ({
aggregated: vi.fn(),
gateway: vi.fn(),
userInfo: vi.fn(),
}));
vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
dailyActivityAggregatedCall: (...args: unknown[]) => network.aggregated(...args),
gatewayDailyActivityCall: (...args: unknown[]) => network.gateway(...args),
userGetInfoV2: (...args: unknown[]) => network.userInfo(...args),
getProxyBaseUrl: () => "http://proxy.test",
}));
const blogPosts = { posts: [{ title: "Post one", description: "d", date: "2026-10-09", url: "https://x/1" }] };
const dayResult = (date: string, model: string, tokens: number) => {
const metrics = {
spend: 1,
prompt_tokens: tokens,
completion_tokens: 0,
total_tokens: tokens,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
return {
date,
metrics,
breakdown: {
models: {},
model_groups: { [model]: { metrics, metadata: {}, api_key_breakdown: {} } },
providers: {},
api_keys: {},
entities: {},
mcp_servers: {},
},
};
};
const activity: DailyActivityAggregatedResponse = {
results: [
dayResult("2026-10-08", "gpt-6.1-sol", 900),
dayResult("2026-10-09", "claude-haiku-5-5", 100),
] as unknown as DailyActivityAggregatedResponse["results"],
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: 501.74,
total_api_requests: 5118,
total_successful_requests: 5075,
total_failed_requests: 43,
total_tokens: 98_600_000,
},
};
const renderHome = () =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<HomePage />
</QueryClientProvider>,
);
describe("HomePage", () => {
beforeEach(() => {
auth.isViewOnly = false;
auth.userRole = all_admin_roles[0]!;
network.aggregated.mockReset().mockResolvedValue(activity);
network.gateway.mockReset().mockResolvedValue({ total_successful_requests: 0, total_failed_requests: 0 });
network.userInfo.mockReset().mockResolvedValue({ user_info: { max_budget: null } });
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(blogPosts), { status: 200 })));
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("shows the usage totals from the daily activity API and links Create New Virtual Key to the key creation flow", async () => {
renderHome();
expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeInTheDocument();
expect(await screen.findByText("$501.74")).toBeInTheDocument();
expect(screen.getByText("5,118")).toBeInTheDocument();
expect(screen.getByText("98.6M")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /create new virtual key/i })).toHaveAttribute(
"href",
expect.stringMatching(/\/api-keys\?create=true$/),
);
expect(network.aggregated).toHaveBeenCalledWith("user", expect.objectContaining({ accessToken: "sk-test" }));
});
it("hides Create New Virtual Key from view-only users", () => {
auth.isViewOnly = true;
renderHome();
expect(screen.queryByRole("link", { name: /create new virtual key/i })).not.toBeInTheDocument();
});
it("shows an error with a retry instead of zero totals when the usage request fails", async () => {
network.aggregated.mockRejectedValueOnce(new Error("boom"));
renderHome();
expect(await screen.findByText("Failed to load usage")).toBeInTheDocument();
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("$501.74")).toBeInTheDocument();
expect(network.aggregated).toHaveBeenCalledTimes(2);
});
it("lists the curated launches newest first with their links", () => {
renderHome();
const section = screen.getByTestId("home-whats-new");
const links = within(section).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(WHATS_NEW_ITEMS.map((item) => item.href));
const dates = WHATS_NEW_ITEMS.map((item) => item.publishedOn);
expect(dates).toEqual([...dates].sort().reverse());
});
it("ranks model groups by token share on the leaderboard", async () => {
renderHome();
const board = screen.getByTestId("home-leaderboard");
expect(await within(board).findByText("gpt-6.1-sol")).toBeInTheDocument();
const rows = within(board).getAllByRole("listitem");
expect(within(rows[0]!).getByText("gpt-6.1-sol")).toBeInTheDocument();
expect(within(rows[0]!).getByText("90.0%")).toBeInTheDocument();
expect(within(rows[1]!).getByText("claude-haiku-5-5")).toBeInTheDocument();
});
it("renders the blog posts fetched from /public/litellm_blog_posts in the Updates feed", async () => {
renderHome();
const updates = screen.getByTestId("home-updates");
expect(await within(updates).findByRole("link", { name: /post one/i })).toHaveAttribute("href", "https://x/1");
expect(fetch).toHaveBeenCalledWith("http://proxy.test/public/litellm_blog_posts");
});
});

View file

@ -0,0 +1,108 @@
"use client";
import { ArrowRight, Plus, Sparkles } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Page } from "@/components/shared/Page";
import { PageHeader, PageHeaderControls, PageHeaderTitle } from "@/components/shared/PageHeader";
import { uiHref } from "@/utils/uiHref";
import { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart";
import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip";
import { formatPublishedOn, WHATS_NEW_ITEMS } from "./homeContent";
import UpdatesFeed from "./UpdatesFeed";
import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage";
const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const;
export default function HomePage() {
const { isViewOnly } = useAuthorized();
const usage = useHomeUsage();
const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8);
return (
<Page>
<PageHeader>
<PageHeaderTitle>Home</PageHeaderTitle>
{!isViewOnly && (
<PageHeaderControls>
<Button render={<a href={uiHref("api-keys?create=true")} />}>
<Plus />
Create New Virtual Key
</Button>
</PageHeaderControls>
)}
</PageHeader>
<section aria-label={`Usage, last ${HOME_USAGE_DAYS} days`}>
{usage.failed ? (
<div className="flex items-center justify-between gap-2 rounded-xl border bg-card px-4 py-3 text-sm">
<span className="text-destructive">Failed to load usage</span>
<Button variant="outline" size="sm" onClick={usage.retry}>
Retry
</Button>
</div>
) : (
<UsageStatStrip
results={usage.results}
totals={usage.totals}
loading={usage.loading}
requestCountsPending={usage.requestCountsPending}
budget={usage.budget}
/>
)}
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(16rem,1fr)]">
<div className="flex min-w-0 flex-col gap-6">
<section aria-labelledby="home-whats-new" data-testid="home-whats-new" className="flex flex-col gap-3">
<div>
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
<Sparkles className="size-4 text-muted-foreground" />
What&apos;s new in LiteLLM?
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{WHATS_NEW_ITEMS.map((item) => {
const Icon = item.icon;
return (
<a key={item.title} href={item.href} target="_blank" rel="noopener noreferrer" className="group">
<Card className="h-full gap-3 px-4 py-4 transition-colors hover:bg-muted/40">
<div className="flex size-9 items-center justify-center rounded-md bg-muted">
<Icon className="size-4" />
</div>
<div className="text-base font-semibold leading-tight">{item.title}</div>
<p className="m-0 line-clamp-3 text-sm text-muted-foreground">{item.description}</p>
<div className="mt-auto flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>{formatPublishedOn(item.publishedOn)}</span>
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
</Card>
</a>
);
})}
</div>
</section>
<Panel
title="Leaderboard"
subtitle={`Share of tokens over the last ${HOME_USAGE_DAYS} days, with the change between the first and second half of the period`}
testId="home-leaderboard"
>
<Leaderboard
ranking={ranking}
series={series}
metric={LEADERBOARD.metric}
dimension={LEADERBOARD.dimension}
columns={2}
limit={10}
/>
</Panel>
</div>
<UpdatesFeed className="self-start" />
</div>
</Page>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { ArrowUpRight, Newspaper } from "lucide-react";
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
export const BLOG_URL = "https://docs.litellm.ai/blog";
const MAX_ROWS = 6;
const DAY_MS = 86_400_000;
const relativeTime = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
export const formatRelativeDate = (isoDate: string, now: Date = new Date()): string => {
const published = new Date(`${isoDate}T00:00:00`);
if (Number.isNaN(published.getTime())) return isoDate;
const days = Math.max(0, Math.round((now.getTime() - published.getTime()) / DAY_MS));
if (days < 7) return relativeTime.format(-days, "day");
if (days < 30) return relativeTime.format(-Math.round(days / 7), "week");
if (days < 365) return relativeTime.format(-Math.round(days / 30), "month");
return relativeTime.format(-Math.round(days / 365), "year");
};
const Row = ({ children }: { children: React.ReactNode }) => (
<li className="flex gap-3 py-3">
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-background text-muted-foreground">
<Newspaper className="size-3.5" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">{children}</div>
</li>
);
/** Latest posts from the LiteLLM blog RSS feed, via /public/litellm_blog_posts, one row per post. */
export default function UpdatesFeed({ className }: { className?: string }) {
const disabled = useDisableBlogPosts();
const { data, isLoading, isError, refetch } = useBlogPosts();
if (disabled) return null;
const posts = data?.posts.slice(0, MAX_ROWS) ?? [];
return (
<section aria-labelledby="home-updates-title" data-testid="home-updates" className={className}>
<div className="flex items-center justify-between gap-2">
<h2 id="home-updates-title" className="text-lg font-semibold">
Updates
</h2>
<Button variant="ghost" size="sm" render={<a href={BLOG_URL} target="_blank" rel="noopener noreferrer" />}>
View all
<ArrowUpRight className="size-3.5" />
</Button>
</div>
{isLoading && (
<ul className="m-0 list-none p-0">
{[0, 1, 2, 3].map((i) => (
<Row key={i}>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-3/5" />
<Skeleton className="h-3 w-full" />
</Row>
))}
</ul>
)}
{isError && (
<div className="flex items-center justify-between gap-2 py-3 text-sm">
<span className="text-destructive">Failed to load posts</span>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
Retry
</Button>
</div>
)}
{data && posts.length === 0 && <p className="py-3 text-sm text-muted-foreground">No posts yet</p>}
{posts.length > 0 && (
<ol className="m-0 list-none p-0">
{posts.map((post: BlogPost) => (
<Row key={post.url}>
<span className="text-xs text-muted-foreground">{formatRelativeDate(post.date)}</span>
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold leading-5 text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{post.title}
</a>
<span className="line-clamp-2 text-sm leading-5 text-muted-foreground">{post.description}</span>
</Row>
))}
</ol>
)}
</section>
);
}

View file

@ -0,0 +1,40 @@
import { Box, Scale, Zap, type LucideIcon } from "lucide-react";
export interface Launch {
readonly icon: LucideIcon;
readonly title: string;
readonly description: string;
readonly href: string;
readonly publishedOn: string;
}
const LAUNCHES: readonly Launch[] = [
{
icon: Box,
title: "Claude Haiku 5.5",
description: "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.",
href: "https://docs.litellm.ai/blog/claude-haiku-5-5",
publishedOn: "2026-10-07",
},
{
icon: Scale,
title: "Decision Models",
description: "Call decision models at /v1/decisions and try them in the Decisions playground.",
href: "https://docs.litellm.ai/docs/decisions",
publishedOn: "2026-10-07",
},
{
icon: Zap,
title: "OpenAI Ultrafast",
description: "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.",
href: "https://docs.litellm.ai/docs/providers/openai/ultrafast",
publishedOn: "2026-10-06",
},
];
export const WHATS_NEW_ITEMS: readonly Launch[] = [...LAUNCHES].sort((a, b) =>
b.publishedOn.localeCompare(a.publishedOn),
);
export const formatPublishedOn = (isoDate: string): string =>
new Date(`${isoDate}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });

View file

@ -0,0 +1,86 @@
import { useEffect, useMemo, useState } from "react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { gatewayDailyActivityCall } from "@/components/networking";
import { toDailyData } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData } from "@/components/UsagePage/types";
import { all_admin_roles } from "@/utils/roles";
import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity";
import { ENTITY_API } from "@/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns";
import type { GatewayActivity } from "@/app/(dashboard)/usage/_components/components/gatewayActivity";
import { EMPTY_DAILY_ACTIVITY_METADATA, type DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi";
import {
overviewTotals,
type OverviewTotals,
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
export const HOME_USAGE_DAYS = 7;
export interface HomeUsage {
results: readonly DailyData[];
totals: OverviewTotals;
loading: boolean;
requestCountsPending: boolean;
failed: boolean;
budget: number | null;
retry: () => void;
}
/** Last HOME_USAGE_DAYS of the signed-in user's view of usage: deployment-wide for admins, their own otherwise. */
export function useHomeUsage(): HomeUsage {
const { accessToken, userRole, userId } = useAuthorized();
const { data: currentUser } = useCurrentUser();
const isAdmin = all_admin_roles.includes(userRole || "");
const [attempt, setAttempt] = useState(0);
const range = useMemo(() => {
const endTime = new Date();
const startTime = new Date(endTime.getTime() - HOME_USAGE_DAYS * 24 * 60 * 60 * 1000);
return { startTime, endTime };
}, []);
const request = useMemo<DailyActivityRequest | null>(
() =>
accessToken && (isAdmin || userId)
? { accessToken, ...range, entityIds: isAdmin ? null : [userId as string] }
: null,
[accessToken, isAdmin, range, userId],
);
const { data, loading, failed } = useAggregatedDailyActivity({
fetch: () => ENTITY_API.user.aggregated(request as DailyActivityRequest),
enabled: request !== null,
deps: [accessToken, isAdmin, userId, range.startTime.toISOString(), attempt],
});
const [gateway, setGateway] = useState<GatewayActivity | null>(null);
useEffect(() => {
if (!isAdmin || !accessToken) return;
let cancelled = false;
gatewayDailyActivityCall(accessToken, range.startTime, range.endTime)
.then((activity: GatewayActivity) => {
const recorded = activity.total_successful_requests + activity.total_failed_requests > 0;
if (!cancelled) setGateway(recorded ? activity : null);
})
.catch(() => {
if (!cancelled) setGateway(null);
});
return () => {
cancelled = true;
};
}, [isAdmin, accessToken, range, attempt]);
const results = useMemo(() => toDailyData(data), [data]);
const totals = useMemo(
() => overviewTotals(data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, gateway),
[data.metadata, gateway],
);
return {
results,
totals,
loading,
requestCountsPending: loading && gateway === null,
failed,
budget: currentUser?.max_budget ?? null,
retry: () => setAttempt((n) => n + 1),
};
}

View file

@ -0,0 +1,13 @@
"use client";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import HomePage from "./_components/HomePage";
export default function HomeRoutePage() {
const { isLoading, isAuthorized } = useAuthorized();
if (isLoading || !isAuthorized) {
return <LoadingScreen />;
}
return <HomePage />;
}

View file

@ -2,6 +2,7 @@ import { uiHref } from "@/utils/uiHref";
const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
Object.entries({
home: "home",
"api-keys": "api-keys",
models: "models-and-endpoints",
api_ref: "api-reference",

View file

@ -37,8 +37,8 @@ vi.mock("@/contexts/AuthContext", () => ({
}),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: mockUseKeys }));
vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({
default: () => <div data-testid="api-keys-dashboard" />,
vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({
default: () => <div data-testid="home-page" />,
}));
vi.mock("@/components/common_components/LoadingScreen", () => ({
default: () => <div data-testid="loading-screen" />,
@ -86,7 +86,7 @@ describe("dashboard landing", () => {
(role) => {
state.userRole = role;
render(<CreateKeyPage />);
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
expect(screen.getByTestId("home-page")).toBeInTheDocument();
expect(screen.queryByTestId("loading-screen")).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
expect(mockUiHref).not.toHaveBeenCalledWith("connect");
@ -96,7 +96,7 @@ describe("dashboard landing", () => {
it("lands a user who already owns a key on the keys dashboard", () => {
state.keys = [{ token: "sk-abc" }];
render(<CreateKeyPage />);
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
expect(screen.getByTestId("home-page")).toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
@ -110,7 +110,7 @@ describe("dashboard landing", () => {
render(<CreateKeyPage />);
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/logs");
expect(screen.getByTestId("loading-screen")).toBeInTheDocument();
expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument();
expect(screen.queryByTestId("home-page")).not.toBeInTheDocument();
});
it("carries the MCP env-var deep link's other params through the legacy redirect", () => {

View file

@ -1,6 +1,6 @@
"use client";
import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard";
import HomePage from "@/app/(dashboard)/home/_components/HomePage";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { proxyBaseUrl } from "@/components/networking";
import { useAuth } from "@/contexts/AuthContext";
@ -89,7 +89,7 @@ function CreateKeyPageContent() {
return <LoadingScreen />;
}
return <ApiKeysDashboard />;
return <HomePage />;
}
export default function CreateKeyPage() {

View file

@ -6,7 +6,7 @@
* matching colors.
*/
import React, { type ReactNode, useMemo, useState } from "react";
import { type ReactNode, useMemo, useState } from "react";
import type { DailyData } from "@/components/UsagePage/types";
import { KeyRound, Users } from "lucide-react";
import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts";
@ -14,18 +14,15 @@ import {
bucketSeries,
bucketTotals,
labelForDate,
dailyTotals,
formatCompact,
formatLatency,
formatMetricValue,
formatUsd,
type Granularity,
type OverviewTotals,
type UsageMetric,
} from "./overviewData";
import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } from "./BreakdownChart";
import { cn } from "@/lib/cva.config";
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented, Sparkline, Stat } from "./Primitives";
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented } from "./Primitives";
import UsageStatStrip from "./UsageStatStrip";
interface UsageOverviewProps {
results: readonly DailyData[];
@ -41,7 +38,6 @@ interface UsageOverviewProps {
}
const METRIC_NOUN: Record<UsageMetric, string> = { spend: "spend", tokens: "tokens", requests: "requests" };
const BRAND = "#2b3fd6";
const GRANULARITY_OPTIONS = [
{ value: "day", label: "Daily" },
{ value: "week", label: "Weekly" },
@ -69,57 +65,17 @@ export default function UsageOverview({
const { series: dailySeries, ranking } = useBreakdown(results, state, 8);
const series = useMemo(() => bucketSeries(dailySeries, granularity), [dailySeries, granularity]);
const totalsByBucket = useMemo(() => bucketTotals(series), [series]);
const daily = useMemo(() => dailyTotals(results), [results]);
const format = (value: number) => formatMetricValue(value, state.metric);
return (
<div className="grid gap-3">
<div className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
<StatCell trend={<Sparkline data={daily} dataKey="spend" color={BRAND} className="h-14" />}>
<Stat
label="Spend"
value={formatUsd(totals.spend)}
pending={loading}
hint={budget ? `of ${formatUsd(budget)} budget` : "No budget set"}
/>
</StatCell>
<StatCell trend={<Sparkline data={daily} dataKey="requests" color={BRAND} className="h-14" />}>
<Stat label="Total Requests" value={totals.requests.toLocaleString()} pending={requestCountsPending} />
{!requestCountsPending && (
<div className="mt-0.5 flex gap-3 text-xs leading-4 text-muted-foreground">
<span>
<span className="text-success tabular-nums">{totals.successful.toLocaleString()}</span> ok
</span>
<span>
<span className={totals.failed > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
{totals.failed.toLocaleString()}
</span>{" "}
failed
</span>
</div>
)}
</StatCell>
<StatCell trend={<Sparkline data={daily} dataKey="tokens" color={BRAND} className="h-14" />}>
<Stat
label="Total Tokens"
value={loading ? null : formatCompact(totals.tokens)}
exact={`${totals.tokens.toLocaleString()} tokens`}
pending={loading}
hint={loading ? undefined : `${formatCompact(totals.cacheReadTokens)} cache read`}
/>
</StatCell>
<StatCell>
<div className="grid gap-3">
<Stat label="Avg cost / request" value={formatUsd(totals.avgCostPerRequest, 4)} pending={loading} />
<Stat
label="Avg latency"
value={totals.avgLatencyMs === null ? "—" : formatLatency(totals.avgLatencyMs)}
pending={loading}
hint={totals.successRate === null ? undefined : `${totals.successRate.toFixed(1)}% success rate`}
/>
</div>
</StatCell>
</div>
<UsageStatStrip
results={results}
totals={totals}
loading={loading}
requestCountsPending={requestCountsPending}
budget={budget}
/>
<Panel
title={`${granularity === "day" ? "Daily" : "Weekly"} usage`}
@ -181,17 +137,3 @@ export default function UsageOverview({
</div>
);
}
function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) {
return (
<div
className={cn(
"flex min-w-0 flex-col justify-between gap-3 border-b py-4 last:border-b-0 lg:border-b-0",
PANEL_INSET_X,
)}
>
<div className="min-w-0">{children}</div>
{trend}
</div>
);
}

View file

@ -0,0 +1,90 @@
"use client";
import React, { useMemo } from "react";
import type { DailyData } from "@/components/UsagePage/types";
import { cn } from "@/lib/cva.config";
import { dailyTotals, formatCompact, formatLatency, formatUsd, type OverviewTotals } from "./overviewData";
import { PANEL_INSET_X, Sparkline, Stat } from "./Primitives";
const BRAND = "#2b3fd6";
interface UsageStatStripProps {
results: readonly DailyData[];
totals: OverviewTotals;
loading: boolean;
requestCountsPending: boolean;
budget: number | null;
}
/** Spend, requests, tokens and averages for a range, each with a daily sparkline. */
export default function UsageStatStrip({
results,
totals,
loading,
requestCountsPending,
budget,
}: UsageStatStripProps) {
const daily = useMemo(() => dailyTotals(results), [results]);
return (
<div className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
<StatCell trend={<Sparkline data={daily} dataKey="spend" color={BRAND} className="h-14" />}>
<Stat
label="Spend"
value={formatUsd(totals.spend)}
pending={loading}
hint={budget ? `of ${formatUsd(budget)} budget` : "No budget set"}
/>
</StatCell>
<StatCell trend={<Sparkline data={daily} dataKey="requests" color={BRAND} className="h-14" />}>
<Stat label="Total Requests" value={totals.requests.toLocaleString()} pending={requestCountsPending} />
{!requestCountsPending && (
<div className="mt-0.5 flex gap-3 text-xs leading-4 text-muted-foreground">
<span>
<span className="text-success tabular-nums">{totals.successful.toLocaleString()}</span> ok
</span>
<span>
<span className={totals.failed > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
{totals.failed.toLocaleString()}
</span>{" "}
failed
</span>
</div>
)}
</StatCell>
<StatCell trend={<Sparkline data={daily} dataKey="tokens" color={BRAND} className="h-14" />}>
<Stat
label="Total Tokens"
value={loading ? null : formatCompact(totals.tokens)}
exact={`${totals.tokens.toLocaleString()} tokens`}
pending={loading}
hint={loading ? undefined : `${formatCompact(totals.cacheReadTokens)} cache read`}
/>
</StatCell>
<StatCell>
<div className="grid gap-3">
<Stat label="Avg cost / request" value={formatUsd(totals.avgCostPerRequest, 4)} pending={loading} />
<Stat
label="Avg latency"
value={totals.avgLatencyMs === null ? "—" : formatLatency(totals.avgLatencyMs)}
pending={loading}
hint={totals.successRate === null ? undefined : `${totals.successRate.toFixed(1)}% success rate`}
/>
</div>
</StatCell>
</div>
);
}
function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) {
return (
<div
className={cn(
"flex min-w-0 flex-col justify-between gap-3 border-b py-4 last:border-b-0 lg:border-b-0",
PANEL_INSET_X,
)}
>
<div className="min-w-0">{children}</div>
{trend}
</div>
);
}

View file

@ -5,7 +5,7 @@ import { flattenNavItems, matchNavItems, paletteScopeForRoute, shortcutLabel, ty
describe("paletteScopeForRoute", () => {
it.each([
["api-keys", "keys"],
["", "keys"],
["", "global"],
["teams", "global"],
] as const)("uses the expected palette scope for %s", (routeSegment, expected) => {
expect(paletteScopeForRoute(routeSegment)).toBe(expected);

View file

@ -12,7 +12,7 @@ export interface PaletteNavItem {
}
export const paletteScopeForRoute = (routeSegment: string): PaletteScope =>
routeSegment === "" || routeSegment === "api-keys" ? "keys" : "global";
routeSegment === "api-keys" ? "keys" : "global";
const matchRank = (item: PaletteNavItem, query: string): number => {
const label = item.label.toLowerCase();

View file

@ -59,11 +59,11 @@ describe("DashboardHeader breadcrumb", () => {
expect(screen.getByText("Models + Endpoints")).toBeInTheDocument();
});
it("titles the dashboard root as Virtual Keys", () => {
it("titles the dashboard root as Home", () => {
state.pathname = "/ui/";
renderDashboardHeader();
expect(screen.getByText("Virtual Keys")).toBeInTheDocument();
expect(screen.getByText("Home")).toBeInTheDocument();
});
it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => {

View file

@ -247,6 +247,7 @@ describe("Sidebar (leftnav)", () => {
renderWithProviders(<Sidebar {...defaultProps} />);
const topLevelLabels = [
"Home",
"Virtual Keys",
"Playground",
"Models + Endpoints",
@ -660,10 +661,10 @@ describe("Sidebar (leftnav)", () => {
expect(screen.getByRole("link", { name: "Virtual Keys" })).not.toHaveAttribute("data-active");
});
it("marks Virtual Keys active at the dashboard root", () => {
it("marks Home active at the dashboard root", () => {
navState.pathname = "/ui/";
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("data-active", "true");
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("data-active", "true");
});
it("expands the parent group of the current nested route and marks the child active", () => {
@ -745,8 +746,8 @@ describe("getBreadcrumb", () => {
expect(getBreadcrumb("/ui/old-usage")).toEqual({ section: "Developer Tools", title: "Old Usage" });
});
it("titles the dashboard root as Virtual Keys", () => {
expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Virtual Keys" });
it("titles the dashboard root as Home", () => {
expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Home" });
});
it("resolves a nested child route to its parent section", () => {

View file

@ -40,6 +40,7 @@ import {
FlaskConical,
Folder,
HeartPulse,
House,
KeyRound,
LayoutGrid,
Network,
@ -207,6 +208,7 @@ const menuGroups: MenuGroup[] = [
{
groupLabel: "AI GATEWAY",
items: [
{ key: "home", page: "home", label: "Home", icon: <House {...ICON} /> },
{ key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: <KeyRound {...ICON} /> },
{
key: "llm-playground",
@ -468,7 +470,7 @@ const menuGroups: MenuGroup[] = [
},
];
const HOME_ROUTE = "api-keys";
const HOME_ROUTE = "home";
export const routeOf = (item: MenuItem): string => item.route ?? item.page;

View file

@ -5,6 +5,7 @@
// Page descriptions for UI Settings configuration
export const pageDescriptions: Record<string, string> = {
home: "Usage at a glance, recent launches and blog updates",
"api-keys": "Manage virtual keys for API access and authentication",
"llm-playground": "Interactive playground for testing LLM requests",
models: "Configure and manage LLM models and endpoints",

View file

@ -105,7 +105,7 @@ vi.mock("@/utils/returnUrlUtils", async (importOriginal) => {
// Super-light stubs for all heavy components so rendering doesn't explode
vi.mock("@/components/navbar", () => ({ default: stub("navbar") }));
vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ default: stub("api-keys-dashboard") }));
vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({ default: stub("home-page") }));
vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") }));
vi.mock("@/components/teams", () => ({ default: stub("teams") }));
vi.mock("@/app/(dashboard)/organizations/_components/organizations", () => ({
@ -271,7 +271,7 @@ describe("CreateKeyPage auth behavior", () => {
// And the default page content appears (ApiKeysDashboard stub; chrome now lives in the layout)
await waitFor(() => {
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
expect(screen.getByTestId("home-page")).toBeInTheDocument();
});
});