flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

225 lines10,392 bytesCodeBlame
1import { AlertOctagon, ArrowRight, CalendarClock, CreditCard, FileText, Gauge } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link, redirect } from "react-router";
4
5import type { Route } from "./+types/overview";
6import { KindBreakdown, MonthsChart } from "~/components/charts";
7import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
8import { Avatar, Notice, PageHeader, Section, Stat } from "~/components/ui";
9import { change, marginOf, monthLong } from "~/lib/chart";
10import { usd } from "~/lib/money";
11import { admin } from "~/lib/services.server";
12import { settle } from "~/lib/settle";
13import { bySignalUrgency, reachOutHref } from "~/lib/signals";
14import { requireStaff } from "~/lib/staff";
15
16export const meta: Route.MetaFunction = () => [{ title: "Overview · sudo" }, { name: "robots", content: "noindex, nofollow" }];
17
18export async function loader({ request, context }: Route.LoaderArgs) {
19 const staff = requireStaff(context);
20 // The workspaces list used to live here: old links with a search go on to it.
21 const { search, searchParams } = new URL(request.url);
22 if (searchParams.has("q") || searchParams.has("show") || searchParams.has("page")) throw redirect(`/workspaces${search}`);
23
24 const [overview, signals] = await Promise.all([settle(admin.overview()), settle(admin.signals())]);
25 return {
26 me: staff.email,
27 overview: overview.ok ? overview.value : null,
28 overviewError: overview.ok ? null : overview.error,
29 signals: signals.ok ? bySignalUrgency(signals.value) : [],
30 signalCount: signals.ok ? signals.value.length : 0,
31 signalsError: signals.ok ? null : signals.error,
32 };
33}
34
35export default function Overview({ loaderData }: Route.ComponentProps) {
36 const { me, overview, overviewError, signals, signalCount, signalsError } = loaderData;
37 const months = overview?.months ?? [];
38 const current = months.find((month) => month.month === overview?.month) ?? months.at(-1) ?? null;
39 const previous = current ? months[months.indexOf(current) - 1] ?? null : null;
40 const charged = current?.chargedMicros ?? overview?.byKind.reduce((sum, row) => sum + row.chargedMicros, 0) ?? 0;
41 const cost = current?.costMicros ?? overview?.byKind.reduce((sum, row) => sum + row.costMicros, 0) ?? 0;
42 const margin = marginOf(charged, cost);
43 const growth = previous ? change(previous.chargedMicros, charged) : null;
44
45 return (
46 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
47 <PageHeader
48 title="Overview"
49 description={overview ? <>The business in {monthLong(overview.month)}, so far, and who needs a word.</> : "The business at a glance."}
50 />
51
52 {overviewError && (
53 <div className="mt-6">
54 <Notice tone="warn">Billing did not answer for the overview: {overviewError}</Notice>
55 </div>
56 )}
57
58 {overview && (
59 <>
60 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
61 <Stat
62 label="Charged this month"
63 value={usd(charged)}
64 hint={growth == null ? "Nothing last month to compare" : `${growth >= 0 ? "+" : "−"}${Math.abs(growth)}% on last month`}
65 tone={growth != null && growth > 0 ? "mint" : undefined}
66 />
67 <Stat label="Cost to g1t" value={usd(cost)} hint="What the usage cost on Cloudflare and model providers" />
68 <Stat
69 label="Margin"
70 value={usd(margin.micros)}
71 hint={margin.percent == null ? "Nothing charged yet" : `${margin.percent}% of what was charged`}
72 tone={margin.micros < 0 ? "danger" : undefined}
73 />
74 <Stat label="Paying workspaces" value={String(overview.payingWorkspaces)} hint="Charged something this month" />
75 </div>
76
77 {overview.pools && (
78 <div className="mt-3 grid grid-cols-2 gap-3">
79 <Stat
80 label="Open-source pool"
81 value={`${usd(overview.pools.ossUsedMicros)} of ${usd(overview.pools.ossPoolMicros)}`}
82 hint={`Paid for work on public repositories this month; up to ${usd(overview.pools.ossRepoMicros)} each`}
83 tone={overview.pools.ossUsedMicros >= overview.pools.ossPoolMicros ? "warn" : undefined}
84 />
85 <Stat
86 label="Trials given"
87 value={`${usd(overview.pools.trialGrantedMicros)} of ${usd(overview.pools.trialPoolMicros)}`}
88 hint={`${overview.pools.trialGrants} new workspace${overview.pools.trialGrants === 1 ? "" : "s"} this month; new trials wait when it is given out`}
89 tone={overview.pools.trialGrantedMicros >= overview.pools.trialPoolMicros ? "warn" : undefined}
90 />
91 </div>
92 )}
93
94 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
95 <Section title="Last six months" description="Charged against what it cost g1t, every workspace together.">
96 <MonthsChart months={months} />
97 </Section>
98 <Section title="This month by kind" description="Where the money comes from, and what each earns.">
99 <KindBreakdown kinds={overview.byKind} />
100 </Section>
101 </div>
102
103 <h2 className="mt-10 text-lg font-semibold tracking-tight">Needs attention</h2>
104 <div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-5">
105 <AttentionCard
106 to={reachOutHref({ kind: "at_limit" })}
107 icon={<AlertOctagon size={15} />}
108 label="Stopped"
109 value={overview.stopped}
110 hint="At their limit; work is stopped"
111 tone={overview.stopped > 0 ? "danger" : undefined}
112 />
113 <AttentionCard
114 to={reachOutHref({ kind: "near_ceiling" })}
115 icon={<Gauge size={15} />}
116 label="Near limit"
117 value={overview.nearCeiling}
118 hint="Past 80% of what is available"
119 tone={overview.nearCeiling > 0 ? "warn" : undefined}
120 />
121 <AttentionCard
122 to={reachOutHref({ kind: "declined" })}
123 icon={<CreditCard size={15} />}
124 label="Declined"
125 value={overview.declined}
126 hint="Card declined or payment disputed"
127 tone={overview.declined > 0 ? "danger" : undefined}
128 />
129 <AttentionCard
130 to="/invoices"
131 icon={<FileText size={15} />}
132 label="Open invoices"
133 value={usd(overview.openInvoicesMicros)}
134 hint="Sent and not yet paid"
135 />
136 <AttentionCard
137 to={reachOutHref({ due: true })}
138 icon={<CalendarClock size={15} />}
139 label="Follow-ups due"
140 value={overview.followUpsDue}
141 hint="Next steps due today or earlier"
142 tone={overview.followUpsDue > 0 ? "warn" : undefined}
143 />
144 </div>
145 </>
146 )}
147
148 <Section
149 title="Reach out"
150 description={signalsError ? undefined : signalCount === 0 ? "Nobody needs a word right now." : `The most urgent of ${signalCount}.`}
151 actions={
152 signalCount > 0 && (
153 <Link to="/reach-out" className="inline-flex items-center gap-1 text-sm text-merged hover:underline hover:underline-offset-4">
154 All {signalCount}
155 <ArrowRight size={14} />
156 </Link>
157 )
158 }
159 className="mt-10"
160 >
161 {signalsError ? (
162 <Notice tone="warn">Billing did not answer for signals: {signalsError}</Notice>
163 ) : signals.length === 0 ? (
164 <p className="text-sm text-muted">No signals. Workspaces show up here when they stop, get close to their limit, are declined, or grow.</p>
165 ) : (
166 <ul className="-mx-4 -my-4 divide-y divide-line sm:-mx-5 sm:-my-5">
167 {signals.slice(0, 5).map((signal, index) => (
168 <li key={`${signal.workspace}-${signal.kind}-${index}`}>
169 <Link
170 to={`/workspaces/${encodeURIComponent(signal.workspace)}#sales`}
171 className="flex flex-col gap-2 px-4 py-3 transition-colors hover:bg-raised/40 sm:flex-row sm:items-center sm:gap-4 sm:px-5"
172 >
173 <span className="flex min-w-0 items-center gap-2.5 sm:w-48 sm:shrink-0">
174 <Avatar name={signal.workspace} size={20} />
175 <span className="truncate font-mono text-sm text-fg">{signal.workspace}</span>
176 </span>
177 <span className="flex min-w-0 grow flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
178 <span className="shrink-0">
179 <SignalBadge kind={signal.kind} />
180 </span>
181 <span className="min-w-0 text-sm text-muted">{signal.detail}</span>
182 </span>
183 <span className="flex shrink-0 items-center gap-3 text-xs sm:w-56 sm:justify-end">
184 <span className="tabular text-fg-soft">{usd(signal.valueMicros)}</span>
185 {signal.stage && signal.stage !== "none" && <StageBadge stage={signal.stage} />}
186 <StaffName email={signal.owner} me={me} />
187 </span>
188 </Link>
189 </li>
190 ))}
191 </ul>
192 )}
193 </Section>
194 </main>
195 );
196}
197
198function AttentionCard({
199 to,
200 icon,
201 label,
202 value,
203 hint,
204 tone,
205}: {
206 to: string;
207 icon: ReactNode;
208 label: string;
209 value: ReactNode;
210 hint: string;
211 tone?: "danger" | "warn";
212}) {
213 const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : "text-fg";
214 return (
215 <Link to={to} className="group rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-line-strong">
216 <p className="flex items-center gap-1.5 text-xs text-muted">
217 <span className={tone ? color : "text-faint"}>{icon}</span>
218 {label}
219 <ArrowRight size={12} className="ml-auto text-faint opacity-0 transition-opacity group-hover:opacity-100" />
220 </p>
221 <p className={`tabular mt-1 text-lg font-semibold tracking-tight ${color}`}>{value}</p>
222 <p className="mt-0.5 text-xs text-faint">{hint}</p>
223 </Link>
224 );
225}