| 1 | import { data, useOutletContext } from "react-router"; |
| 2 | |
| 3 | import type { AgentSpendBreakdown, WorkspaceAgent } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/spend"; |
| 6 | import { readOrNull } from "../../../components/agents/actions.server"; |
| 7 | import { meterTone, monthName, shareOf } from "../../../components/agents/format"; |
| 8 | import { DailyBars, Meter, Panel, Quiet, SessionRow, SliceList } from "../../../components/agents/parts"; |
| 9 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 10 | import { workspaceAgents } from "../../../lib/services.server"; |
| 11 | import { money } from "../../../lib/usage"; |
| 12 | |
| 13 | /** Where this agent's month went. */ |
| 14 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ spend: AgentSpendBreakdown | null }> { |
| 15 | const viewer = requireUser(context, request); |
| 16 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 17 | return { spend: await readOrNull(workspaceAgents.spend(params.owner.toLowerCase(), viewer, params.handle.toLowerCase())) }; |
| 18 | } |
| 19 | |
| 20 | /** |
| 21 | * Spend this month against the agent's budget: by day, by kind of work, by |
| 22 | * model, by who asked, and its costliest sessions. A session's helpers and |
| 23 | * subagents are paid here when this agent started it. |
| 24 | */ |
| 25 | export default function Spend({ loaderData, params }: Route.ComponentProps) { |
| 26 | const agent = useOutletContext<WorkspaceAgent>(); |
| 27 | const { spend } = loaderData; |
| 28 | const spent = spend?.total_micros ?? agent.spent_month_micros ?? 0; |
| 29 | const cap = agent.budget.monthly_micros; |
| 30 | const share = shareOf(spent, cap); |
| 31 | const tone = meterTone(share); |
| 32 | return ( |
| 33 | <div className="space-y-6"> |
| 34 | <section className="rounded-2xl border border-line bg-surface p-6"> |
| 35 | <p className="text-sm text-muted">Spent in {monthName(spend?.period ?? new Date().toISOString().slice(0, 7))}</p> |
| 36 | <p className="mt-1 flex flex-wrap items-baseline gap-x-2"> |
| 37 | <span className="text-3xl font-semibold tracking-tight tabular-nums">{money(spent)}</span> |
| 38 | <span className="text-sm text-muted">{cap != null ? `of ${money(cap)}` : "No monthly budget of its own: the workspace's agent budget applies"}</span> |
| 39 | </p> |
| 40 | {cap != null && ( |
| 41 | <> |
| 42 | <Meter spent={spent} cap={cap} label="Spent of its monthly budget" className="mt-4" /> |
| 43 | <p className={`mt-2 text-xs ${tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : "text-faint"}`}> |
| 44 | {Math.round((share ?? 0) * 100)}% used. At 100% it takes no new work until the 1st, or until an owner raises its budget on its profile. |
| 45 | </p> |
| 46 | </> |
| 47 | )} |
| 48 | <dl className="mt-6 grid grid-cols-2 gap-4 border-t border-line pt-5 sm:grid-cols-4"> |
| 49 | <Stat label="Daily cap" value={agent.budget.daily_micros != null ? money(agent.budget.daily_micros) : "None"} /> |
| 50 | <Stat label="Per session" value={agent.budget.task_micros != null ? money(agent.budget.task_micros) : "Workspace default"} /> |
| 51 | <Stat label="While idle" value="Costs nothing" /> |
| 52 | <Stat label="Rolls up to" value="The workspace's bill" /> |
| 53 | </dl> |
| 54 | </section> |
| 55 | |
| 56 | {!spend ? ( |
| 57 | <Quiet title="The breakdown can't be shown right now">The agents service didn't answer. Reload in a moment.</Quiet> |
| 58 | ) : ( |
| 59 | <> |
| 60 | <Panel title="By day" aside="UTC"> |
| 61 | <DailyBars period={spend.period} days={spend.days} className="px-4 pt-4 pb-3" /> |
| 62 | </Panel> |
| 63 | <div className="grid gap-4 lg:grid-cols-2"> |
| 64 | <Panel title="By kind of work"> |
| 65 | <SliceList slices={spend.by_kind} unit="runs" /> |
| 66 | </Panel> |
| 67 | <Panel title="By model"> |
| 68 | <SliceList slices={spend.by_model} unit="runs" /> |
| 69 | </Panel> |
| 70 | <Panel title="By who asked" className="lg:col-span-2"> |
| 71 | <SliceList slices={spend.by_person} unit="runs" /> |
| 72 | </Panel> |
| 73 | </div> |
| 74 | {spend.top_sessions.length > 0 && ( |
| 75 | <Panel title="Costliest sessions" aside="With everything they brought in"> |
| 76 | <ul className="divide-y divide-line/60"> |
| 77 | {spend.top_sessions.map((session) => ( |
| 78 | <SessionRow key={session.id} slug={params.owner} session={session} showAgent={session.agent_id !== agent.id} /> |
| 79 | ))} |
| 80 | </ul> |
| 81 | </Panel> |
| 82 | )} |
| 83 | </> |
| 84 | )} |
| 85 | </div> |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | function Stat({ label, value }: { label: string; value: string }) { |
| 90 | return ( |
| 91 | <div> |
| 92 | <dt className="text-xs text-faint">{label}</dt> |
| 93 | <dd className="mt-0.5 text-sm tabular-nums">{value}</dd> |
| 94 | </div> |
| 95 | ); |
| 96 | } |