feat(ui): add resource and usage dashboard (/usage route)
adds a new /usage page that lets board operators see how much each ai provider is consuming across any date window, with per-model breakdowns, rolling 5h/24h/7d burn windows, weekly budget bars, and a deficit notch when projected spend is on track to exceed the monthly budget. - new GET /companies/:id/costs/by-provider endpoint aggregates cost events by provider + model with pro-rated billing type splits from heartbeat runs - new GET /companies/:id/costs/window-spend endpoint returns rolling window spend (5h, 24h, 7d) per provider with no schema changes - QuotaBar: reusable boxed-border progress bar with green/yellow/red threshold fill colors and optional deficit notch - ProviderQuotaCard: per-provider card showing budget allocation bars, rolling windows, subscription usage, and model breakdown with token/cost share overlays - Usage page: date preset toggles (mtd, 7d, 30d, ytd, all, custom), provider tabs, 30s polling plus ws invalidation on cost_event - custom date range blocks queries until both dates are selected and treats boundaries as local-time (not utc midnight) so full days are included regardless of timezone - query key to timestamp is floored to the nearest minute to prevent cache churn on every 30s refetch tick
This commit is contained in:
@@ -132,6 +132,8 @@ export type {
|
|||||||
CostEvent,
|
CostEvent,
|
||||||
CostSummary,
|
CostSummary,
|
||||||
CostByAgent,
|
CostByAgent,
|
||||||
|
CostByProviderModel,
|
||||||
|
CostWindowSpendRow,
|
||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
HeartbeatRunEvent,
|
HeartbeatRunEvent,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
|
|||||||
@@ -34,3 +34,27 @@ export interface CostByAgent {
|
|||||||
subscriptionInputTokens: number;
|
subscriptionInputTokens: number;
|
||||||
subscriptionOutputTokens: number;
|
subscriptionOutputTokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CostByProviderModel {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
costCents: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
apiRunCount: number;
|
||||||
|
subscriptionRunCount: number;
|
||||||
|
subscriptionInputTokens: number;
|
||||||
|
subscriptionOutputTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** spend per provider for a fixed rolling time window */
|
||||||
|
export interface CostWindowSpendRow {
|
||||||
|
provider: string;
|
||||||
|
/** duration label, e.g. "5h", "24h", "7d" */
|
||||||
|
window: string;
|
||||||
|
/** rolling window duration in hours */
|
||||||
|
windowHours: number;
|
||||||
|
costCents: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export type {
|
|||||||
CompanySecret,
|
CompanySecret,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
} from "./secrets.js";
|
} from "./secrets.js";
|
||||||
export type { CostEvent, CostSummary, CostByAgent } from "./cost.js";
|
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow } from "./cost.js";
|
||||||
export type {
|
export type {
|
||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
HeartbeatRunEvent,
|
HeartbeatRunEvent,
|
||||||
|
|||||||
@@ -62,6 +62,21 @@ export function costRoutes(db: Db) {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
const range = parseDateRange(req.query);
|
||||||
|
const rows = await costs.byProvider(companyId, range);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/companies/:companyId/costs/window-spend", async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
const rows = await costs.windowSpend(companyId);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|||||||
@@ -153,6 +153,121 @@ export function costService(db: Db) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
byProvider: async (companyId: string, range?: CostDateRange) => {
|
||||||
|
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||||
|
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||||
|
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||||
|
|
||||||
|
const costRows = await db
|
||||||
|
.select({
|
||||||
|
provider: costEvents.provider,
|
||||||
|
model: costEvents.model,
|
||||||
|
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||||
|
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||||
|
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||||
|
})
|
||||||
|
.from(costEvents)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.groupBy(costEvents.provider, costEvents.model)
|
||||||
|
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||||
|
|
||||||
|
const runConditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
||||||
|
if (range?.from) runConditions.push(gte(heartbeatRuns.finishedAt, range.from));
|
||||||
|
if (range?.to) runConditions.push(lte(heartbeatRuns.finishedAt, range.to));
|
||||||
|
|
||||||
|
const runRows = await db
|
||||||
|
.select({
|
||||||
|
agentId: heartbeatRuns.agentId,
|
||||||
|
apiRunCount:
|
||||||
|
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`,
|
||||||
|
subscriptionRunCount:
|
||||||
|
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`,
|
||||||
|
subscriptionInputTokens:
|
||||||
|
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`,
|
||||||
|
subscriptionOutputTokens:
|
||||||
|
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0) else 0 end), 0)::int`,
|
||||||
|
})
|
||||||
|
.from(heartbeatRuns)
|
||||||
|
.where(and(...runConditions))
|
||||||
|
.groupBy(heartbeatRuns.agentId);
|
||||||
|
|
||||||
|
// aggregate run billing splits across all agents (runs don't carry model info so we can't go per-model)
|
||||||
|
const totals = runRows.reduce(
|
||||||
|
(acc, r) => ({
|
||||||
|
apiRunCount: acc.apiRunCount + r.apiRunCount,
|
||||||
|
subscriptionRunCount: acc.subscriptionRunCount + r.subscriptionRunCount,
|
||||||
|
subscriptionInputTokens: acc.subscriptionInputTokens + r.subscriptionInputTokens,
|
||||||
|
subscriptionOutputTokens: acc.subscriptionOutputTokens + r.subscriptionOutputTokens,
|
||||||
|
}),
|
||||||
|
{ apiRunCount: 0, subscriptionRunCount: 0, subscriptionInputTokens: 0, subscriptionOutputTokens: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// pro-rate billing split across models by token share
|
||||||
|
const totalTokens = costRows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
||||||
|
|
||||||
|
return costRows.map((row) => {
|
||||||
|
const rowTokens = row.inputTokens + row.outputTokens;
|
||||||
|
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
|
||||||
|
return {
|
||||||
|
provider: row.provider,
|
||||||
|
model: row.model,
|
||||||
|
costCents: row.costCents,
|
||||||
|
inputTokens: row.inputTokens,
|
||||||
|
outputTokens: row.outputTokens,
|
||||||
|
apiRunCount: Math.round(totals.apiRunCount * share),
|
||||||
|
subscriptionRunCount: Math.round(totals.subscriptionRunCount * share),
|
||||||
|
subscriptionInputTokens: Math.round(totals.subscriptionInputTokens * share),
|
||||||
|
subscriptionOutputTokens: Math.round(totals.subscriptionOutputTokens * share),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* aggregates cost_events by provider for each of three rolling windows:
|
||||||
|
* last 5 hours, last 24 hours, last 7 days.
|
||||||
|
* purely internal consumption data, no external rate-limit sources.
|
||||||
|
*/
|
||||||
|
windowSpend: async (companyId: string) => {
|
||||||
|
const windows = [
|
||||||
|
{ label: "5h", hours: 5 },
|
||||||
|
{ label: "24h", hours: 24 },
|
||||||
|
{ label: "7d", hours: 168 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
windows.map(async ({ label, hours }) => {
|
||||||
|
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
provider: costEvents.provider,
|
||||||
|
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||||
|
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||||
|
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||||
|
})
|
||||||
|
.from(costEvents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(costEvents.companyId, companyId),
|
||||||
|
gte(costEvents.occurredAt, since),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(costEvents.provider)
|
||||||
|
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
provider: row.provider,
|
||||||
|
window: label as string,
|
||||||
|
windowHours: hours,
|
||||||
|
costCents: row.costCents,
|
||||||
|
inputTokens: row.inputTokens,
|
||||||
|
outputTokens: row.outputTokens,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.flat();
|
||||||
|
},
|
||||||
|
|
||||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||||
const runProjectLinks = db
|
const runProjectLinks = db
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { GoalDetail } from "./pages/GoalDetail";
|
|||||||
import { Approvals } from "./pages/Approvals";
|
import { Approvals } from "./pages/Approvals";
|
||||||
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||||
import { Costs } from "./pages/Costs";
|
import { Costs } from "./pages/Costs";
|
||||||
|
import { Usage } from "./pages/Usage";
|
||||||
import { Activity } from "./pages/Activity";
|
import { Activity } from "./pages/Activity";
|
||||||
import { Inbox } from "./pages/Inbox";
|
import { Inbox } from "./pages/Inbox";
|
||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
@@ -147,6 +148,7 @@ function boardRoutes() {
|
|||||||
<Route path="approvals/all" element={<Approvals />} />
|
<Route path="approvals/all" element={<Approvals />} />
|
||||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||||
<Route path="costs" element={<Costs />} />
|
<Route path="costs" element={<Costs />} />
|
||||||
|
<Route path="usage" element={<Usage />} />
|
||||||
<Route path="activity" element={<Activity />} />
|
<Route path="activity" element={<Activity />} />
|
||||||
<Route path="inbox" element={<InboxRootRedirect />} />
|
<Route path="inbox" element={<InboxRootRedirect />} />
|
||||||
<Route path="inbox/recent" element={<Inbox />} />
|
<Route path="inbox/recent" element={<Inbox />} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CostSummary, CostByAgent } from "@paperclipai/shared";
|
import type { CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export interface CostByProject {
|
export interface CostByProject {
|
||||||
@@ -24,4 +24,8 @@ export const costsApi = {
|
|||||||
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
|
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
|
||||||
byProject: (companyId: string, from?: string, to?: string) =>
|
byProject: (companyId: string, from?: string, to?: string) =>
|
||||||
api.get<CostByProject[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
api.get<CostByProject[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
||||||
|
byProvider: (companyId: string, from?: string, to?: string) =>
|
||||||
|
api.get<CostByProviderModel[]>(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
|
||||||
|
windowSpend: (companyId: string) =>
|
||||||
|
api.get<CostWindowSpendRow[]>(`/companies/${companyId}/costs/window-spend`),
|
||||||
};
|
};
|
||||||
|
|||||||
238
ui/src/components/ProviderQuotaCard.tsx
Normal file
238
ui/src/components/ProviderQuotaCard.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import type { CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { QuotaBar } from "./QuotaBar";
|
||||||
|
import { formatCents, formatTokens } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ProviderQuotaCardProps {
|
||||||
|
provider: string;
|
||||||
|
rows: CostByProviderModel[];
|
||||||
|
/** company monthly budget in cents (0 means unlimited) */
|
||||||
|
budgetMonthlyCents: number;
|
||||||
|
/** total company spend in this period in cents, all providers */
|
||||||
|
totalCompanySpendCents: number;
|
||||||
|
/** spend in the current calendar week in cents, this provider only */
|
||||||
|
weekSpendCents: number;
|
||||||
|
/** rolling window rows for this provider: 5h, 24h, 7d */
|
||||||
|
windowRows: CostWindowSpendRow[];
|
||||||
|
showDeficitNotch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(provider: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
openai: "OpenAI",
|
||||||
|
google: "Google",
|
||||||
|
cursor: "Cursor",
|
||||||
|
jetbrains: "JetBrains AI",
|
||||||
|
};
|
||||||
|
return map[provider.toLowerCase()] ?? provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderQuotaCard({
|
||||||
|
provider,
|
||||||
|
rows,
|
||||||
|
budgetMonthlyCents,
|
||||||
|
totalCompanySpendCents,
|
||||||
|
weekSpendCents,
|
||||||
|
windowRows,
|
||||||
|
showDeficitNotch,
|
||||||
|
}: ProviderQuotaCardProps) {
|
||||||
|
const totalInputTokens = rows.reduce((s, r) => s + r.inputTokens, 0);
|
||||||
|
const totalOutputTokens = rows.reduce((s, r) => s + r.outputTokens, 0);
|
||||||
|
const totalTokens = totalInputTokens + totalOutputTokens;
|
||||||
|
const totalCostCents = rows.reduce((s, r) => s + r.costCents, 0);
|
||||||
|
const totalApiRuns = rows.reduce((s, r) => s + r.apiRunCount, 0);
|
||||||
|
const totalSubRuns = rows.reduce((s, r) => s + r.subscriptionRunCount, 0);
|
||||||
|
const totalSubInputTokens = rows.reduce((s, r) => s + r.subscriptionInputTokens, 0);
|
||||||
|
const totalSubOutputTokens = rows.reduce((s, r) => s + r.subscriptionOutputTokens, 0);
|
||||||
|
const totalSubTokens = totalSubInputTokens + totalSubOutputTokens;
|
||||||
|
|
||||||
|
// sub share = sub tokens / (api tokens + sub tokens)
|
||||||
|
const allTokens = totalTokens + totalSubTokens;
|
||||||
|
const subSharePct = allTokens > 0 ? (totalSubTokens / allTokens) * 100 : 0;
|
||||||
|
|
||||||
|
// budget bars: use this provider's own spend vs its pro-rata share of budget
|
||||||
|
// pro-rata: if a provider is 40% of total spend, it gets 40% of the budget allocated.
|
||||||
|
// falls back to raw provider spend vs total budget when totalCompanySpend is 0.
|
||||||
|
const providerBudgetShare =
|
||||||
|
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
|
||||||
|
? (totalCostCents / totalCompanySpendCents) * budgetMonthlyCents
|
||||||
|
: budgetMonthlyCents;
|
||||||
|
|
||||||
|
const budgetPct =
|
||||||
|
providerBudgetShare > 0
|
||||||
|
? Math.min(100, (totalCostCents / providerBudgetShare) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const weeklyBudgetShare = providerBudgetShare > 0 ? providerBudgetShare / 4.33 : 0;
|
||||||
|
const weekPct =
|
||||||
|
weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0;
|
||||||
|
|
||||||
|
const hasBudget = budgetMonthlyCents > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-4 pb-0 gap-1">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="text-sm font-semibold">
|
||||||
|
{providerLabel(provider)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs mt-0.5">
|
||||||
|
<span className="font-mono">{formatTokens(totalInputTokens)}</span> in
|
||||||
|
{" · "}
|
||||||
|
<span className="font-mono">{formatTokens(totalOutputTokens)}</span> out
|
||||||
|
{(totalApiRuns > 0 || totalSubRuns > 0) && (
|
||||||
|
<span className="ml-1.5">
|
||||||
|
·{" "}
|
||||||
|
{totalApiRuns > 0 && `~${totalApiRuns} api`}
|
||||||
|
{totalApiRuns > 0 && totalSubRuns > 0 && " / "}
|
||||||
|
{totalSubRuns > 0 && `~${totalSubRuns} sub`}
|
||||||
|
{" runs"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tabular-nums shrink-0">
|
||||||
|
{formatCents(totalCostCents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-4 pb-4 pt-3 space-y-4">
|
||||||
|
{hasBudget && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<QuotaBar
|
||||||
|
label="Period spend"
|
||||||
|
percentUsed={budgetPct}
|
||||||
|
leftLabel={formatCents(totalCostCents)}
|
||||||
|
rightLabel={`${Math.round(budgetPct)}% of allocation`}
|
||||||
|
showDeficitNotch={showDeficitNotch}
|
||||||
|
/>
|
||||||
|
<QuotaBar
|
||||||
|
label="This week"
|
||||||
|
percentUsed={weekPct}
|
||||||
|
leftLabel={formatCents(weekSpendCents)}
|
||||||
|
rightLabel={`~${formatCents(Math.round(weeklyBudgetShare))} / wk`}
|
||||||
|
showDeficitNotch={weekPct >= 100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* rolling window consumption — always shown when data is available */}
|
||||||
|
{windowRows.length > 0 && (() => {
|
||||||
|
const WINDOWS = ["5h", "24h", "7d"] as const;
|
||||||
|
const windowMap = new Map(windowRows.map((r) => [r.window, r]));
|
||||||
|
const maxCents = Math.max(...windowRows.map((r) => r.costCents), 1);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Rolling windows
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{WINDOWS.map((w) => {
|
||||||
|
const row = windowMap.get(w);
|
||||||
|
const cents = row?.costCents ?? 0;
|
||||||
|
const tokens = (row?.inputTokens ?? 0) + (row?.outputTokens ?? 0);
|
||||||
|
const barPct = maxCents > 0 ? (cents / maxCents) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={w} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="font-mono text-muted-foreground w-6 shrink-0">{w}</span>
|
||||||
|
<span className="text-muted-foreground font-mono flex-1">
|
||||||
|
{formatTokens(tokens)} tok
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{formatCents(cents)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full border border-border overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary/60 transition-[width] duration-150"
|
||||||
|
style={{ width: `${barPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* subscription usage — shown when any subscription-billed runs exist */}
|
||||||
|
{totalSubRuns > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Subscription
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono text-foreground">{totalSubRuns}</span> runs
|
||||||
|
{" · "}
|
||||||
|
<span className="font-mono text-foreground">{formatTokens(totalSubInputTokens)}</span> in
|
||||||
|
{" · "}
|
||||||
|
<span className="font-mono text-foreground">{formatTokens(totalSubOutputTokens)}</span> out
|
||||||
|
</p>
|
||||||
|
<div className="h-1.5 w-full border border-border overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary/60 transition-[width] duration-150"
|
||||||
|
style={{ width: `${subSharePct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{Math.round(subSharePct)}% of token usage via subscription
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* model breakdown — always shown, with token-share bars */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const rowTokens = row.inputTokens + row.outputTokens;
|
||||||
|
const tokenPct = totalTokens > 0 ? (rowTokens / totalTokens) * 100 : 0;
|
||||||
|
const costPct = totalCostCents > 0 ? (row.costCents / totalCostCents) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={`${row.provider}:${row.model}`} className="space-y-1.5">
|
||||||
|
{/* model name and cost */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground truncate font-mono">
|
||||||
|
{row.model}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 tabular-nums text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatTokens(rowTokens)} tok
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{formatCents(row.costCents)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* token share bar */}
|
||||||
|
<div className="relative h-1.5 w-full border border-border overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-primary/60 transition-[width] duration-150"
|
||||||
|
style={{ width: `${tokenPct}%` }}
|
||||||
|
title={`${Math.round(tokenPct)}% of provider tokens`}
|
||||||
|
/>
|
||||||
|
{/* cost share overlay — narrower, opaque, shows relative cost weight */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-primary transition-[width] duration-150"
|
||||||
|
style={{ width: `${costPct}%`, opacity: 0.85 }}
|
||||||
|
title={`${Math.round(costPct)}% of provider cost`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
ui/src/components/QuotaBar.tsx
Normal file
65
ui/src/components/QuotaBar.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface QuotaBarProps {
|
||||||
|
label: string;
|
||||||
|
// value between 0 and 100
|
||||||
|
percentUsed: number;
|
||||||
|
leftLabel: string;
|
||||||
|
rightLabel?: string;
|
||||||
|
// shows a 2px destructive notch at the fill tip when true
|
||||||
|
showDeficitNotch?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillColor(pct: number): string {
|
||||||
|
if (pct > 90) return "bg-red-400";
|
||||||
|
if (pct > 70) return "bg-yellow-400";
|
||||||
|
return "bg-green-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaBar({
|
||||||
|
label,
|
||||||
|
percentUsed,
|
||||||
|
leftLabel,
|
||||||
|
rightLabel,
|
||||||
|
showDeficitNotch = false,
|
||||||
|
className,
|
||||||
|
}: QuotaBarProps) {
|
||||||
|
const clampedPct = Math.min(100, Math.max(0, percentUsed));
|
||||||
|
// keep the notch visible even near the edges
|
||||||
|
const notchLeft = Math.min(clampedPct, 97);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-1.5", className)}>
|
||||||
|
{/* row header */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-xs font-medium tabular-nums">{leftLabel}</span>
|
||||||
|
{rightLabel && (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{rightLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* track — boxed border, square corners to match the theme */}
|
||||||
|
<div className="relative h-2 w-full border border-border overflow-hidden">
|
||||||
|
{/* fill */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 left-0 transition-[width,background-color] duration-150",
|
||||||
|
fillColor(clampedPct),
|
||||||
|
)}
|
||||||
|
style={{ width: `${clampedPct}%` }}
|
||||||
|
/>
|
||||||
|
{/* deficit notch — 2px wide, sits at the fill tip */}
|
||||||
|
{showDeficitNotch && clampedPct > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 w-[2px] bg-destructive z-10"
|
||||||
|
style={{ left: `${notchLeft}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
Gauge,
|
||||||
History,
|
History,
|
||||||
Search,
|
Search,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
@@ -107,6 +108,7 @@ export function Sidebar() {
|
|||||||
<SidebarSection label="Company">
|
<SidebarSection label="Company">
|
||||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||||
|
<SidebarNavItem to="/usage" label="Usage" icon={Gauge} />
|
||||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|||||||
@@ -413,6 +413,8 @@ function invalidateActivityQueries(
|
|||||||
|
|
||||||
if (entityType === "cost_event") {
|
if (entityType === "cost_event") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||||||
"goals",
|
"goals",
|
||||||
"approvals",
|
"approvals",
|
||||||
"costs",
|
"costs",
|
||||||
|
"usage",
|
||||||
"activity",
|
"activity",
|
||||||
"inbox",
|
"inbox",
|
||||||
"design-guide",
|
"design-guide",
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ export const queryKeys = {
|
|||||||
activity: (companyId: string) => ["activity", companyId] as const,
|
activity: (companyId: string) => ["activity", companyId] as const,
|
||||||
costs: (companyId: string, from?: string, to?: string) =>
|
costs: (companyId: string, from?: string, to?: string) =>
|
||||||
["costs", companyId, from, to] as const,
|
["costs", companyId, from, to] as const,
|
||||||
|
usageByProvider: (companyId: string, from?: string, to?: string) =>
|
||||||
|
["usage-by-provider", companyId, from, to] as const,
|
||||||
|
usageWindowSpend: (companyId: string) =>
|
||||||
|
["usage-window-spend", companyId] as const,
|
||||||
heartbeats: (companyId: string, agentId?: string) =>
|
heartbeats: (companyId: string, agentId?: string) =>
|
||||||
["heartbeats", companyId, agentId] as const,
|
["heartbeats", companyId, agentId] as const,
|
||||||
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
|
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ export function Costs() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{data.summary.budgetCents > 0 && (
|
{data.summary.budgetCents > 0 && (
|
||||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
<div className="w-full h-2 border border-border overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-[width,background-color] duration-150 ${
|
className={`h-full transition-[width,background-color] duration-150 ${
|
||||||
data.summary.utilizationPercent > 90
|
data.summary.utilizationPercent > 90
|
||||||
? "bg-red-400"
|
? "bg-red-400"
|
||||||
: data.summary.utilizationPercent > 70
|
: data.summary.utilizationPercent > 70
|
||||||
|
|||||||
325
ui/src/pages/Usage.tsx
Normal file
325
ui/src/pages/Usage.tsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
|
||||||
|
import { costsApi } from "../api/costs";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { ProviderQuotaCard } from "../components/ProviderQuotaCard";
|
||||||
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
|
import { formatCents, formatTokens } from "../lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Gauge } from "lucide-react";
|
||||||
|
|
||||||
|
type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
|
||||||
|
|
||||||
|
const PRESET_LABELS: Record<DatePreset, string> = {
|
||||||
|
mtd: "Month to Date",
|
||||||
|
"7d": "Last 7 Days",
|
||||||
|
"30d": "Last 30 Days",
|
||||||
|
ytd: "Year to Date",
|
||||||
|
all: "All Time",
|
||||||
|
custom: "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
function computeRange(preset: DatePreset): { from: string; to: string } {
|
||||||
|
const now = new Date();
|
||||||
|
const to = now.toISOString();
|
||||||
|
switch (preset) {
|
||||||
|
case "mtd": {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return { from: d.toISOString(), to };
|
||||||
|
}
|
||||||
|
case "7d": {
|
||||||
|
const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
return { from: d.toISOString(), to };
|
||||||
|
}
|
||||||
|
case "30d": {
|
||||||
|
const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
return { from: d.toISOString(), to };
|
||||||
|
}
|
||||||
|
case "ytd": {
|
||||||
|
const d = new Date(now.getFullYear(), 0, 1);
|
||||||
|
return { from: d.toISOString(), to };
|
||||||
|
}
|
||||||
|
case "all":
|
||||||
|
return { from: "", to: "" };
|
||||||
|
case "custom":
|
||||||
|
return { from: "", to: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerDisplayName(provider: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
openai: "OpenAI",
|
||||||
|
google: "Google",
|
||||||
|
cursor: "Cursor",
|
||||||
|
jetbrains: "JetBrains AI",
|
||||||
|
};
|
||||||
|
return map[provider.toLowerCase()] ?? provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** current week mon-sun boundaries as iso strings */
|
||||||
|
function currentWeekRange(): { from: string; to: string } {
|
||||||
|
const now = new Date();
|
||||||
|
const day = now.getDay(); // 0 = Sun, 1 = Mon, …
|
||||||
|
const diffToMon = (day === 0 ? -6 : 1 - day);
|
||||||
|
const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate() + diffToMon, 0, 0, 0, 0);
|
||||||
|
const sun = new Date(mon.getTime() + 6 * 24 * 60 * 60 * 1000 + 23 * 3600 * 1000 + 3599 * 1000 + 999);
|
||||||
|
return { from: mon.toISOString(), to: sun.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderTabLabel({ provider, rows }: { provider: string; rows: CostByProviderModel[] }) {
|
||||||
|
const totalTokens = rows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
||||||
|
const totalCost = rows.reduce((s, r) => s + r.costCents, 0);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span>{providerDisplayName(provider)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{formatTokens(totalTokens)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatCents(totalCost)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Usage() {
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
||||||
|
const [preset, setPreset] = useState<DatePreset>("mtd");
|
||||||
|
const [customFrom, setCustomFrom] = useState("");
|
||||||
|
const [customTo, setCustomTo] = useState("");
|
||||||
|
const [activeProvider, setActiveProvider] = useState("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([{ label: "Usage" }]);
|
||||||
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
const { from, to } = useMemo(() => {
|
||||||
|
if (preset === "custom") {
|
||||||
|
// treat custom date strings as local-date boundaries so the full day is included
|
||||||
|
// regardless of the user's timezone. "from" starts at local midnight (00:00:00),
|
||||||
|
// "to" ends at local 23:59:59.999 (converted to utc via Date constructor).
|
||||||
|
const fromDate = customFrom ? new Date(customFrom + "T00:00:00") : null;
|
||||||
|
const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null;
|
||||||
|
return {
|
||||||
|
from: fromDate ? fromDate.toISOString() : "",
|
||||||
|
to: toDate ? toDate.toISOString() : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const range = computeRange(preset);
|
||||||
|
// floor `to` to the nearest minute so the query key is stable across 30s refetch ticks
|
||||||
|
// (prevents a new cache entry being created on every poll cycle)
|
||||||
|
if (range.to) {
|
||||||
|
const d = new Date(range.to);
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
range.to = d.toISOString();
|
||||||
|
}
|
||||||
|
return range;
|
||||||
|
}, [preset, customFrom, customTo]);
|
||||||
|
|
||||||
|
const weekRange = useMemo(() => currentWeekRange(), []);
|
||||||
|
|
||||||
|
// for custom preset, only fetch once both dates are selected
|
||||||
|
const customReady = preset !== "custom" || (!!customFrom && !!customTo);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.usageByProvider(selectedCompanyId!, from || undefined, to || undefined),
|
||||||
|
queryFn: () => costsApi.byProvider(selectedCompanyId!, from || undefined, to || undefined),
|
||||||
|
enabled: !!selectedCompanyId && customReady,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: summary } = useQuery({
|
||||||
|
queryKey: queryKeys.costs(selectedCompanyId!, from || undefined, to || undefined),
|
||||||
|
queryFn: () =>
|
||||||
|
costsApi.summary(selectedCompanyId!, from || undefined, to || undefined),
|
||||||
|
enabled: !!selectedCompanyId && customReady,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: weekData } = useQuery({
|
||||||
|
queryKey: queryKeys.usageByProvider(selectedCompanyId!, weekRange.from, weekRange.to),
|
||||||
|
queryFn: () => costsApi.byProvider(selectedCompanyId!, weekRange.from, weekRange.to),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: windowData } = useQuery({
|
||||||
|
queryKey: queryKeys.usageWindowSpend(selectedCompanyId!),
|
||||||
|
queryFn: () => costsApi.windowSpend(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// rows grouped by provider
|
||||||
|
const byProvider = useMemo(() => {
|
||||||
|
const map = new Map<string, CostByProviderModel[]>();
|
||||||
|
for (const row of data ?? []) {
|
||||||
|
const arr = map.get(row.provider) ?? [];
|
||||||
|
arr.push(row);
|
||||||
|
map.set(row.provider, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// week spend per provider
|
||||||
|
const weekSpendByProvider = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const row of weekData ?? []) {
|
||||||
|
map.set(row.provider, (map.get(row.provider) ?? 0) + row.costCents);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [weekData]);
|
||||||
|
|
||||||
|
// window spend rows per provider, keyed by provider with the 3-window array
|
||||||
|
const windowSpendByProvider = useMemo(() => {
|
||||||
|
const map = new Map<string, CostWindowSpendRow[]>();
|
||||||
|
for (const row of windowData ?? []) {
|
||||||
|
const arr = map.get(row.provider) ?? [];
|
||||||
|
arr.push(row);
|
||||||
|
map.set(row.provider, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [windowData]);
|
||||||
|
|
||||||
|
// deficit notch: projected spend exceeds remaining budget — only meaningful for mtd preset
|
||||||
|
// (other presets use a different date range than the monthly budget, so the projection is nonsensical)
|
||||||
|
const showDeficitNotch = useMemo(() => {
|
||||||
|
if (preset !== "mtd") return false;
|
||||||
|
const budget = summary?.budgetCents ?? 0;
|
||||||
|
if (budget <= 0) return false;
|
||||||
|
const spend = summary?.spendCents ?? 0;
|
||||||
|
const today = new Date();
|
||||||
|
const daysElapsed = today.getDate();
|
||||||
|
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
|
||||||
|
const daysRemaining = daysInMonth - daysElapsed;
|
||||||
|
const burnRatePerDay = spend / Math.max(daysElapsed, 1);
|
||||||
|
const projected = spend + burnRatePerDay * daysRemaining;
|
||||||
|
return projected > budget;
|
||||||
|
}, [summary, preset]);
|
||||||
|
|
||||||
|
const providers = Array.from(byProvider.keys());
|
||||||
|
|
||||||
|
if (!selectedCompanyId) {
|
||||||
|
return <EmptyState icon={Gauge} message="Select a company to view usage." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <PageSkeleton variant="costs" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
value: "all",
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span>All providers</span>
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{formatTokens(data.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0))}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatCents(data.reduce((s, r) => s + r.costCents, 0))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...providers.map((p) => ({
|
||||||
|
value: p,
|
||||||
|
label: <ProviderTabLabel provider={p} rows={byProvider.get(p)!} />,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* date range selector */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{presetKeys.map((p) => (
|
||||||
|
<Button
|
||||||
|
key={p}
|
||||||
|
variant={preset === p ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreset(p)}
|
||||||
|
>
|
||||||
|
{PRESET_LABELS[p]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{preset === "custom" && (
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customFrom}
|
||||||
|
onChange={(e) => setCustomFrom(e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customTo}
|
||||||
|
onChange={(e) => setCustomTo(e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
||||||
|
|
||||||
|
{preset === "custom" && !customReady ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
|
||||||
|
) : (
|
||||||
|
<Tabs value={activeProvider} onValueChange={setActiveProvider}>
|
||||||
|
<PageTabBar items={tabItems} value={activeProvider} onValueChange={setActiveProvider} />
|
||||||
|
|
||||||
|
<TabsContent value="all" className="mt-4">
|
||||||
|
{providers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No cost events in this period.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{providers.map((p) => (
|
||||||
|
<ProviderQuotaCard
|
||||||
|
key={p}
|
||||||
|
provider={p}
|
||||||
|
rows={byProvider.get(p)!}
|
||||||
|
budgetMonthlyCents={summary?.budgetCents ?? 0}
|
||||||
|
totalCompanySpendCents={summary?.spendCents ?? 0}
|
||||||
|
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
||||||
|
windowRows={windowSpendByProvider.get(p) ?? []}
|
||||||
|
showDeficitNotch={showDeficitNotch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{providers.map((p) => (
|
||||||
|
<TabsContent key={p} value={p} className="mt-4">
|
||||||
|
<ProviderQuotaCard
|
||||||
|
provider={p}
|
||||||
|
rows={byProvider.get(p)!}
|
||||||
|
budgetMonthlyCents={summary?.budgetCents ?? 0}
|
||||||
|
totalCompanySpendCents={summary?.spendCents ?? 0}
|
||||||
|
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
||||||
|
windowRows={windowSpendByProvider.get(p) ?? []}
|
||||||
|
showDeficitNotch={showDeficitNotch}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user