Skip to content

feat: Basic usage dashboard to show run volume #501

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 6 commits into from
Sep 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions apps/webapp/app/presenters/OrgUsagePresenter.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { PrismaClient, prisma } from "~/db.server";

export class OrgUsagePresenter {
#prismaClient: PrismaClient;

constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}

public async call({ userId, slug }: { userId: string; slug: string }) {
const organization = await this.#prismaClient.organization.findFirst({
where: {
slug,
members: {
some: {
userId,
},
},
},
});

if (!organization) {
return;
}

const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
const startOfLastMonth = new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1); // this works for January as well

// Get count of runs since the start of the current month
const runsCount = await this.#prismaClient.jobRun.count({
where: {
organizationId: organization.id,
createdAt: {
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
},
},
});

// Get the count of runs for last month
const runsCountLastMonth = await this.#prismaClient.jobRun.count({
where: {
organizationId: organization.id,
createdAt: {
gte: startOfLastMonth,
lt: startOfMonth,
},
},
});

// Get the count of the runs for the last 6 months, by month. So for example we want the data shape to be:
// [
// { month: "2021-01", count: 10 },
// { month: "2021-02", count: 20 },
// { month: "2021-03", count: 30 },
// { month: "2021-04", count: 40 },
// { month: "2021-05", count: 50 },
// { month: "2021-06", count: 60 },
// ]
// This will be used to generate the chart on the usage page
// Use prisma queryRaw for this since prisma doesn't support grouping by month
const chartDataRaw = await this.#prismaClient.$queryRaw<
{
month: string;
count: number;
}[]
>`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`;

const chartData = chartDataRaw.map((obj) => ({
name: obj.month,
total: Number(obj.count), // Convert BigInt to Number
}));

const totalJobs = await this.#prismaClient.job.count({
where: {
organizationId: organization.id,
internal: false,
},
});

const totalJobsLastMonth = await this.#prismaClient.job.count({
where: {
organizationId: organization.id,
createdAt: {
lt: startOfMonth,
},
deletedAt: null,
internal: false,
},
});

const totalIntegrations = await this.#prismaClient.integration.count({
where: {
organizationId: organization.id,
},
});

const totalIntegrationsLastMonth = await this.#prismaClient.integration.count({
where: {
organizationId: organization.id,
createdAt: {
lt: startOfMonth,
},
},
});

const totalMembers = await this.#prismaClient.orgMember.count({
where: {
organizationId: organization.id,
},
});

const jobs = await this.#prismaClient.job.findMany({
where: {
organizationId: organization.id,
deletedAt: null,
internal: false,
},
select: {
id: true,
slug: true,
_count: {
select: {
runs: {
where: {
createdAt: {
gte: startOfMonth,
},
},
},
},
},
project: {
select: {
id: true,
name: true,
slug: true,
},
},
},
});

return {
id: organization.id,
runsCount,
runsCountLastMonth,
chartData: fillInMissingMonthlyData(chartData, 6),
totalJobs,
totalJobsLastMonth,
totalIntegrations,
totalIntegrationsLastMonth,
totalMembers,
jobs,
};
}
}

// This will fill in missing chart data with zeros
// So for example, if data is [{ name: "2021-01", total: 10 }, { name: "2021-03", total: 30 }] and the totalNumberOfMonths is 6
// And the current month is "2021-04", then this function will return:
// [{ 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 }]
function fillInMissingMonthlyData(
data: Array<{ name: string; total: number }>,
totalNumberOfMonths: number
): Array<{ name: string; total: number }> {
const currentMonth = new Date().toISOString().slice(0, 7);

const startMonth = new Date(
new Date(currentMonth).getFullYear(),
new Date(currentMonth).getMonth() - totalNumberOfMonths,
1
)
.toISOString()
.slice(0, 7);

const months = getMonthsBetween(startMonth, currentMonth);

let completeData = months.map((month) => {
let foundData = data.find((d) => d.name === month);
return foundData ? { ...foundData } : { name: month, total: 0 };
});

return completeData;
}

function getMonthsBetween(startMonth: string, endMonth: string): string[] {
const startDate = new Date(startMonth);
const endDate = new Date(endMonth);

const months = [];
let currentDate = startDate;

while (currentDate <= endDate) {
months.push(currentDate.toISOString().slice(0, 7));
currentDate = new Date(currentDate.setMonth(currentDate.getMonth() + 1));
}

months.push(endMonth);

return months;
}
175 changes: 168 additions & 7 deletions apps/webapp/app/routes/_app.orgs.$organizationSlug.billing/route.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,178 @@
import { ComingSoon } from "~/components/ComingSoon";
import { PageContainer, PageBody } from "~/components/layout/AppLayout";
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import {
ForwardIcon,
SquaresPlusIcon,
UsersIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/solid";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import { OrganizationParamsSchema, jobPath, organizationTeamPath } from "~/utils/pathBuilder";
import { OrgAdminHeader } from "../_app.orgs.$organizationSlug._index/OrgAdminHeader";
import { Link } from "@remix-run/react/dist/components";
import { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
import { requireUserId } from "~/services/session.server";

export async function loader({ params, request }: LoaderArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);

const presenter = new OrgUsagePresenter();

const data = await presenter.call({ userId, slug: organizationSlug });

if (!data) {
throw new Response(null, { status: 404 });
}

return typedjson(data);
}

const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (active && payload) {
return (
<div className="flex items-center gap-2 rounded border border-border bg-slate-900 px-4 py-2 text-sm text-dimmed">
<p className="text-white">{label}:</p>
<p className="text-white">{payload[0].value}</p>
</div>
);
}

return null;
};

export default function Page() {
const organization = useOrganization();
const loaderData = useTypedLoaderData<typeof loader>();

return (
<PageContainer>
<OrgAdminHeader />
<PageBody>
<ComingSoon
title="Usage & billing"
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."
icon="billing"
/>
<div className="mb-4 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Runs this month</Header2>
<ForwardIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.runsCount.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.runsCountLastMonth} runs last month
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Jobs</Header2>
<WrenchScrewdriverIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalJobs.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.totalJobs === loaderData.totalJobsLastMonth ? (
<>No change since last month</>
) : loaderData.totalJobs > loaderData.totalJobsLastMonth ? (
<>+{loaderData.totalJobs - loaderData.totalJobsLastMonth} since last month</>
) : (
<>-{loaderData.totalJobsLastMonth - loaderData.totalJobs} since last month</>
)}
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Integrations</Header2>
<SquaresPlusIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalIntegrations.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.totalIntegrations === loaderData.totalIntegrationsLastMonth ? (
<>No change since last month</>
) : loaderData.totalIntegrations > loaderData.totalIntegrationsLastMonth ? (
<>
+{loaderData.totalIntegrations - loaderData.totalIntegrationsLastMonth} since
last month
</>
) : (
<>
-{loaderData.totalIntegrationsLastMonth - loaderData.totalIntegrations} since
last month
</>
)}
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Team members</Header2>
<UsersIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalMembers.toLocaleString()}</p>
<TextLink
to={organizationTeamPath(organization)}
className="group text-sm text-dimmed hover:text-bright"
>
Manage
<ArrowRightIcon className="-mb-0.5 ml-0.5 h-4 w-4 text-dimmed transition group-hover:translate-x-1 group-hover:text-bright" />
</TextLink>
</div>
</div>
</div>
<div className="flex max-h-[500px] gap-x-4">
<div className="w-1/2 rounded border border-border py-6 pr-2">
<Header2 className="mb-8 pl-6">Job Runs per month</Header2>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={loaderData.chartData}>
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip cursor={{ fill: "rgba(255,255,255,0.05)" }} content={<CustomTooltip />} />
<Bar dataKey="total" fill="#DB2777" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="w-1/2 overflow-y-auto rounded border border-border px-3 py-6">
<div className="mb-2 flex items-baseline justify-between border-b border-border px-3 pb-4">
<Header2 className="">Jobs</Header2>
<Header2 className="">Runs</Header2>
</div>
<div className="space-y-2">
{loaderData.jobs.map((job) => (
<Link
to={jobPath(organization, job.project, job)}
className="flex items-center rounded px-4 py-3 transition hover:bg-slate-850"
key={job.id}
>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{job.slug}</p>
<p className="text-sm text-muted-foreground">Project: {job.project.name}</p>
</div>
<div className="ml-auto font-medium">{job._count.runs.toLocaleString()}</div>
</Link>
))}
</div>
</div>
</div>
</PageBody>
</PageContainer>
);
Expand Down
Loading