chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
import { InformationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { Await, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { redirect, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { URL } from "url";
|
||||
import { UsageBar } from "~/components/billing/UsageBar";
|
||||
import { getUsageBarBillingLimitDollars } from "~/components/billing/billingAlertsFormat";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Card } from "~/components/primitives/charts/Card";
|
||||
import type { ChartConfig } from "~/components/primitives/charts/Chart";
|
||||
import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { UsagePresenter, type UsageSeriesData } from "~/presenters/v3/UsagePresenter.server";
|
||||
import { getPromoCredits } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
|
||||
import { useBillingLimit } from "~/hooks/useOrganizations";
|
||||
import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Usage | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
if (!isManagedCloud) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
//past 6 months, 1st day of the month
|
||||
const months = Array.from({ length: 6 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setUTCDate(1);
|
||||
date.setUTCMonth(date.getUTCMonth() - i);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
return date;
|
||||
});
|
||||
|
||||
const search = new URL(request.url).searchParams;
|
||||
const searchMonth = search.get("month");
|
||||
const startDate = searchMonth ? new Date(decodeURIComponent(searchMonth)) : months[0];
|
||||
startDate.setUTCDate(1);
|
||||
startDate.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const presenter = new UsagePresenter();
|
||||
const { usage, tasks } = await presenter.call({
|
||||
organizationId: organization.id,
|
||||
startDate,
|
||||
});
|
||||
|
||||
// Credit-grant balance (promo now, other grant types later). Cheap + cached +
|
||||
// fails to null, and applies to any org with grants — not gated on plan tier.
|
||||
const promoCredits = await getPromoCredits(organization.id);
|
||||
|
||||
return typeddefer({
|
||||
usage,
|
||||
tasks,
|
||||
months,
|
||||
isCurrentMonth: startDate.toISOString() === months[0].toISOString(),
|
||||
promoCredits,
|
||||
});
|
||||
}
|
||||
|
||||
const creditExpiryFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "utc",
|
||||
});
|
||||
|
||||
const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "utc",
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
const { usage, tasks, months, isCurrentMonth, promoCredits } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const currentPlan = useCurrentPlan();
|
||||
const billingLimit = useBillingLimit();
|
||||
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0;
|
||||
const billingLimitDollars = isCurrentMonth
|
||||
? getUsageBarBillingLimitDollars(billingLimit, planLimitCents)
|
||||
: undefined;
|
||||
const { value, replace } = useSearchParams();
|
||||
|
||||
const month = value("month") ?? months[0].toISOString();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Usage" />
|
||||
</NavBar>
|
||||
<PageBody scrollable={true} className="p-0">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Select
|
||||
name="month"
|
||||
placeholder="Select a month"
|
||||
className="m-3"
|
||||
defaultValue={month}
|
||||
items={months.map((date) => ({
|
||||
label: monthDateFormatter.format(date),
|
||||
value: date.toISOString(),
|
||||
}))}
|
||||
text={(value) => monthDateFormatter.format(new Date(value))}
|
||||
setValue={(value) => {
|
||||
replace({ month: value });
|
||||
}}
|
||||
dropdownIcon
|
||||
variant="tertiary/small"
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((month) => (
|
||||
<SelectItem key={month.value} value={month.value}>
|
||||
{month.label}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
{promoCredits && (
|
||||
<div className="flex flex-col gap-1 border-t border-grid-dimmed p-3">
|
||||
<div className="flex items-end gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header2 className="whitespace-nowrap">Promo credits</Header2>
|
||||
<p className="whitespace-nowrap text-3xl font-medium text-text-bright">
|
||||
{formatCurrency(promoCredits.remainingCents / 100, false)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col gap-1 pb-1">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-charcoal-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500"
|
||||
style={{
|
||||
width: `${
|
||||
promoCredits.grantedCents > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
(promoCredits.remainingCents / promoCredits.grantedCents) * 100
|
||||
)
|
||||
)
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{formatCurrency(promoCredits.remainingCents / 100, false)} of{" "}
|
||||
{formatCurrency(promoCredits.grantedCents / 100, false)} remaining
|
||||
{promoCredits.expiresAt
|
||||
? ` · expires ${creditExpiryFormatter.format(new Date(promoCredits.expiresAt))}`
|
||||
: ""}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2 border-t border-grid-dimmed p-3">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await
|
||||
resolve={usage}
|
||||
errorElement={
|
||||
<div className="flex min-h-40 items-center justify-center">
|
||||
<Paragraph variant="small">Failed to load graph.</Paragraph>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(usage) => (
|
||||
<div className="flex items-end gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header2 className="whitespace-nowrap">
|
||||
{isCurrentMonth ? "Month-to-date" : "Usage"}
|
||||
</Header2>
|
||||
<p className="whitespace-nowrap text-3xl font-medium text-text-bright">
|
||||
{formatCurrency(usage.overall.current, false)}
|
||||
</p>
|
||||
</div>
|
||||
<UsageBar
|
||||
current={usage.overall.current}
|
||||
isPaying={currentPlan?.v3Subscription?.isPaying ?? false}
|
||||
tierLimit={isCurrentMonth ? planLimitCents / 100 : undefined}
|
||||
billingLimit={billingLimitDollars}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<Card>
|
||||
<Card.Header>Usage by day</Card.Header>
|
||||
<Card.Content>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-40 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await
|
||||
resolve={usage}
|
||||
errorElement={
|
||||
<div className="flex min-h-40 items-center justify-center">
|
||||
<Paragraph variant="small">Failed to load graph.</Paragraph>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(u) => <UsageChart data={u.timeSeries} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
<div>
|
||||
<Header2 spacing className="pl-3">
|
||||
Tasks
|
||||
</Header2>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await
|
||||
resolve={tasks}
|
||||
errorElement={
|
||||
<div className="flex min-h-40 items-center justify-center">
|
||||
<Paragraph variant="small">Failed to load.</Paragraph>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(tasks) => {
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Runs</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Average duration</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Average cost</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Total duration</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Total cost</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Paragraph variant="base/bright">
|
||||
No runs for this period
|
||||
</Paragraph>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TableRow key={task.taskIdentifier}>
|
||||
<TableCell>{task.taskIdentifier}</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums">
|
||||
{formatNumber(task.runCount)}
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
{formatDurationMilliseconds(task.averageDuration, {
|
||||
style: "short",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums">
|
||||
{formatCurrencyAccurate(task.averageCost)}
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums">
|
||||
{formatDurationMilliseconds(task.totalDuration, {
|
||||
style: "short",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums">
|
||||
{formatCurrencyAccurate(task.totalCost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
variant="minimal"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
Dev environment runs are excluded from the usage data above, since they do
|
||||
not have an associated compute cost.
|
||||
</InfoPanel>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
dollars: {
|
||||
label: "Usage $",
|
||||
color: "#7655fd",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const tooltipDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const xAxisTickFormatter = (value: string) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}`;
|
||||
};
|
||||
|
||||
const tooltipLabelFormatter = (label: string) => {
|
||||
if (!label) return "";
|
||||
return tooltipDateFormatter.format(new Date(label));
|
||||
};
|
||||
|
||||
function UsageChart({ data }: { data: UsageSeriesData }) {
|
||||
const maxDollar = Math.max(...data.map((d) => d.dollars));
|
||||
const decimalPlaces = maxDollar < 1 ? 4 : 2;
|
||||
|
||||
const yAxisTickFormatter = useMemo(
|
||||
() => (value: number) => `$${value.toFixed(decimalPlaces)}`,
|
||||
[decimalPlaces]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-80">
|
||||
<Chart.Root
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey="date"
|
||||
showLegend={false}
|
||||
enableZoom={false}
|
||||
fillContainer
|
||||
>
|
||||
<Chart.Bar
|
||||
xAxisProps={{ tickFormatter: xAxisTickFormatter }}
|
||||
yAxisProps={{ tickFormatter: yAxisTickFormatter, allowDecimals: true }}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user