Skip to content

Commit df24cd5

Browse files
ericallamsamejr
andauthored
feat: Basic usage dashboard to show run volume (#501)
* New usage dashboard with static data * Implement org usage dash * Grab chart data for the last 12 months * If no org is found just return undefined so a 404 will be shown * Remove mock data * Fill in missing months with 0s --------- Co-authored-by: James Ritchie <[email protected]>
1 parent 2843240 commit df24cd5

File tree

4 files changed

+614
-8
lines changed

4 files changed

+614
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { PrismaClient, prisma } from "~/db.server";
2+
3+
export class OrgUsagePresenter {
4+
#prismaClient: PrismaClient;
5+
6+
constructor(prismaClient: PrismaClient = prisma) {
7+
this.#prismaClient = prismaClient;
8+
}
9+
10+
public async call({ userId, slug }: { userId: string; slug: string }) {
11+
const organization = await this.#prismaClient.organization.findFirst({
12+
where: {
13+
slug,
14+
members: {
15+
some: {
16+
userId,
17+
},
18+
},
19+
},
20+
});
21+
22+
if (!organization) {
23+
return;
24+
}
25+
26+
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
27+
const startOfLastMonth = new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1); // this works for January as well
28+
29+
// Get count of runs since the start of the current month
30+
const runsCount = await this.#prismaClient.jobRun.count({
31+
where: {
32+
organizationId: organization.id,
33+
createdAt: {
34+
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
35+
},
36+
},
37+
});
38+
39+
// Get the count of runs for last month
40+
const runsCountLastMonth = await this.#prismaClient.jobRun.count({
41+
where: {
42+
organizationId: organization.id,
43+
createdAt: {
44+
gte: startOfLastMonth,
45+
lt: startOfMonth,
46+
},
47+
},
48+
});
49+
50+
// Get the count of the runs for the last 6 months, by month. So for example we want the data shape to be:
51+
// [
52+
// { month: "2021-01", count: 10 },
53+
// { month: "2021-02", count: 20 },
54+
// { month: "2021-03", count: 30 },
55+
// { month: "2021-04", count: 40 },
56+
// { month: "2021-05", count: 50 },
57+
// { month: "2021-06", count: 60 },
58+
// ]
59+
// This will be used to generate the chart on the usage page
60+
// Use prisma queryRaw for this since prisma doesn't support grouping by month
61+
const chartDataRaw = await this.#prismaClient.$queryRaw<
62+
{
63+
month: string;
64+
count: number;
65+
}[]
66+
>`SELECT TO_CHAR("createdAt", 'YYYY-MM') as month, COUNT(*) as count FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '6 months' GROUP BY month ORDER BY month ASC`;
67+
68+
const chartData = chartDataRaw.map((obj) => ({
69+
name: obj.month,
70+
total: Number(obj.count), // Convert BigInt to Number
71+
}));
72+
73+
const totalJobs = await this.#prismaClient.job.count({
74+
where: {
75+
organizationId: organization.id,
76+
internal: false,
77+
},
78+
});
79+
80+
const totalJobsLastMonth = await this.#prismaClient.job.count({
81+
where: {
82+
organizationId: organization.id,
83+
createdAt: {
84+
lt: startOfMonth,
85+
},
86+
deletedAt: null,
87+
internal: false,
88+
},
89+
});
90+
91+
const totalIntegrations = await this.#prismaClient.integration.count({
92+
where: {
93+
organizationId: organization.id,
94+
},
95+
});
96+
97+
const totalIntegrationsLastMonth = await this.#prismaClient.integration.count({
98+
where: {
99+
organizationId: organization.id,
100+
createdAt: {
101+
lt: startOfMonth,
102+
},
103+
},
104+
});
105+
106+
const totalMembers = await this.#prismaClient.orgMember.count({
107+
where: {
108+
organizationId: organization.id,
109+
},
110+
});
111+
112+
const jobs = await this.#prismaClient.job.findMany({
113+
where: {
114+
organizationId: organization.id,
115+
deletedAt: null,
116+
internal: false,
117+
},
118+
select: {
119+
id: true,
120+
slug: true,
121+
_count: {
122+
select: {
123+
runs: {
124+
where: {
125+
createdAt: {
126+
gte: startOfMonth,
127+
},
128+
},
129+
},
130+
},
131+
},
132+
project: {
133+
select: {
134+
id: true,
135+
name: true,
136+
slug: true,
137+
},
138+
},
139+
},
140+
});
141+
142+
return {
143+
id: organization.id,
144+
runsCount,
145+
runsCountLastMonth,
146+
chartData: fillInMissingMonthlyData(chartData, 6),
147+
totalJobs,
148+
totalJobsLastMonth,
149+
totalIntegrations,
150+
totalIntegrationsLastMonth,
151+
totalMembers,
152+
jobs,
153+
};
154+
}
155+
}
156+
157+
// This will fill in missing chart data with zeros
158+
// So for example, if data is [{ name: "2021-01", total: 10 }, { name: "2021-03", total: 30 }] and the totalNumberOfMonths is 6
159+
// And the current month is "2021-04", then this function will return:
160+
// [{ name: "2020-11", total: 0 }, { name: "2020-12", total: 0 }, { name: "2021-01", total: 10 }, { name: "2021-02", total: 0 }, { name: "2021-03", total: 30 }, { name: "2021-04", total: 0 }]
161+
function fillInMissingMonthlyData(
162+
data: Array<{ name: string; total: number }>,
163+
totalNumberOfMonths: number
164+
): Array<{ name: string; total: number }> {
165+
const currentMonth = new Date().toISOString().slice(0, 7);
166+
167+
const startMonth = new Date(
168+
new Date(currentMonth).getFullYear(),
169+
new Date(currentMonth).getMonth() - totalNumberOfMonths,
170+
1
171+
)
172+
.toISOString()
173+
.slice(0, 7);
174+
175+
const months = getMonthsBetween(startMonth, currentMonth);
176+
177+
let completeData = months.map((month) => {
178+
let foundData = data.find((d) => d.name === month);
179+
return foundData ? { ...foundData } : { name: month, total: 0 };
180+
});
181+
182+
return completeData;
183+
}
184+
185+
function getMonthsBetween(startMonth: string, endMonth: string): string[] {
186+
const startDate = new Date(startMonth);
187+
const endDate = new Date(endMonth);
188+
189+
const months = [];
190+
let currentDate = startDate;
191+
192+
while (currentDate <= endDate) {
193+
months.push(currentDate.toISOString().slice(0, 7));
194+
currentDate = new Date(currentDate.setMonth(currentDate.getMonth() + 1));
195+
}
196+
197+
months.push(endMonth);
198+
199+
return months;
200+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.billing/route.tsx

+168-7
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,178 @@
1-
import { ComingSoon } from "~/components/ComingSoon";
2-
import { PageContainer, PageBody } from "~/components/layout/AppLayout";
1+
import { ArrowRightIcon } from "@heroicons/react/20/solid";
2+
import {
3+
ForwardIcon,
4+
SquaresPlusIcon,
5+
UsersIcon,
6+
WrenchScrewdriverIcon,
7+
} from "@heroicons/react/24/solid";
8+
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
9+
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
10+
import { Header2 } from "~/components/primitives/Headers";
11+
import { Paragraph } from "~/components/primitives/Paragraph";
12+
import { TextLink } from "~/components/primitives/TextLink";
13+
import { useOrganization } from "~/hooks/useOrganizations";
14+
import { OrganizationParamsSchema, jobPath, organizationTeamPath } from "~/utils/pathBuilder";
315
import { OrgAdminHeader } from "../_app.orgs.$organizationSlug._index/OrgAdminHeader";
16+
import { Link } from "@remix-run/react/dist/components";
17+
import { LoaderArgs } from "@remix-run/server-runtime";
18+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
19+
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
20+
import { requireUserId } from "~/services/session.server";
21+
22+
export async function loader({ params, request }: LoaderArgs) {
23+
const userId = await requireUserId(request);
24+
const { organizationSlug } = OrganizationParamsSchema.parse(params);
25+
26+
const presenter = new OrgUsagePresenter();
27+
28+
const data = await presenter.call({ userId, slug: organizationSlug });
29+
30+
if (!data) {
31+
throw new Response(null, { status: 404 });
32+
}
33+
34+
return typedjson(data);
35+
}
36+
37+
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
38+
if (active && payload) {
39+
return (
40+
<div className="flex items-center gap-2 rounded border border-border bg-slate-900 px-4 py-2 text-sm text-dimmed">
41+
<p className="text-white">{label}:</p>
42+
<p className="text-white">{payload[0].value}</p>
43+
</div>
44+
);
45+
}
46+
47+
return null;
48+
};
449

550
export default function Page() {
51+
const organization = useOrganization();
52+
const loaderData = useTypedLoaderData<typeof loader>();
53+
654
return (
755
<PageContainer>
856
<OrgAdminHeader />
957
<PageBody>
10-
<ComingSoon
11-
title="Usage & billing"
12-
description="View your usage, tier and billing information. During the beta we will display usage and start billing if you exceed your limits. But don't worry, we'll give you plenty of warning."
13-
icon="billing"
14-
/>
58+
<div className="mb-4 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
59+
<div className="rounded border border-border p-6">
60+
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
61+
<Header2>Total Runs this month</Header2>
62+
<ForwardIcon className="h-6 w-6 text-dimmed" />
63+
</div>
64+
<div>
65+
<p className="text-3xl font-bold">{loaderData.runsCount.toLocaleString()}</p>
66+
<Paragraph variant="small" className="text-dimmed">
67+
{loaderData.runsCountLastMonth} runs last month
68+
</Paragraph>
69+
</div>
70+
</div>
71+
<div className="rounded border border-border p-6">
72+
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
73+
<Header2>Total Jobs</Header2>
74+
<WrenchScrewdriverIcon className="h-6 w-6 text-dimmed" />
75+
</div>
76+
<div>
77+
<p className="text-3xl font-bold">{loaderData.totalJobs.toLocaleString()}</p>
78+
<Paragraph variant="small" className="text-dimmed">
79+
{loaderData.totalJobs === loaderData.totalJobsLastMonth ? (
80+
<>No change since last month</>
81+
) : loaderData.totalJobs > loaderData.totalJobsLastMonth ? (
82+
<>+{loaderData.totalJobs - loaderData.totalJobsLastMonth} since last month</>
83+
) : (
84+
<>-{loaderData.totalJobsLastMonth - loaderData.totalJobs} since last month</>
85+
)}
86+
</Paragraph>
87+
</div>
88+
</div>
89+
<div className="rounded border border-border p-6">
90+
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
91+
<Header2>Total Integrations</Header2>
92+
<SquaresPlusIcon className="h-6 w-6 text-dimmed" />
93+
</div>
94+
<div>
95+
<p className="text-3xl font-bold">{loaderData.totalIntegrations.toLocaleString()}</p>
96+
<Paragraph variant="small" className="text-dimmed">
97+
{loaderData.totalIntegrations === loaderData.totalIntegrationsLastMonth ? (
98+
<>No change since last month</>
99+
) : loaderData.totalIntegrations > loaderData.totalIntegrationsLastMonth ? (
100+
<>
101+
+{loaderData.totalIntegrations - loaderData.totalIntegrationsLastMonth} since
102+
last month
103+
</>
104+
) : (
105+
<>
106+
-{loaderData.totalIntegrationsLastMonth - loaderData.totalIntegrations} since
107+
last month
108+
</>
109+
)}
110+
</Paragraph>
111+
</div>
112+
</div>
113+
<div className="rounded border border-border p-6">
114+
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
115+
<Header2>Team members</Header2>
116+
<UsersIcon className="h-6 w-6 text-dimmed" />
117+
</div>
118+
<div>
119+
<p className="text-3xl font-bold">{loaderData.totalMembers.toLocaleString()}</p>
120+
<TextLink
121+
to={organizationTeamPath(organization)}
122+
className="group text-sm text-dimmed hover:text-bright"
123+
>
124+
Manage
125+
<ArrowRightIcon className="-mb-0.5 ml-0.5 h-4 w-4 text-dimmed transition group-hover:translate-x-1 group-hover:text-bright" />
126+
</TextLink>
127+
</div>
128+
</div>
129+
</div>
130+
<div className="flex max-h-[500px] gap-x-4">
131+
<div className="w-1/2 rounded border border-border py-6 pr-2">
132+
<Header2 className="mb-8 pl-6">Job Runs per month</Header2>
133+
<ResponsiveContainer width="100%" height={400}>
134+
<BarChart data={loaderData.chartData}>
135+
<XAxis
136+
dataKey="name"
137+
stroke="#888888"
138+
fontSize={12}
139+
tickLine={false}
140+
axisLine={false}
141+
/>
142+
<YAxis
143+
stroke="#888888"
144+
fontSize={12}
145+
tickLine={false}
146+
axisLine={false}
147+
tickFormatter={(value) => `${value}`}
148+
/>
149+
<Tooltip cursor={{ fill: "rgba(255,255,255,0.05)" }} content={<CustomTooltip />} />
150+
<Bar dataKey="total" fill="#DB2777" radius={[4, 4, 0, 0]} />
151+
</BarChart>
152+
</ResponsiveContainer>
153+
</div>
154+
<div className="w-1/2 overflow-y-auto rounded border border-border px-3 py-6">
155+
<div className="mb-2 flex items-baseline justify-between border-b border-border px-3 pb-4">
156+
<Header2 className="">Jobs</Header2>
157+
<Header2 className="">Runs</Header2>
158+
</div>
159+
<div className="space-y-2">
160+
{loaderData.jobs.map((job) => (
161+
<Link
162+
to={jobPath(organization, job.project, job)}
163+
className="flex items-center rounded px-4 py-3 transition hover:bg-slate-850"
164+
key={job.id}
165+
>
166+
<div className="space-y-1">
167+
<p className="text-sm font-medium leading-none">{job.slug}</p>
168+
<p className="text-sm text-muted-foreground">Project: {job.project.name}</p>
169+
</div>
170+
<div className="ml-auto font-medium">{job._count.runs.toLocaleString()}</div>
171+
</Link>
172+
))}
173+
</div>
174+
</div>
175+
</div>
15176
</PageBody>
16177
</PageContainer>
17178
);

0 commit comments

Comments
 (0)