flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/apps/sudo/app/routes/accounts.tsx

252 lines10,398 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Billing accounts, terms and enterprises; g1t is no longer free1import { Building2, ChevronRight, Search } from "lucide-react";
2import { Link } from "react-router";
3
4import type { AccountSummary } from "@g1t/contracts";
5
6import type { Route } from "./+types/accounts";
7import {
8 Avatar,
9 Button,
10 ButtonLink,
11 EmptyState,
12 ExposureBar,
13 KindBadge,
14 Stat,
15 TermsBadge,
16 TrustBadge,
17} from "~/components/ui";
18import { usd } from "~/lib/money";
19import { admin } from "~/lib/services.server";
20import { requireStaff } from "~/lib/staff";
21
22export const meta: Route.MetaFunction = () => [{ title: "Accounts · sudo" }, { name: "robots", content: "noindex, nofollow" }];
23
24const FILTERS = {
25 attention: { label: "Stopped or warning", test: (row: AccountSummary) => row.limit.state !== "ok" },
26 terms: { label: "Comped or custom", test: (row: AccountSummary) => row.account.terms.kind !== "standard" },
27 enterprise: { label: "Enterprises", test: (row: AccountSummary) => row.account.kind === "enterprise" },
28} as const;
29
30type Filter = keyof typeof FILTERS;
31
32const SEVERITY = { stopped: 0, warning: 1, ok: 2 } as const;
33
34export async function loader({ request, context }: Route.LoaderArgs) {
35 requireStaff(context);
36 const url = new URL(request.url);
37 const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100);
38 const show = url.searchParams.getAll("show").filter((value): value is Filter => value in FILTERS);
39
40 const all = await admin.accounts(q || undefined);
41 const rows = all
42 .filter((row) => show.every((filter) => FILTERS[filter].test(row)))
43 .sort(
44 (a, b) =>
45 SEVERITY[a.limit.state] - SEVERITY[b.limit.state] ||
46 b.limit.exposureMicros - a.limit.exposureMicros ||
47 a.account.name.localeCompare(b.account.name),
48 );
49
50 const sum = (pick: (row: AccountSummary) => number) => all.reduce((total, row) => total + pick(row), 0);
51 return {
52 q,
53 show,
54 rows,
55 total: all.length,
56 totals: {
57 charged: sum((row) => row.chargedMicros),
58 cost: sum((row) => row.costMicros),
59 paid: sum((row) => row.paidMicros),
60 exposure: sum((row) => row.limit.exposureMicros),
61 stopped: all.filter((row) => row.limit.state === "stopped").length,
62 warning: all.filter((row) => row.limit.state === "warning").length,
63 },
64 };
65}
66
67function accountHref(row: AccountSummary) {
68 return `/accounts/${encodeURIComponent(row.account.id)}`;
69}
70
71export default function Accounts({ loaderData }: Route.ComponentProps) {
72 const { q, show, rows, total, totals } = loaderData;
73 const margin = totals.charged - totals.cost;
74 const filtered = q !== "" || show.length > 0;
75
76 return (
77 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
78 <div className="flex flex-wrap items-end justify-between gap-4">
79 <div>
80 <h1 className="text-2xl font-semibold tracking-tight">Accounts</h1>
81 <p className="mt-1 text-sm text-muted">Every account that pays, and where it stands this month.</p>
82 </div>
83 <ButtonLink to="/enterprises/new" variant="lavender">
84 <Building2 size={15} />
85 New enterprise
86 </ButtonLink>
87 </div>
88
89 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
90 <Stat label="Charged this month" value={usd(totals.charged)} hint={`${total} account${total === 1 ? "" : "s"}`} />
91 <Stat label="Cost to g1t" value={usd(totals.cost)} hint={`Margin ${usd(margin)}`} tone={margin < 0 ? "danger" : undefined} />
92 <Stat label="Unpaid exposure" value={usd(totals.exposure)} hint={`Paid ever ${usd(totals.paid)}`} />
93 <Stat
94 label="Needs attention"
95 value={`${totals.stopped} stopped`}
96 hint={`${totals.warning} near the ceiling`}
97 tone={totals.stopped > 0 ? "danger" : totals.warning > 0 ? "warn" : "mint"}
98 />
99 </div>
100
101 <form method="get" action="/" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center">
102 <div className="relative grow">
103 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
104 <input
105 type="search"
106 name="q"
107 defaultValue={q}
108 placeholder="Search by workspace, account id or enterprise"
109 aria-label="Search accounts"
110 autoComplete="off"
111 data-1p-ignore
112 className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-merged/60"
113 />
114 </div>
115 <fieldset className="flex flex-wrap items-center gap-2">
116 <legend className="sr-only">Show only</legend>
117 {(Object.keys(FILTERS) as Filter[]).map((key) => (
118 <label
119 key={key}
120 className="inline-flex cursor-pointer items-center gap-2 rounded-full border border-line px-3 py-1.5 text-xs text-muted transition-colors select-none hover:border-line-strong has-checked:border-merged/50 has-checked:bg-merged/10 has-checked:text-merged"
121 >
122 <input type="checkbox" name="show" value={key} defaultChecked={show.includes(key)} className="size-3.5" />
123 {FILTERS[key].label}
124 </label>
125 ))}
126 <Button type="submit" variant="quiet" className="py-1.5">
127 Apply
128 </Button>
129 {filtered && (
130 <Link to="/" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
131 Clear
132 </Link>
133 )}
134 </fieldset>
135 </form>
136
137 <p className="mt-4 text-xs text-faint">
138 {rows.length === total ? `${total} accounts` : `${rows.length} of ${total} accounts`}
139 {q && (
140 <>
141 {" "}
142 matching <span className="font-mono text-muted">{q}</span>
143 </>
144 )}
145 . Stopped and warning first, then by exposure.
146 </p>
147
148 {rows.length === 0 ? (
149 <div className="mt-3">
150 <EmptyState title={filtered ? "No accounts match" : "No accounts yet"}>
151 {filtered ? "Try another search, or clear the filters." : "Accounts appear once a workspace exists."}
152 </EmptyState>
153 </div>
154 ) : (
155 <>
156 {/* Phones: one card per account. */}
157 <ul className="mt-3 space-y-2 md:hidden">
158 {rows.map((row) => (
159 <li key={row.account.id}>
160 <Link to={accountHref(row)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong">
161 <div className="flex items-start justify-between gap-3">
162 <AccountName row={row} />
163 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" />
164 </div>
165 <div className="mt-3">
166 <ExposureBar limit={row.limit} wide />
167 </div>
168 <dl className="tabular mt-3 grid grid-cols-3 gap-2 text-xs">
169 <Figure label="Charged" value={usd(row.chargedMicros)} />
170 <Figure label="Cost" value={usd(row.costMicros)} />
171 <Figure label="Paid ever" value={usd(row.paidMicros)} />
172 </dl>
173 </Link>
174 </li>
175 ))}
176 </ul>
177
178 {/* Wider screens: a table. */}
179 <div className="mt-3 hidden overflow-x-auto rounded-lg border border-line md:block">
180 <table className="w-full text-sm">
181 <thead>
182 <tr className="border-b border-line bg-surface text-left text-xs text-muted">
183 <th className="px-4 py-2.5 font-medium">Account</th>
184 <th className="px-4 py-2.5 font-medium">Trust</th>
185 <th className="px-4 py-2.5 font-medium">Exposure / ceiling</th>
186 <th className="px-4 py-2.5 text-right font-medium">Charged</th>
187 <th className="px-4 py-2.5 text-right font-medium">Cost to g1t</th>
188 <th className="px-4 py-2.5 text-right font-medium">Paid ever</th>
189 </tr>
190 </thead>
191 <tbody>
192 {rows.map((row) => (
193 <tr key={row.account.id} className="border-b border-line last:border-0 hover:bg-surface/60">
194 <td className="px-4 py-3">
195 <Link to={accountHref(row)} className="group block">
196 <AccountName row={row} />
197 </Link>
198 </td>
199 <td className="px-4 py-3">
200 <TrustBadge trust={row.limit.trust} />
201 </td>
202 <td className="px-4 py-3">
203 <ExposureBar limit={row.limit} />
204 </td>
205 <td className="tabular px-4 py-3 text-right">{usd(row.chargedMicros)}</td>
206 <td className="tabular px-4 py-3 text-right text-muted">{usd(row.costMicros)}</td>
207 <td className="tabular px-4 py-3 text-right text-muted">{usd(row.paidMicros)}</td>
208 </tr>
209 ))}
210 </tbody>
211 </table>
212 </div>
213 </>
214 )}
215 </main>
216 );
217}
218
219function AccountName({ row }: { row: AccountSummary }) {
220 const { account } = row;
221 const members = account.workspaces.length;
222 return (
223 <div className="flex min-w-0 items-start gap-2.5">
224 <span className="mt-0.5">
225 <Avatar name={account.name} size={22} />
226 </span>
227 <div className="min-w-0">
228 <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{account.name}</p>
229 <p className="truncate font-mono text-xs text-faint">
230 {account.id}
231 {account.kind === "enterprise" && ` · ${members} workspace${members === 1 ? "" : "s"}`}
232 </p>
233 <div className="mt-1.5 flex flex-wrap gap-1.5">
234 <KindBadge kind={account.kind} />
235 <TermsBadge terms={account.terms} />
236 <span className="md:hidden">
237 <TrustBadge trust={row.limit.trust} />
238 </span>
239 </div>
240 </div>
241 </div>
242 );
243}
244
245function Figure({ label, value }: { label: string; value: string }) {
246 return (
247 <div>
248 <dt className="text-faint">{label}</dt>
249 <dd className="mt-0.5 text-fg-soft">{value}</dd>
250 </div>
251 );
252}