Expand billing panel with cost breakdown and budget utilization

BillingPanel now shows: stat tiles (allocation, cap, requests, failures),
token usage summary (input/output chars), budget utilization progress
bar, per-agent cost breakdown table (budget, requests, I/O chars), and
per-provider activity table. Replaced the minimal single-table view.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-07 18:11:00 -04:00
1 parent 55842d12a1
commit 157a677a31
2 files changed
+99 -28

No files matched your search

@@ -133,7 +133,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
roleGrants={roleGrants}
/>
)}
{tab === "billing" && <BillingPanel agents={agents} providers={providers} providerRequests={providerRequests} />}
{tab === "billing" && <BillingPanel agents={agents} providers={providers} providerRequests={providerRequests} hiringPolicies={hiringPolicies} />}
{tab === "models" && <ModelsPanel providers={providers} agents={agents} />}
{tab === "providers" && (
<ProvidersPanel
@@ -1,52 +1,92 @@
import { Pill } from "../../shared/ui";
import type { AgentRecord, ProviderRecord, ProviderRequestRecord } from "../../../../shared/domain";
import { money } from "../types";
import { useMemo } from "react";
import { Avatar, Pill } from "../../shared/ui";
import type { AgentRecord, HiringPolicyRecord, ProviderRecord, ProviderRequestRecord } from "../../../../shared/domain";
import { money, formatRelative } from "../types";
export function BillingPanel({
agents,
providers,
providerRequests,
hiringPolicies,
}: {
agents: AgentRecord[];
providers: ProviderRecord[];
providerRequests: ProviderRequestRecord[];
hiringPolicies: HiringPolicyRecord[];
}) {
const totalBudget = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
const monthRequests = providerRequests.filter((request) => withinDays(request.createdAt, 30));
const totalBudget = agents.reduce((sum, a) => sum + a.budgetCents, 0);
const totalCap = hiringPolicies.reduce((sum, p) => sum + p.monthlyBudgetLimitCents, 0);
const monthRequests = providerRequests.filter((r) => withinDays(r.createdAt, 30));
const totalInputChars = monthRequests.reduce((s, r) => s + r.inputCharCount, 0);
const totalOutputChars = monthRequests.reduce((s, r) => s + r.outputCharCount, 0);
const failedRequests = monthRequests.filter((r) => r.state === "failed");
const agentSpend = useMemo(() => buildAgentSpend(agents, providers, monthRequests), [agents, providers, monthRequests]);
return (
<div className="page">
<h1 className="page-h1">Billing</h1>
<p className="page-sub">Allocations, provider activity, and budget windows.</p>
<div className="card" style={{ padding: 18, marginBottom: 16 }}>
<div className="section-label" style={{ marginBottom: 8 }}>Monthly allocation</div>
<div className="num" style={{ fontSize: 28, fontWeight: 600 }}>{money(totalBudget)}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>across {agents.length} {agents.length === 1 ? "agent" : "agents"}</div>
<p className="page-sub">Allocations, spend, and cost breakdown.</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, marginBottom: 20 }}>
<StatTile label="Monthly allocation" value={money(totalBudget)} />
<StatTile label="Budget cap" value={totalCap > 0 ? money(totalCap) : "—"} />
<StatTile label="Requests (30d)" value={String(monthRequests.length)} />
<StatTile label="Failures (30d)" value={String(failedRequests.length)} tone={failedRequests.length > 0 ? "red" : undefined} />
</div>
<div className="section-label" style={{ marginBottom: 10 }}>Activity (30 days)</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 20 }}>
<div className="card" style={{ padding: 14 }}>
<div className="card-label" style={{ marginBottom: 8 }}>Token usage (30d)</div>
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "6px 14px", fontSize: 12.5 }}>
<span style={{ color: "var(--text-3)" }}>Input chars</span><span className="num">{totalInputChars.toLocaleString()}</span>
<span style={{ color: "var(--text-3)" }}>Output chars</span><span className="num">{totalOutputChars.toLocaleString()}</span>
<span style={{ color: "var(--text-3)" }}>Total</span><span className="num">{(totalInputChars + totalOutputChars).toLocaleString()}</span>
</div>
</div>
<div className="card" style={{ padding: 14 }}>
<div className="card-label" style={{ marginBottom: 8 }}>Budget utilization</div>
<div style={{ height: 6, background: "var(--panel-3)", borderRadius: 3, overflow: "hidden", marginBottom: 8 }}>
<div style={{ width: `${totalCap > 0 ? Math.min(100, Math.round((totalBudget / totalCap) * 100)) : 0}%`, height: "100%", background: "var(--accent)" }} />
</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>
{totalCap > 0 ? `${Math.round((totalBudget / totalCap) * 100)}% of cap` : "No budget cap set"}
</div>
</div>
</div>
<div className="section-label" style={{ marginBottom: 10 }}>Cost by agent</div>
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 20 }}>
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}>
<div>Agent</div><div>Budget</div><div>Requests</div><div>Input</div><div>Output</div>
</div>
{agentSpend.map((row, i) => (
<div key={row.agent.id} className="table-row" style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: i === agentSpend.length - 1 ? "none" : "1px solid var(--border-soft)" }}>
<div className="row" style={{ gap: 8 }}>
<Avatar name={row.agent.name} size="sm" bot />
<span style={{ fontWeight: 480 }}>{row.agent.name}</span>
</div>
<div className="num mono">{money(row.agent.budgetCents)}</div>
<div className="num mono">{row.requestCount}</div>
<div className="num mono" style={{ fontSize: 11 }}>{row.inputChars.toLocaleString()}</div>
<div className="num mono" style={{ fontSize: 11 }}>{row.outputChars.toLocaleString()}</div>
</div>
))}
</div>
<div className="section-label" style={{ marginBottom: 10 }}>Provider activity (30d)</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}
>
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}>
<div>Provider</div><div>Requests</div><div>Errors</div><div>Agents</div>
</div>
{providers.map((provider, index) => {
const requests = monthRequests.filter((request) => request.providerId === provider.id);
const errors = requests.filter((request) => request.state === "failed").length;
const boundAgents = agents.filter((agent) => agent.modelProviderId === provider.id).length;
const last = index === providers.length - 1;
const requests = monthRequests.filter((r) => r.providerId === provider.id);
const errors = requests.filter((r) => r.state === "failed").length;
const boundAgents = agents.filter((a) => a.modelProviderId === provider.id).length;
return (
<div
key={provider.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div key={provider.id} className="table-row" style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: index === providers.length - 1 ? "none" : "1px solid var(--border-soft)" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540 }}>{provider.label}</div>
{provider.model && (
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{provider.model}</div>
)}
{provider.model && <div style={{ fontSize: 11, color: "var(--text-3)" }}>{provider.model}</div>}
</div>
<div className="num mono">{requests.length}</div>
<div>{errors > 0 ? <Pill tone="amber">{errors}</Pill> : <span className="num mono" style={{ color: "var(--text-3)" }}>0</span>}</div>
@@ -59,6 +99,37 @@ export function BillingPanel({
);
}
function StatTile({ label, value, tone }: { label: string; value: string; tone?: "red" }) {
return (
<div className="card" style={{ padding: 14 }}>
<div className="card-label" style={{ marginBottom: 6 }}>{label}</div>
<div className="num" style={{ fontSize: 22, fontWeight: 600, color: tone === "red" ? "var(--red)" : "var(--text-1)" }}>{value}</div>
</div>
);
}
interface AgentSpendRow {
agent: AgentRecord;
requestCount: number;
inputChars: number;
outputChars: number;
}
function buildAgentSpend(agents: AgentRecord[], providers: ProviderRecord[], requests: ProviderRequestRecord[]): AgentSpendRow[] {
return agents.map((agent) => {
const agentRequests = requests.filter((r) => {
if (!agent.modelProviderId) return false;
return r.providerId === agent.modelProviderId;
});
return {
agent,
requestCount: agentRequests.length,
inputChars: agentRequests.reduce((s, r) => s + r.inputCharCount, 0),
outputChars: agentRequests.reduce((s, r) => s + r.outputCharCount, 0),
};
}).sort((a, b) => b.requestCount - a.requestCount);
}
function withinDays(iso: string, days: number): boolean {
return Date.now() - new Date(iso).getTime() < days * 24 * 60 * 60 * 1000;
}