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

208 lines9,402 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 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
78 <Section title="Last six months" description="Charged against what it cost g1t, every workspace together.">
79 <MonthsChart months={months} />
80 </Section>
81 <Section title="This month by kind" description="Where the money comes from, and what each earns.">
82 <KindBreakdown kinds={overview.byKind} />
83 </Section>
84 </div>
85
86 <h2 className="mt-10 text-lg font-semibold tracking-tight">Needs attention</h2>
87 <div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-5">
88 <AttentionCard
89 to={reachOutHref({ kind: "at_limit" })}
90 icon={<AlertOctagon size={15} />}
91 label="Stopped"
92 value={overview.stopped}
93 hint="At their limit; work is stopped"
94 tone={overview.stopped > 0 ? "danger" : undefined}
95 />
96 <AttentionCard
97 to={reachOutHref({ kind: "near_ceiling" })}
98 icon={<Gauge size={15} />}
99 label="Near limit"
100 value={overview.nearCeiling}
101 hint="Past 80% of what is available"
102 tone={overview.nearCeiling > 0 ? "warn" : undefined}
103 />
104 <AttentionCard
105 to={reachOutHref({ kind: "declined" })}
106 icon={<CreditCard size={15} />}
107 label="Declined"
108 value={overview.declined}
109 hint="Card declined or payment disputed"
110 tone={overview.declined > 0 ? "danger" : undefined}
111 />
112 <AttentionCard
113 to="/invoices"
114 icon={<FileText size={15} />}
115 label="Open invoices"
116 value={usd(overview.openInvoicesMicros)}
117 hint="Sent and not yet paid"
118 />
119 <AttentionCard
120 to={reachOutHref({ due: true })}
121 icon={<CalendarClock size={15} />}
122 label="Follow-ups due"
123 value={overview.followUpsDue}
124 hint="Next steps due today or earlier"
125 tone={overview.followUpsDue > 0 ? "warn" : undefined}
126 />
127 </div>
128 </>
129 )}
130
131 <Section
132 title="Reach out"
133 description={signalsError ? undefined : signalCount === 0 ? "Nobody needs a word right now." : `The most urgent of ${signalCount}.`}
134 actions={
135 signalCount > 0 && (
136 <Link to="/reach-out" className="inline-flex items-center gap-1 text-sm text-merged hover:underline hover:underline-offset-4">
137 All {signalCount}
138 <ArrowRight size={14} />
139 </Link>
140 )
141 }
142 className="mt-10"
143 >
144 {signalsError ? (
145 <Notice tone="warn">Billing did not answer for signals: {signalsError}</Notice>
146 ) : signals.length === 0 ? (
147 <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>
148 ) : (
149 <ul className="-mx-4 -my-4 divide-y divide-line sm:-mx-5 sm:-my-5">
150 {signals.slice(0, 5).map((signal, index) => (
151 <li key={`${signal.workspace}-${signal.kind}-${index}`}>
152 <Link
153 to={`/workspaces/${encodeURIComponent(signal.workspace)}#sales`}
154 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"
155 >
156 <span className="flex min-w-0 items-center gap-2.5 sm:w-48 sm:shrink-0">
157 <Avatar name={signal.workspace} size={20} />
158 <span className="truncate font-mono text-sm text-fg">{signal.workspace}</span>
159 </span>
160 <span className="flex min-w-0 grow flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
161 <span className="shrink-0">
162 <SignalBadge kind={signal.kind} />
163 </span>
164 <span className="min-w-0 text-sm text-muted">{signal.detail}</span>
165 </span>
166 <span className="flex shrink-0 items-center gap-3 text-xs sm:w-56 sm:justify-end">
167 <span className="tabular text-fg-soft">{usd(signal.valueMicros)}</span>
168 {signal.stage && signal.stage !== "none" && <StageBadge stage={signal.stage} />}
169 <StaffName email={signal.owner} me={me} />
170 </span>
171 </Link>
172 </li>
173 ))}
174 </ul>
175 )}
176 </Section>
177 </main>
178 );
179}
180
181function AttentionCard({
182 to,
183 icon,
184 label,
185 value,
186 hint,
187 tone,
188}: {
189 to: string;
190 icon: ReactNode;
191 label: string;
192 value: ReactNode;
193 hint: string;
194 tone?: "danger" | "warn";
195}) {
196 const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : "text-fg";
197 return (
198 <Link to={to} className="group rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-line-strong">
199 <p className="flex items-center gap-1.5 text-xs text-muted">
200 <span className={tone ? color : "text-faint"}>{icon}</span>
201 {label}
202 <ArrowRight size={12} className="ml-auto text-faint opacity-0 transition-opacity group-hover:opacity-100" />
203 </p>
204 <p className={`tabular mt-1 text-lg font-semibold tracking-tight ${color}`}>{value}</p>
205 <p className="mt-0.5 text-xs text-faint">{hint}</p>
206 </Link>
207 );
208}