feat(costs): add billing, quota, and budget control plane
This commit is contained in:
69
ui/src/components/AccountingModelCard.tsx
Normal file
69
ui/src/components/AccountingModelCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Database, Gauge, ReceiptText } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
const SURFACES = [
|
||||
{
|
||||
title: "Inference ledger",
|
||||
description: "Request-scoped usage and billed runs from cost_events.",
|
||||
icon: Database,
|
||||
points: ["tokens + billed dollars", "provider, biller, model", "subscription and overage aware"],
|
||||
tone: "from-sky-500/12 via-sky-500/6 to-transparent",
|
||||
},
|
||||
{
|
||||
title: "Finance ledger",
|
||||
description: "Account-level charges that are not one prompt-response pair.",
|
||||
icon: ReceiptText,
|
||||
points: ["top-ups, refunds, fees", "Bedrock provisioned or training charges", "credit expiries and adjustments"],
|
||||
tone: "from-amber-500/14 via-amber-500/6 to-transparent",
|
||||
},
|
||||
{
|
||||
title: "Live quotas",
|
||||
description: "Provider or biller windows that can stop traffic in real time.",
|
||||
icon: Gauge,
|
||||
points: ["provider quota windows", "biller credit systems", "errors surfaced directly"],
|
||||
tone: "from-emerald-500/14 via-emerald-500/6 to-transparent",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AccountingModelCard() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-border/70">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.08),transparent_35%),radial-gradient(circle_at_bottom_right,rgba(56,189,248,0.1),transparent_32%)]" />
|
||||
<CardHeader className="relative px-5 pt-5 pb-2">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Accounting model
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-2xl text-sm leading-6">
|
||||
Paperclip now separates request-level inference usage from account-level finance events.
|
||||
That keeps provider reporting honest when the biller is OpenRouter, Cloudflare, Bedrock, or another intermediary.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative grid gap-3 px-5 pb-5 md:grid-cols-3">
|
||||
{SURFACES.map((surface) => {
|
||||
const Icon = surface.icon;
|
||||
return (
|
||||
<div
|
||||
key={surface.title}
|
||||
className={`rounded-2xl border border-border/70 bg-gradient-to-br ${surface.tone} p-4 shadow-sm`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border/70 bg-background/80">
|
||||
<Icon className="h-4 w-4 text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{surface.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{surface.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-muted-foreground">
|
||||
{surface.points.map((point) => (
|
||||
<div key={point}>{point}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export function ApprovalCard({
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = typeLabel[approval.type] ?? approval.type;
|
||||
const showResolutionButtons =
|
||||
approval.type !== "budget_override_required" &&
|
||||
(approval.status === "pending" || approval.status === "revision_requested");
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-0">
|
||||
@@ -67,7 +70,7 @@ export function ApprovalCard({
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{(approval.status === "pending" || approval.status === "revision_requested") && (
|
||||
{showResolutionButtons && (
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { UserPlus, Lightbulb, ShieldCheck } from "lucide-react";
|
||||
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { formatCents } from "../lib/utils";
|
||||
|
||||
export const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
budget_override_required: "Budget Override",
|
||||
};
|
||||
|
||||
export const typeIcon: Record<string, typeof UserPlus> = {
|
||||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
budget_override_required: ShieldAlert,
|
||||
};
|
||||
|
||||
export const defaultTypeIcon = ShieldCheck;
|
||||
@@ -69,7 +72,28 @@ export function CeoStrategyPayload({ payload }: { payload: Record<string, unknow
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetOverridePayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const budgetAmount = typeof payload.budgetAmount === "number" ? payload.budgetAmount : null;
|
||||
const observedAmount = typeof payload.observedAmount === "number" ? payload.observedAmount : null;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<PayloadField label="Scope" value={payload.scopeName ?? payload.scopeType} />
|
||||
<PayloadField label="Window" value={payload.windowKind} />
|
||||
<PayloadField label="Metric" value={payload.metric} />
|
||||
{(budgetAmount !== null || observedAmount !== null) ? (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
Limit {budgetAmount !== null ? formatCents(budgetAmount) : "—"} · Observed {observedAmount !== null ? formatCents(observedAmount) : "—"}
|
||||
</div>
|
||||
) : null}
|
||||
{!!payload.guidance && (
|
||||
<p className="text-muted-foreground">{String(payload.guidance)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
|
||||
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
|
||||
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
|
||||
return <CeoStrategyPayload payload={payload} />;
|
||||
}
|
||||
|
||||
145
ui/src/components/BillerSpendCard.tsx
Normal file
145
ui/src/components/BillerSpendCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CostByBiller, CostByProviderModel } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { QuotaBar } from "./QuotaBar";
|
||||
import { billingTypeDisplayName, formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
|
||||
|
||||
interface BillerSpendCardProps {
|
||||
row: CostByBiller;
|
||||
weekSpendCents: number;
|
||||
budgetMonthlyCents: number;
|
||||
totalCompanySpendCents: number;
|
||||
providerRows: CostByProviderModel[];
|
||||
}
|
||||
|
||||
export function BillerSpendCard({
|
||||
row,
|
||||
weekSpendCents,
|
||||
budgetMonthlyCents,
|
||||
totalCompanySpendCents,
|
||||
providerRows,
|
||||
}: BillerSpendCardProps) {
|
||||
const providerBreakdown = useMemo(() => {
|
||||
const map = new Map<string, { provider: string; costCents: number; inputTokens: number; outputTokens: number }>();
|
||||
for (const entry of providerRows) {
|
||||
const current = map.get(entry.provider) ?? {
|
||||
provider: entry.provider,
|
||||
costCents: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
current.costCents += entry.costCents;
|
||||
current.inputTokens += entry.inputTokens + entry.cachedInputTokens;
|
||||
current.outputTokens += entry.outputTokens;
|
||||
map.set(entry.provider, current);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.costCents - a.costCents);
|
||||
}, [providerRows]);
|
||||
|
||||
const billingTypeBreakdown = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const entry of providerRows) {
|
||||
map.set(entry.billingType, (map.get(entry.billingType) ?? 0) + entry.costCents);
|
||||
}
|
||||
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
|
||||
}, [providerRows]);
|
||||
|
||||
const providerBudgetShare =
|
||||
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
|
||||
? (row.costCents / totalCompanySpendCents) * budgetMonthlyCents
|
||||
: budgetMonthlyCents;
|
||||
const budgetPct =
|
||||
providerBudgetShare > 0
|
||||
? Math.min(100, (row.costCents / providerBudgetShare) * 100)
|
||||
: 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">
|
||||
{providerDisplayName(row.biller)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5">
|
||||
<span className="font-mono">{formatTokens(row.inputTokens + row.cachedInputTokens)}</span> in
|
||||
{" · "}
|
||||
<span className="font-mono">{formatTokens(row.outputTokens)}</span> out
|
||||
{" · "}
|
||||
{row.providerCount} provider{row.providerCount === 1 ? "" : "s"}
|
||||
{" · "}
|
||||
{row.modelCount} model{row.modelCount === 1 ? "" : "s"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<span className="text-xl font-bold tabular-nums shrink-0">
|
||||
{formatCents(row.costCents)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pb-4 pt-3 space-y-4">
|
||||
{budgetMonthlyCents > 0 && (
|
||||
<QuotaBar
|
||||
label="Period spend"
|
||||
percentUsed={budgetPct}
|
||||
leftLabel={formatCents(row.costCents)}
|
||||
rightLabel={`${Math.round(budgetPct)}% of allocation`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.apiRunCount > 0 ? `${row.apiRunCount} metered run${row.apiRunCount === 1 ? "" : "s"}` : "0 metered runs"}
|
||||
{" · "}
|
||||
{row.subscriptionRunCount > 0
|
||||
? `${row.subscriptionRunCount} subscription run${row.subscriptionRunCount === 1 ? "" : "s"}`
|
||||
: "0 subscription runs"}
|
||||
{" · "}
|
||||
{formatCents(weekSpendCents)} this week
|
||||
</div>
|
||||
|
||||
{billingTypeBreakdown.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Billing types
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{billingTypeBreakdown.map(([billingType, costCents]) => (
|
||||
<div key={billingType} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{billingTypeDisplayName(billingType as any)}</span>
|
||||
<span className="font-medium tabular-nums">{formatCents(costCents)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerBreakdown.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Upstream providers
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{providerBreakdown.map((entry) => (
|
||||
<div key={entry.provider} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{providerDisplayName(entry.provider)}</span>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="font-medium">{formatCents(entry.costCents)}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatTokens(entry.inputTokens + entry.outputTokens)} tok
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
ui/src/components/BudgetIncidentCard.tsx
Normal file
100
ui/src/components/BudgetIncidentCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import type { BudgetIncident } from "@paperclipai/shared";
|
||||
import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function centsInputValue(value: number) {
|
||||
return (value / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function parseDollarInput(value: string) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
export function BudgetIncidentCard({
|
||||
incident,
|
||||
onRaiseAndResume,
|
||||
onKeepPaused,
|
||||
isMutating,
|
||||
}: {
|
||||
incident: BudgetIncident;
|
||||
onRaiseAndResume: (amountCents: number) => void;
|
||||
onKeepPaused: () => void;
|
||||
isMutating?: boolean;
|
||||
}) {
|
||||
const [draftAmount, setDraftAmount] = useState(
|
||||
centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)),
|
||||
);
|
||||
const parsed = parseDollarInput(draftAmount);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-red-500/20 bg-[linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))]">
|
||||
<CardHeader className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-red-200/80">
|
||||
{incident.scopeType} hard stop
|
||||
</div>
|
||||
<CardTitle className="mt-1 text-base text-red-50">{incident.scopeName}</CardTitle>
|
||||
<CardDescription className="mt-1 text-red-100/70">
|
||||
Spending reached {formatCents(incident.amountObserved)} against a limit of {formatCents(incident.amountLimit)}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="rounded-full border border-red-400/30 bg-red-500/10 p-2 text-red-200">
|
||||
<AlertOctagon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-5 pb-5 pt-0">
|
||||
<div className="flex items-start gap-2 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-50/90">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
{incident.scopeType === "project"
|
||||
? "Project execution is paused. New work in this project will not start until you resolve the budget incident."
|
||||
: "This scope is paused. New heartbeats will not start until you resolve the budget incident."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/60 p-3">
|
||||
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
New budget (USD)
|
||||
</label>
|
||||
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={draftAmount}
|
||||
onChange={(event) => setDraftAmount(event.target.value)}
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isMutating || parsed === null || parsed <= incident.amountObserved}
|
||||
onClick={() => {
|
||||
if (typeof parsed === "number") onRaiseAndResume(parsed);
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
{isMutating ? "Applying..." : "Raise budget & resume"}
|
||||
</Button>
|
||||
</div>
|
||||
{parsed !== null && parsed <= incident.amountObserved ? (
|
||||
<p className="mt-2 text-xs text-red-200/80">
|
||||
The new budget must exceed current observed spend.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" className="text-muted-foreground" disabled={isMutating} onClick={onKeepPaused}>
|
||||
Keep paused
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
153
ui/src/components/BudgetPolicyCard.tsx
Normal file
153
ui/src/components/BudgetPolicyCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { AlertTriangle, PauseCircle, ShieldAlert, Wallet } from "lucide-react";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function centsInputValue(value: number) {
|
||||
return (value / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function parseDollarInput(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) return 0;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
function windowLabel(windowKind: BudgetPolicySummary["windowKind"]) {
|
||||
return windowKind === "lifetime" ? "Lifetime budget" : "Monthly UTC budget";
|
||||
}
|
||||
|
||||
function statusTone(status: BudgetPolicySummary["status"]) {
|
||||
if (status === "hard_stop") return "text-red-300 border-red-500/30 bg-red-500/10";
|
||||
if (status === "warning") return "text-amber-200 border-amber-500/30 bg-amber-500/10";
|
||||
return "text-emerald-200 border-emerald-500/30 bg-emerald-500/10";
|
||||
}
|
||||
|
||||
export function BudgetPolicyCard({
|
||||
summary,
|
||||
onSave,
|
||||
isSaving,
|
||||
compact = false,
|
||||
}: {
|
||||
summary: BudgetPolicySummary;
|
||||
onSave?: (amountCents: number) => void;
|
||||
isSaving?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [draftBudget, setDraftBudget] = useState(centsInputValue(summary.amount));
|
||||
|
||||
useEffect(() => {
|
||||
setDraftBudget(centsInputValue(summary.amount));
|
||||
}, [summary.amount]);
|
||||
|
||||
const parsedDraft = parseDollarInput(draftBudget);
|
||||
const canSave = typeof parsedDraft === "number" && parsedDraft !== summary.amount && Boolean(onSave);
|
||||
const progress = summary.amount > 0 ? Math.min(100, summary.utilizationPercent) : 0;
|
||||
const StatusIcon = summary.status === "hard_stop" ? ShieldAlert : summary.status === "warning" ? AlertTriangle : Wallet;
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden border-border/70 bg-card/80", compact ? "" : "shadow-[0_20px_80px_-40px_rgba(0,0,0,0.55)]")}>
|
||||
<CardHeader className={cn("gap-3", compact ? "px-4 pt-4 pb-2" : "px-5 pt-5 pb-3")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{summary.scopeType}
|
||||
</div>
|
||||
<CardTitle className="mt-1 text-base">{summary.scopeName}</CardTitle>
|
||||
<CardDescription className="mt-1">{windowLabel(summary.windowKind)}</CardDescription>
|
||||
</div>
|
||||
<div className={cn("inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] uppercase tracking-[0.18em]", statusTone(summary.status))}>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
{summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={cn("space-y-4", compact ? "px-4 pb-4 pt-0" : "px-5 pb-5 pt-0")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Budget</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">
|
||||
{summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Remaining</span>
|
||||
<span>{summary.amount > 0 ? formatCents(summary.remainingAmount) : "Unlimited"}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted/70">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width,background-color] duration-200",
|
||||
summary.status === "hard_stop"
|
||||
? "bg-red-400"
|
||||
: summary.status === "warning"
|
||||
? "bg-amber-300"
|
||||
: "bg-emerald-300",
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.paused ? (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-100">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
{summary.scopeType === "project"
|
||||
? "Execution is paused for this project until the budget is raised or the incident is dismissed."
|
||||
: "Heartbeats are paused for this scope until the budget is raised or the incident is dismissed."}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onSave ? (
|
||||
<div className="rounded-xl border border-border/70 bg-background/50 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Budget (USD)
|
||||
</label>
|
||||
<Input
|
||||
value={draftBudget}
|
||||
onChange={(event) => setDraftBudget(event.target.value)}
|
||||
className="mt-2"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (typeof parsedDraft === "number" && onSave) onSave(parsedDraft);
|
||||
}}
|
||||
disabled={!canSave || isSaving || parsedDraft === null}
|
||||
>
|
||||
{isSaving ? "Saving..." : summary.amount > 0 ? "Update budget" : "Set budget"}
|
||||
</Button>
|
||||
</div>
|
||||
{parsedDraft === null ? (
|
||||
<p className="mt-2 text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
140
ui/src/components/ClaudeSubscriptionPanel.tsx
Normal file
140
ui/src/components/ClaudeSubscriptionPanel.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { QuotaWindow } from "@paperclipai/shared";
|
||||
import { cn, quotaSourceDisplayName } from "@/lib/utils";
|
||||
|
||||
interface ClaudeSubscriptionPanelProps {
|
||||
windows: QuotaWindow[];
|
||||
source?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const WINDOW_ORDER = [
|
||||
"currentsession",
|
||||
"currentweekallmodels",
|
||||
"currentweeksonnetonly",
|
||||
"currentweeksonnet",
|
||||
"currentweekopusonly",
|
||||
"currentweekopus",
|
||||
"extrausage",
|
||||
] as const;
|
||||
|
||||
function normalizeLabel(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function detailText(window: QuotaWindow): string | null {
|
||||
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
|
||||
if (window.resetsAt) {
|
||||
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
return `Resets ${formatted}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
|
||||
return [...windows].sort((a, b) => {
|
||||
const aIndex = WINDOW_ORDER.indexOf(normalizeLabel(a.label) as (typeof WINDOW_ORDER)[number]);
|
||||
const bIndex = WINDOW_ORDER.indexOf(normalizeLabel(b.label) as (typeof WINDOW_ORDER)[number]);
|
||||
return (aIndex === -1 ? WINDOW_ORDER.length : aIndex) - (bIndex === -1 ? WINDOW_ORDER.length : bIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function fillClass(usedPercent: number | null): string {
|
||||
if (usedPercent == null) return "bg-zinc-700";
|
||||
if (usedPercent >= 90) return "bg-red-400";
|
||||
if (usedPercent >= 70) return "bg-amber-400";
|
||||
return "bg-primary/70";
|
||||
}
|
||||
|
||||
export function ClaudeSubscriptionPanel({
|
||||
windows,
|
||||
source = null,
|
||||
error = null,
|
||||
}: ClaudeSubscriptionPanelProps) {
|
||||
const ordered = orderedWindows(windows);
|
||||
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Anthropic subscription
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Live Claude quota windows.
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(source)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{ordered.map((window) => {
|
||||
const normalized = normalizeLabel(window.label);
|
||||
const detail = detailText(window);
|
||||
if (normalized === "extrausage") {
|
||||
return (
|
||||
<div
|
||||
key={window.label}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{window.valueLabel ? (
|
||||
<div className="text-sm font-medium text-foreground">{window.valueLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{detail ? (
|
||||
<div className="mt-2 text-sm text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const width = Math.min(100, Math.max(0, window.usedPercent ?? 0));
|
||||
return (
|
||||
<div
|
||||
key={window.label}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{detail ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{window.usedPercent != null ? (
|
||||
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
|
||||
{window.usedPercent}% used
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 overflow-hidden bg-muted">
|
||||
<div
|
||||
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
ui/src/components/CodexSubscriptionPanel.tsx
Normal file
157
ui/src/components/CodexSubscriptionPanel.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { QuotaWindow } from "@paperclipai/shared";
|
||||
import { cn, quotaSourceDisplayName } from "@/lib/utils";
|
||||
|
||||
interface CodexSubscriptionPanelProps {
|
||||
windows: QuotaWindow[];
|
||||
source?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const WINDOW_PRIORITY = [
|
||||
"5hlimit",
|
||||
"weeklylimit",
|
||||
"credits",
|
||||
] as const;
|
||||
|
||||
function normalizeLabel(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
|
||||
return [...windows].sort((a, b) => {
|
||||
const aBase = normalizeLabel(a.label).replace(/^gpt53codexspark/, "");
|
||||
const bBase = normalizeLabel(b.label).replace(/^gpt53codexspark/, "");
|
||||
const aIndex = WINDOW_PRIORITY.indexOf(aBase as (typeof WINDOW_PRIORITY)[number]);
|
||||
const bIndex = WINDOW_PRIORITY.indexOf(bBase as (typeof WINDOW_PRIORITY)[number]);
|
||||
return (aIndex === -1 ? WINDOW_PRIORITY.length : aIndex) - (bIndex === -1 ? WINDOW_PRIORITY.length : bIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function detailText(window: QuotaWindow): string | null {
|
||||
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
|
||||
if (!window.resetsAt) return null;
|
||||
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
return `Resets ${formatted}`;
|
||||
}
|
||||
|
||||
function fillClass(usedPercent: number | null): string {
|
||||
if (usedPercent == null) return "bg-zinc-700";
|
||||
if (usedPercent >= 90) return "bg-red-400";
|
||||
if (usedPercent >= 70) return "bg-amber-400";
|
||||
return "bg-primary/70";
|
||||
}
|
||||
|
||||
function isModelSpecific(label: string): boolean {
|
||||
const normalized = normalizeLabel(label);
|
||||
return normalized.includes("gpt53codexspark") || normalized.includes("gpt5");
|
||||
}
|
||||
|
||||
export function CodexSubscriptionPanel({
|
||||
windows,
|
||||
source = null,
|
||||
error = null,
|
||||
}: CodexSubscriptionPanelProps) {
|
||||
const ordered = orderedWindows(windows);
|
||||
const accountWindows = ordered.filter((window) => !isModelSpecific(window.label));
|
||||
const modelWindows = ordered.filter((window) => isModelSpecific(window.label));
|
||||
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Codex subscription
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Live Codex quota windows.
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(source)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Account windows
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{accountWindows.map((window) => (
|
||||
<QuotaWindowRow key={window.label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modelWindows.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model windows
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{modelWindows.map((window) => (
|
||||
<QuotaWindowRow key={window.label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaWindowRow({ window }: { window: QuotaWindow }) {
|
||||
const detail = detailText(window);
|
||||
if (window.usedPercent == null) {
|
||||
return (
|
||||
<div className="border border-border px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{window.valueLabel ? (
|
||||
<div className="text-sm font-semibold tabular-nums text-foreground">{window.valueLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{detail ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border px-3.5 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{detail ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
|
||||
{window.usedPercent}% used
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 overflow-hidden bg-muted">
|
||||
<div
|
||||
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
|
||||
style={{ width: `${Math.max(0, Math.min(100, window.usedPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
ui/src/components/FinanceBillerCard.tsx
Normal file
44
ui/src/components/FinanceBillerCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { FinanceByBiller } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatCents, providerDisplayName } from "@/lib/utils";
|
||||
|
||||
interface FinanceBillerCardProps {
|
||||
row: FinanceByBiller;
|
||||
}
|
||||
|
||||
export function FinanceBillerCard({ row }: FinanceBillerCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{providerDisplayName(row.biller)}</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} across {row.kindCount} kind{row.kindCount === 1 ? "" : "s"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold tabular-nums">{formatCents(row.netCents)}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">net</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4 pt-3">
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">debits</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.debitCents)}</div>
|
||||
</div>
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">credits</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.creditCents)}</div>
|
||||
</div>
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">estimated</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.estimatedDebitCents)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
43
ui/src/components/FinanceKindCard.tsx
Normal file
43
ui/src/components/FinanceKindCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { FinanceByKind } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { financeEventKindDisplayName, formatCents } from "@/lib/utils";
|
||||
|
||||
interface FinanceKindCardProps {
|
||||
rows: FinanceByKind[];
|
||||
}
|
||||
|
||||
export function FinanceKindCard({ rows }: FinanceKindCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<CardTitle className="text-base">Financial event mix</CardTitle>
|
||||
<CardDescription>Account-level charges grouped by event kind.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4 pb-4 pt-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No finance events in this period.</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<div
|
||||
key={row.eventKind}
|
||||
className="flex items-center justify-between gap-3 border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{financeEventKindDisplayName(row.eventKind)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} · {row.billerCount} biller{row.billerCount === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="text-sm font-medium">{formatCents(row.netCents)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatCents(row.debitCents)} debits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
71
ui/src/components/FinanceTimelineCard.tsx
Normal file
71
ui/src/components/FinanceTimelineCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { FinanceEvent } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
financeDirectionDisplayName,
|
||||
financeEventKindDisplayName,
|
||||
formatCents,
|
||||
formatDateTime,
|
||||
providerDisplayName,
|
||||
} from "@/lib/utils";
|
||||
|
||||
interface FinanceTimelineCardProps {
|
||||
rows: FinanceEvent[];
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function FinanceTimelineCard({
|
||||
rows,
|
||||
emptyMessage = "No financial events in this period.",
|
||||
}: FinanceTimelineCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<CardTitle className="text-base">Recent financial events</CardTitle>
|
||||
<CardDescription>Top-ups, fees, credits, commitments, and other non-request charges.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4 pt-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="border border-border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{financeEventKindDisplayName(row.eventKind)}</Badge>
|
||||
<Badge variant={row.direction === "credit" ? "outline" : "secondary"}>
|
||||
{financeDirectionDisplayName(row.direction)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(row.occurredAt)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{providerDisplayName(row.biller)}
|
||||
{row.provider ? ` -> ${providerDisplayName(row.provider)}` : ""}
|
||||
{row.model ? <span className="ml-1 font-mono text-xs text-muted-foreground">{row.model}</span> : null}
|
||||
</div>
|
||||
{(row.description || row.externalInvoiceId || row.region || row.pricingTier) && (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{row.description ? <div>{row.description}</div> : null}
|
||||
{row.externalInvoiceId ? <div>invoice {row.externalInvoiceId}</div> : null}
|
||||
{row.region ? <div>region {row.region}</div> : null}
|
||||
{row.pricingTier ? <div>tier {row.pricingTier}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="text-sm font-semibold">{formatCents(row.amountCents)}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.currency}</div>
|
||||
{row.estimated ? <div className="text-[11px] uppercase tracking-[0.12em] text-amber-600">estimated</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QuotaBar } from "./QuotaBar";
|
||||
import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
|
||||
import { ClaudeSubscriptionPanel } from "./ClaudeSubscriptionPanel";
|
||||
import { CodexSubscriptionPanel } from "./CodexSubscriptionPanel";
|
||||
import {
|
||||
billingTypeDisplayName,
|
||||
formatCents,
|
||||
formatTokens,
|
||||
providerDisplayName,
|
||||
quotaSourceDisplayName,
|
||||
} from "@/lib/utils";
|
||||
|
||||
// ordered display labels for rolling-window rows
|
||||
const ROLLING_WINDOWS = ["5h", "24h", "7d"] as const;
|
||||
@@ -21,6 +30,9 @@ interface ProviderQuotaCardProps {
|
||||
showDeficitNotch: boolean;
|
||||
/** live subscription quota windows from the provider's own api */
|
||||
quotaWindows?: QuotaWindow[];
|
||||
quotaError?: string | null;
|
||||
quotaSource?: string | null;
|
||||
quotaLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderQuotaCard({
|
||||
@@ -32,6 +44,9 @@ export function ProviderQuotaCard({
|
||||
windowRows,
|
||||
showDeficitNotch,
|
||||
quotaWindows = [],
|
||||
quotaError = null,
|
||||
quotaSource = null,
|
||||
quotaLoading = false,
|
||||
}: ProviderQuotaCardProps) {
|
||||
// single-pass aggregation over rows — memoized so the 8 derived values are not
|
||||
// recomputed on every parent render tick (providers tab polls every 30s, and each
|
||||
@@ -108,6 +123,11 @@ export function ProviderQuotaCard({
|
||||
() => Math.max(...windowRows.map((r) => r.costCents), 0),
|
||||
[windowRows],
|
||||
);
|
||||
const isClaudeQuotaPanel = provider === "anthropic";
|
||||
const isCodexQuotaPanel = provider === "openai" && quotaSource?.startsWith("codex-");
|
||||
const supportsSubscriptionQuota = provider === "anthropic" || provider === "openai";
|
||||
const showSubscriptionQuotaSection =
|
||||
supportsSubscriptionQuota && (quotaLoading || quotaWindows.length > 0 || quotaError != null);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -183,7 +203,7 @@ export function ProviderQuotaCard({
|
||||
</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-2 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/60 transition-[width] duration-150"
|
||||
style={{ width: `${barPct}%` }}
|
||||
@@ -197,56 +217,6 @@ export function ProviderQuotaCard({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription quota windows from provider api — shown when data is available */}
|
||||
{quotaWindows.length > 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 quota
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{quotaWindows.map((qw) => {
|
||||
const fillColor =
|
||||
qw.usedPercent == null
|
||||
? null
|
||||
: qw.usedPercent >= 90
|
||||
? "bg-red-400"
|
||||
: qw.usedPercent >= 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400";
|
||||
return (
|
||||
<div key={qw.label} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
||||
<span className="flex-1" />
|
||||
{qw.valueLabel != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.valueLabel}</span>
|
||||
) : qw.usedPercent != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
|
||||
) : null}
|
||||
</div>
|
||||
{qw.usedPercent != null && fillColor != null && (
|
||||
<div className="h-1.5 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-[width] duration-150 ${fillColor}`}
|
||||
style={{ width: `${qw.usedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{qw.resetsAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription usage — shown when any subscription-billed runs exist */}
|
||||
{totalSubRuns > 0 && (
|
||||
<>
|
||||
@@ -258,6 +228,12 @@ export function ProviderQuotaCard({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-mono text-foreground">{totalSubRuns}</span> runs
|
||||
{" · "}
|
||||
{totalSubTokens > 0 && (
|
||||
<>
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubTokens)}</span> total
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubInputTokens)}</span> in
|
||||
{" · "}
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubOutputTokens)}</span> out
|
||||
@@ -292,9 +268,14 @@ export function ProviderQuotaCard({
|
||||
<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="min-w-0">
|
||||
<span className="text-xs text-muted-foreground truncate font-mono block">
|
||||
{row.model}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground truncate block">
|
||||
{providerDisplayName(row.biller)} · {billingTypeDisplayName(row.billingType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 tabular-nums text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatTokens(rowTokens)} tok
|
||||
@@ -303,7 +284,7 @@ export function ProviderQuotaCard({
|
||||
</div>
|
||||
</div>
|
||||
{/* token share bar */}
|
||||
<div className="relative h-1.5 w-full border border-border overflow-hidden">
|
||||
<div className="relative h-2 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}%` }}
|
||||
@@ -322,7 +303,114 @@ export function ProviderQuotaCard({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription quota windows from provider api — shown when data is available */}
|
||||
{showSubscriptionQuotaSection && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Subscription quota
|
||||
</p>
|
||||
{quotaSource && !isClaudeQuotaPanel && !isCodexQuotaPanel ? (
|
||||
<span className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(quotaSource)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{quotaLoading ? (
|
||||
<QuotaPanelSkeleton />
|
||||
) : isClaudeQuotaPanel ? (
|
||||
<ClaudeSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
|
||||
) : isCodexQuotaPanel ? (
|
||||
<CodexSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
|
||||
) : (
|
||||
<>
|
||||
{quotaError ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{quotaError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-2.5">
|
||||
{quotaWindows.map((qw) => {
|
||||
const fillColor =
|
||||
qw.usedPercent == null
|
||||
? null
|
||||
: qw.usedPercent >= 90
|
||||
? "bg-red-400"
|
||||
: qw.usedPercent >= 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400";
|
||||
return (
|
||||
<div key={qw.label} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
||||
<span className="flex-1" />
|
||||
{qw.valueLabel != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.valueLabel}</span>
|
||||
) : qw.usedPercent != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
|
||||
) : null}
|
||||
</div>
|
||||
{qw.usedPercent != null && fillColor != null && (
|
||||
<div className="h-2 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-[width] duration-150 ${fillColor}`}
|
||||
style={{ width: `${qw.usedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{qw.detail ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{qw.detail}
|
||||
</p>
|
||||
) : qw.resetsAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaPanelSkeleton() {
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-36" />
|
||||
<Skeleton className="h-4 w-64 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-28" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-44 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-2 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user