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(); 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 (
{promoCredits && (
Promo credits

{formatCurrency(promoCredits.remainingCents / 100, false)}

0 ? Math.min( 100, Math.max( 0, (promoCredits.remainingCents / promoCredits.grantedCents) * 100 ) ) : 0 }%`, }} />
{formatCurrency(promoCredits.remainingCents / 100, false)} of{" "} {formatCurrency(promoCredits.grantedCents / 100, false)} remaining {promoCredits.expiresAt ? ` · expires ${creditExpiryFormatter.format(new Date(promoCredits.expiresAt))}` : ""}
)}
}> Failed to load graph.
} > {(usage) => (
{isCurrentMonth ? "Month-to-date" : "Usage"}

{formatCurrency(usage.overall.current, false)}

)}
Usage by day
} > Failed to load graph.
} > {(u) => }
Tasks }> Failed to load.
} > {(tasks) => { return ( <> Task Runs Average duration Average cost Total duration Total cost {tasks.length === 0 ? (
No runs for this period
) : ( tasks.map((task) => ( {task.taskIdentifier} {formatNumber(task.runCount)} {formatDurationMilliseconds(task.averageDuration, { style: "short", })} {formatCurrencyAccurate(task.averageCost)} {formatDurationMilliseconds(task.totalDuration, { style: "short", })} {formatCurrencyAccurate(task.totalCost)} )) )}
Dev environment runs are excluded from the usage data above, since they do not have an associated compute cost. ); }}
); } 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 (
); }