Improvements to the usage page
This commit is contained in:
@@ -35,7 +35,6 @@ export function ConcurrentRunsChart({
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Header3 className="mb-4">Concurrent Runs</Header3>
|
||||
{!hasConcurrencyData && (
|
||||
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
No concurrent Runs to show
|
||||
@@ -52,20 +51,33 @@ export function ConcurrentRunsChart({
|
||||
}}
|
||||
className="-ml-8"
|
||||
>
|
||||
<XAxis stroke="#94A3B8" fontSize={12} tickLine={false} axisLine={false} dataKey="name" />
|
||||
<XAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dataKey="name"
|
||||
label={"Last 30 days"}
|
||||
/>
|
||||
<YAxis stroke="#94A3B8" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip cursor={{ fill: "rgba(255,255,255,0.05)" }} contentStyle={tooltipStyle} />
|
||||
{concurrentRunsLimit && (
|
||||
<ReferenceLine
|
||||
y={concurrentRunsLimit}
|
||||
label={<ReferenceLineLabel y={concurrentRunsLimit} />}
|
||||
stroke="#F43F5E"
|
||||
color="#fff"
|
||||
color="#F43F5E"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={5}
|
||||
ifOverflow="extendDomain"
|
||||
className="text-xs"
|
||||
/>
|
||||
>
|
||||
<Label
|
||||
value="Concurrent Runs limit"
|
||||
offset={5}
|
||||
position="insideBottomLeft"
|
||||
fill="#F43F5E"
|
||||
/>
|
||||
</ReferenceLine>
|
||||
)}
|
||||
<Line
|
||||
dataKey="maxConcurrentRuns"
|
||||
@@ -79,13 +91,3 @@ export function ConcurrentRunsChart({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceLineLabel({ y }: { y: number }) {
|
||||
return (
|
||||
<text x={0} y={y} fill={"#F43F5E"}>
|
||||
<tspan x={"8em"} dy={"0.3em"}>
|
||||
Concurrency limit
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,11 +28,6 @@ export class OrgUsagePresenter {
|
||||
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
|
||||
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
|
||||
endOfMonth.setDate(endOfMonth.getDate() - 1);
|
||||
|
||||
// Get count of runs since the start of the current month
|
||||
const runsCount = await this.#prismaClient.jobRun.count({
|
||||
where: {
|
||||
@@ -44,18 +39,6 @@ export class OrgUsagePresenter {
|
||||
},
|
||||
});
|
||||
|
||||
// Get the count of runs for last month
|
||||
const runsCountLastMonth = await this.#prismaClient.jobRun.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
createdAt: {
|
||||
gte: startOfLastMonth,
|
||||
lt: startOfMonth,
|
||||
},
|
||||
internal: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 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 },
|
||||
@@ -82,45 +65,6 @@ export class OrgUsagePresenter {
|
||||
|
||||
const monthlyRunsDataDisplay = fillInMissingRunMonthlyData(monthlyRunsData, 6);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// Max concurrency each day over past 30 days
|
||||
const concurrencyChartRawData = await this.#prismaClient.$queryRaw<
|
||||
{ day: Date; max_concurrent_runs: BigInt }[]
|
||||
@@ -173,6 +117,8 @@ export class OrgUsagePresenter {
|
||||
concurrencyChartRawData
|
||||
);
|
||||
|
||||
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
|
||||
endOfMonth.setDate(endOfMonth.getDate() - 1);
|
||||
const projectedRunsCount = Math.round(
|
||||
runsCount / (new Date().getDate() / endOfMonth.getDate())
|
||||
);
|
||||
@@ -202,16 +148,10 @@ export class OrgUsagePresenter {
|
||||
id: organization.id,
|
||||
runsCount,
|
||||
projectedRunsCount,
|
||||
runsCountLastMonth,
|
||||
monthlyRunsData: monthlyRunsDataDisplay,
|
||||
hasMonthlyRunData,
|
||||
concurrencyData: concurrencyChartRawDataFilledIn,
|
||||
hasConcurrencyData,
|
||||
totalJobs,
|
||||
totalJobsLastMonth,
|
||||
totalIntegrations,
|
||||
totalIntegrationsLastMonth,
|
||||
totalMembers,
|
||||
runCostEstimation,
|
||||
projectedRunCostEstimation,
|
||||
};
|
||||
@@ -260,15 +200,21 @@ function fillInMissingConcurrencyDays(
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
|
||||
let formattedDate = `${date.getDate()}`;
|
||||
if (date.getDate() === 1) {
|
||||
formattedDate = dateFormatter.format(date);
|
||||
}
|
||||
|
||||
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
|
||||
if (!foundData) {
|
||||
outputData.push({
|
||||
name: dateFormatter.format(date),
|
||||
name: formattedDate,
|
||||
maxConcurrentRuns: 0,
|
||||
});
|
||||
} else {
|
||||
outputData.push({
|
||||
name: dateFormatter.format(date),
|
||||
name: formattedDate,
|
||||
maxConcurrentRuns: Number(foundData.max_concurrent_runs),
|
||||
});
|
||||
}
|
||||
@@ -302,11 +248,3 @@ function getMonthsBetween(startMonth: string, endMonth: string): string[] {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getLastSecondOfMonth(endMonth: string) {
|
||||
const [year, month] = endMonth.split("-").map(Number);
|
||||
const nextMonthFirstDay = new Date(year, month, 1);
|
||||
nextMonthFirstDay.setDate(0);
|
||||
nextMonthFirstDay.setHours(23, 59, 59);
|
||||
return nextMonthFirstDay;
|
||||
}
|
||||
|
||||
@@ -188,78 +188,6 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="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">
|
||||
<Header3>Total Runs this month</Header3>
|
||||
<NamedIcon className="h-6 w-6 text-dimmed/50" name={"runs"} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-medium">{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">
|
||||
<Header3>Total Jobs</Header3>
|
||||
<WrenchScrewdriverIcon className="h-6 w-6 text-dimmed/50" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-medium">{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">
|
||||
<Header3>Total Integrations</Header3>
|
||||
<SquaresPlusIcon className="h-6 w-6 text-dimmed/50" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-medium">{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">
|
||||
<Header3>Team members</Header3>
|
||||
<UsersIcon className="h-6 w-6 text-dimmed/50" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-medium">{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,12 +29,6 @@ import { OrganizationParamsSchema, plansPath, usagePath } from "~/utils/pathBuil
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { ActiveSubscription } from "@trigger.dev/billing";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
return typedjson({});
|
||||
}
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Usage & Billing" />,
|
||||
};
|
||||
@@ -74,16 +68,16 @@ export default function Page() {
|
||||
<PageButtons>
|
||||
{isManagedCloud && (
|
||||
<>
|
||||
{/* {stripePortalLink && (
|
||||
{currentPlan?.subscription?.isPaying && (
|
||||
<>
|
||||
<LinkButton to={stripePortalLink} variant="secondary/small">
|
||||
{/* <LinkButton to={stripePortalLink} variant="secondary/small">
|
||||
Invoices
|
||||
</LinkButton>
|
||||
<LinkButton to={stripePortalLink} variant="secondary/small">
|
||||
Manage card details
|
||||
</LinkButton>
|
||||
</LinkButton> */}
|
||||
</>
|
||||
)} */}
|
||||
)}
|
||||
<LinkButton
|
||||
to={plansPath(organization)}
|
||||
variant="primary/small"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SetPlanBodySchema } from "@trigger.dev/billing";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
OrganizationParamsSchema,
|
||||
organizationBillingPath,
|
||||
subscribedPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: SetPlanBodySchema });
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await prisma.organization.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
submission.error.message = "Invalid organization";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const billingPresenter = new BillingService(true);
|
||||
const result = await billingPresenter.setPlan(org.id, submission.value);
|
||||
if (result === undefined) {
|
||||
submission.error.message = "No billing client";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.message = result.error;
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case "create_subscription_flow_start": {
|
||||
return redirect(result.checkoutUrl);
|
||||
}
|
||||
case "canceled_subscription": {
|
||||
return redirectWithSuccessMessage(
|
||||
organizationBillingPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Your subscription has been canceled."
|
||||
);
|
||||
}
|
||||
case "updated_subscription": {
|
||||
return redirect(subscribedPath({ slug: organizationSlug }), request);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Error setting plan", { error: e });
|
||||
submission.error.message = e instanceof Error ? e.message : JSON.stringify(e);
|
||||
return json(submission);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user