Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ htmlcov/
refs/
docs/
tests/

# Explicit frontend excludes to keep Docker context small
frontend/node_modules/
**/node_modules/
9 changes: 7 additions & 2 deletions frontend/src/features/dashboard/components/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useDashboard } from "@/features/dashboard/hooks/use-dashboard";
import { useRequestLogs } from "@/features/dashboard/hooks/use-request-logs";
import { buildDashboardView } from "@/features/dashboard/utils";
import type { AccountSummary } from "@/features/dashboard/schemas";
import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences";
import { useThemeStore } from "@/hooks/use-theme";
import { REQUEST_STATUS_LABELS } from "@/utils/constants";
import { formatModelLabel, formatSlug } from "@/utils/formatters";
Expand All @@ -25,6 +26,7 @@ export function DashboardPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDark = useThemeStore((s) => s.theme === "dark");
const showAccountBurnrate = useDashboardPreferencesStore((s) => s.accountBurnrateEnabled);
const dashboardQuery = useDashboard();
const { filters, logsQuery, optionsQuery, updateFilters } = useRequestLogs();
const { resumeMutation } = useAccountMutations();
Expand Down Expand Up @@ -59,8 +61,11 @@ export function DashboardPage() {
if (!overview || !logPage) {
return null;
}
return buildDashboardView(overview, logPage.requests, isDark);
}, [overview, logPage, isDark]);
return buildDashboardView(overview, logPage.requests, {
isDark,
showAccountBurnrate,
});
}, [overview, logPage, isDark, showAccountBurnrate]);

const accountOptions = useMemo(() => {
const entries = new Map<string, { label: string; isEmail: boolean }>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { Activity, AlertTriangle, Coins, DollarSign } from "lucide-react";
import { Activity, AlertTriangle, Coins, DollarSign, Flame } from "lucide-react";
import { describe, expect, it } from "vitest";

import { StatsGrid } from "@/features/dashboard/components/stats-grid";
Expand All @@ -8,13 +9,14 @@ const EMPTY_TREND: { value: number }[] = [];
const SAMPLE_TREND = [{ value: 1 }, { value: 2 }, { value: 3 }];

describe("StatsGrid", () => {
it("renders four metric cards with values", () => {
it("renders five metric cards with values", () => {
render(
<StatsGrid
stats={[
{ label: "Requests (7d)", value: "228", icon: Activity, trend: SAMPLE_TREND, trendColor: "#3b82f6" },
{ label: "Tokens (7d)", value: "45K", icon: Coins, trend: SAMPLE_TREND, trendColor: "#8b5cf6" },
{ label: "Cost (7d)", value: "$1.82", meta: "Avg/hr $0.01", icon: DollarSign, trend: SAMPLE_TREND, trendColor: "#10b981" },
{ label: "Account burn rate (5h/7d)", value: "0.7 / 0.8", meta: "Primary 0.7 acc/5h · Secondary 0.8 acc/7d", icon: Flame, trend: SAMPLE_TREND, trendColor: "#ef4444" },
{ label: "Error rate", value: "2.8%", meta: "Top: rate_limit_exceeded", icon: AlertTriangle, trend: SAMPLE_TREND, trendColor: "#f59e0b" },
]}
/>,
Expand All @@ -26,6 +28,8 @@ describe("StatsGrid", () => {
expect(screen.getByText("45K")).toBeInTheDocument();
expect(screen.getByText("Cost (7d)")).toBeInTheDocument();
expect(screen.getByText("Avg/hr $0.01")).toBeInTheDocument();
expect(screen.getByText("Account burn rate (5h/7d)")).toBeInTheDocument();
expect(screen.getByText("Primary 0.7 acc/5h · Secondary 0.8 acc/7d")).toBeInTheDocument();
expect(screen.getByText("Error rate")).toBeInTheDocument();
expect(screen.getByText("Top: rate_limit_exceeded")).toBeInTheDocument();
});
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/features/dashboard/components/stats-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const ACCENT_STYLES = [
"bg-blue-500/10 text-blue-600 dark:bg-blue-500/15 dark:text-blue-400",
"bg-violet-500/10 text-violet-600 dark:bg-violet-500/15 dark:text-violet-400",
"bg-emerald-500/10 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400",
"bg-rose-500/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400",
"bg-amber-500/10 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400",
];

Expand All @@ -14,8 +15,10 @@ export type StatsGridProps = {
};

export function StatsGrid({ stats }: StatsGridProps) {
const columnsClass = stats.length >= 5 ? "xl:grid-cols-5" : "xl:grid-cols-4";

return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className={cn("grid gap-3 sm:grid-cols-2", columnsClass)}>
{stats.map((stat, index) => {
const Icon = stat.icon;
const accent = ACCENT_STYLES[index % ACCENT_STYLES.length];
Expand Down
145 changes: 145 additions & 0 deletions frontend/src/features/dashboard/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";

import {
buildDashboardView,
buildDepletionView,
buildRemainingItems,
} from "@/features/dashboard/utils";
import type { AccountSummary, Depletion } from "@/features/dashboard/schemas";
import { createAccountSummary, createDashboardOverview, createDefaultRequestLogs } from "@/test/mocks/factories";
import { formatCompactAccountId } from "@/utils/account-identifiers";

function account(overrides: Partial<AccountSummary> & Pick<AccountSummary, "accountId" | "email">): AccountSummary {
Expand Down Expand Up @@ -123,3 +125,146 @@ describe("buildRemainingItems", () => {
expect(items[2].isEmail).toBe(true);
});
});

describe("buildDashboardView", () => {
it("adds plus-burn stat between cost and error rate", () => {
const overview = createDashboardOverview();
const logs = createDefaultRequestLogs();

const view = buildDashboardView(overview, logs);

expect(view.stats[2].label).toBe("Cost (7d)");
expect(view.stats[3].label).toBe("Account burn rate (5h/7d)");
expect(view.stats[3].value).toBe("0.7 / 1.2");
expect(view.stats[3].meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d");
expect(view.stats[3].trend.length).toBeGreaterThan(0);
expect(view.stats[4].label).toBe("Error rate");
});

it("falls back to usage equivalents when depletion data is missing", () => {
const overview = createDashboardOverview({
depletionPrimary: null,
depletionSecondary: null,
});

const view = buildDashboardView(overview, createDefaultRequestLogs());
const burn = view.stats[3];

expect(burn.label).toBe("Account burn rate (5h/7d)");
expect(burn.value).toBe("0.7 / 1.2");
expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d");
expect(burn.trend.length).toBeGreaterThan(0);
});

it("projects burn rate to full 7d window when reset is still in the future", () => {
const now = Date.now();
const overview = createDashboardOverview({
accounts: [
createAccountSummary({
accountId: "acc-idle",
email: "idle@example.com",
usage: {
primaryRemainingPercent: 100,
secondaryRemainingPercent: 100,
},
resetAtSecondary: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(),
windowMinutesSecondary: 10_080,
}),
createAccountSummary({
accountId: "acc-hot",
email: "hot@example.com",
usage: {
primaryRemainingPercent: 100,
secondaryRemainingPercent: 16,
},
resetAtSecondary: new Date(now + 74 * 60 * 60 * 1000).toISOString(),
windowMinutesSecondary: 10_080,
}),
],
depletionPrimary: null,
depletionSecondary: null,
});

const view = buildDashboardView(overview, createDefaultRequestLogs());
const burn = view.stats[3];

expect(burn.value).toBe("0.0 / 1.5");
expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 1.5 acc/7d");
});

it("counts quota_exceeded secondary accounts as fully burned and caps to account count", () => {
const now = Date.now();
const overview = createDashboardOverview({
accounts: [
createAccountSummary({
accountId: "acc-quota",
email: "quota@example.com",
status: "quota_exceeded",
usage: {
primaryRemainingPercent: 100,
secondaryRemainingPercent: 100,
},
resetAtSecondary: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(),
windowMinutesSecondary: 10_080,
}),
createAccountSummary({
accountId: "acc-hot",
email: "hot@example.com",
status: "active",
usage: {
primaryRemainingPercent: 100,
secondaryRemainingPercent: 16,
},
resetAtSecondary: new Date(now + 74 * 60 * 60 * 1000).toISOString(),
windowMinutesSecondary: 10_080,
}),
],
depletionPrimary: null,
depletionSecondary: null,
});

const view = buildDashboardView(overview, createDefaultRequestLogs());
const burn = view.stats[3];

expect(burn.value).toBe("0.0 / 2.0");
expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 2.0 acc/7d");
});

it("uses usage-equivalent fallback when burn rate is zero", () => {
const overview = createDashboardOverview({
depletionSecondary: {
risk: 1,
riskLevel: "critical",
burnRate: 0,
safeUsagePercent: 98,
projectedExhaustionAt: null,
secondsUntilExhaustion: null,
},
});

const view = buildDashboardView(overview, createDefaultRequestLogs());
const burn = view.stats[3];

expect(burn.value).toBe("0.7 / 1.2");
expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d");
});

it("caps burn-equivalent to available account count per window", () => {
const overview = createDashboardOverview({
depletionSecondary: {
risk: 1,
riskLevel: "critical",
burnRate: 999,
safeUsagePercent: 98,
projectedExhaustionAt: null,
secondsUntilExhaustion: null,
},
});

const view = buildDashboardView(overview, createDefaultRequestLogs());
const burn = view.stats[3];

expect(burn.value).toBe("0.7 / 2.0");
expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 2.0 acc/7d");
});
});
Loading
Loading