chore: import upstream snapshot with attribution
This commit is contained in:
+889
@@ -0,0 +1,889 @@
|
||||
import { CheckIcon, BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { IconCardsFilled, IconDiamondFilled, IconTallymark4 } from "@tabler/icons-react";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { Gauge } from "lucide-react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector";
|
||||
import { AnimatedNumber } from "~/components/primitives/AnimatedNumber";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
LimitsPresenter,
|
||||
type FeatureInfo,
|
||||
type LimitsResult,
|
||||
type QuotaInfo,
|
||||
type RateLimitInfo,
|
||||
} from "~/presenters/v3/LimitsPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import {
|
||||
concurrencyPath,
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
organizationBillingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Limits | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new LimitsPresenter();
|
||||
const [error, result] = await tryCatch(
|
||||
presenter.call({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
environmentApiKey: environment.apiKey,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Response(error.message, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Match the queues page pattern: pass a poll interval from the loader
|
||||
const autoReloadPollIntervalMs = 5000;
|
||||
|
||||
return typedjson({
|
||||
...result,
|
||||
autoReloadPollIntervalMs,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Auto-revalidate using the loader-provided interval and refresh on focus
|
||||
useAutoRevalidate({ interval: data.autoReloadPollIntervalMs, onFocus: true });
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Limits" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Plan</Property.Label>
|
||||
<Property.Value>{data.planName ?? "No plan"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Organization ID</Property.Label>
|
||||
<Property.Value>{data.organizationId}</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/limits")}>
|
||||
Limits docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<div className="mx-auto mt-10 max-w-2xl p-4">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Current Plan Section */}
|
||||
{data.planName && (
|
||||
<CurrentPlanSection
|
||||
planName={data.planName}
|
||||
isOnTopPlan={data.isOnTopPlan}
|
||||
billingPath={organizationBillingPath(organization)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Concurrency Section */}
|
||||
<ConcurrencySection
|
||||
concurrencyPath={concurrencyPath(organization, project, environment)}
|
||||
/>
|
||||
|
||||
{/* Rate Limits Section */}
|
||||
<RateLimitsSection
|
||||
rateLimits={data.rateLimits}
|
||||
isOnTopPlan={data.isOnTopPlan}
|
||||
billingPath={organizationBillingPath(organization)}
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
/>
|
||||
|
||||
{/* Quotas Section */}
|
||||
<QuotasSection
|
||||
quotas={data.quotas}
|
||||
isOnTopPlan={data.isOnTopPlan}
|
||||
billingPath={organizationBillingPath(organization)}
|
||||
/>
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesSection
|
||||
features={data.features}
|
||||
isOnTopPlan={data.isOnTopPlan}
|
||||
billingPath={organizationBillingPath(organization)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentPlanSection({
|
||||
planName,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
}: {
|
||||
planName: string;
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
}) {
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1">
|
||||
<IconCardsFilled className="size-5 text-amber-400" />
|
||||
Current plan
|
||||
</Header2>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="w-full text-sm">{planName}</TableCell>
|
||||
<TableCell alignment="right">
|
||||
{isOnTopPlan ? (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Request Enterprise</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
) : showSelfServe ? (
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1.5">
|
||||
<ConcurrencyIcon className="size-5 text-orange-500" />
|
||||
Concurrency limits
|
||||
<InfoIconTooltip
|
||||
content="Concurrency limits control how many runs execute at the same time."
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Header2>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="w-full text-sm">Concurrency</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<LinkButton to={concurrencyPath} variant="secondary/small">
|
||||
Manage concurrency
|
||||
</LinkButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitsSection({
|
||||
rateLimits,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
}: {
|
||||
rateLimits: LimitsResult["rateLimits"];
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
organization: ReturnType<typeof useOrganization>;
|
||||
project: ReturnType<typeof useProject>;
|
||||
environment: ReturnType<typeof useEnvironment>;
|
||||
}) {
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2 className="flex items-center gap-1.5">
|
||||
<Gauge className="size-5 text-indigo-500" />
|
||||
Rate limits
|
||||
<InfoIconTooltip
|
||||
content="Rate limits control how many API requests can be made within a time window. They are tracked per API key."
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Header2>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Rate limit</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">
|
||||
<span className="flex items-center justify-end gap-x-1">
|
||||
Type
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
<div className="flex max-w-xs flex-col gap-3 py-1">
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<RateLimitTypeBadge type="tokenBucket" />
|
||||
<span className="pl-0.5 text-text-bright">
|
||||
Requests consume tokens from a bucket that refills over time. When empty,
|
||||
requests are rate limited.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<RateLimitTypeBadge type="fixedWindow" />
|
||||
<span className="pl-0.5 text-text-bright">
|
||||
Allows a set number of requests per time window. The window resets at
|
||||
fixed intervals.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<RateLimitTypeBadge type="slidingWindow" />
|
||||
<span className="pl-0.5 text-text-bright">
|
||||
Allows a set number of requests per rolling time window. The limit is
|
||||
continuously evaluated.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Configuration</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">
|
||||
<span className="flex items-center justify-end gap-x-1">
|
||||
Available
|
||||
<InfoIconTooltip
|
||||
content="Current available tokens for this environment's API key"
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<RateLimitRow
|
||||
info={rateLimits.api}
|
||||
isOnTopPlan={isOnTopPlan}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
<RateLimitRow
|
||||
info={rateLimits.batch}
|
||||
isOnTopPlan={isOnTopPlan}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitRow({
|
||||
info,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
showSelfServe,
|
||||
}: {
|
||||
info: RateLimitInfo;
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
showSelfServe: boolean;
|
||||
}) {
|
||||
const maxTokens = info.config.type === "tokenBucket" ? info.config.maxTokens : info.config.tokens;
|
||||
const percentage =
|
||||
info.currentTokens !== null && maxTokens > 0 ? info.currentTokens / maxTokens : null;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
{info.name}
|
||||
<InfoIconTooltip content={info.description} disableHoverableContent />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<div className="flex justify-end">
|
||||
<RateLimitTypeBadge config={info.config} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<RateLimitConfigDisplay config={info.config} />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
{info.currentTokens !== null ? (
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
getUsageColorClass(percentage, "remaining")
|
||||
)}
|
||||
>
|
||||
<AnimatedNumber value={info.currentTokens} />
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-text-dimmed">
|
||||
of {formatNumber(maxTokens)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{showSelfServe ? (
|
||||
<TableCell alignment="right">
|
||||
<div className="flex justify-end">
|
||||
{info.name === "Batch rate limit" ? (
|
||||
isOnTopPlan ? (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
) : (
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
)
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitTypeBadge({
|
||||
config,
|
||||
type,
|
||||
}: {
|
||||
config?: RateLimitInfo["config"];
|
||||
type?: "tokenBucket" | "fixedWindow" | "slidingWindow";
|
||||
}) {
|
||||
const rateLimitType = type ?? config?.type;
|
||||
switch (rateLimitType) {
|
||||
case "tokenBucket":
|
||||
return (
|
||||
<Badge variant="small" className="w-fit bg-blue-500/20 text-blue-400">
|
||||
Token bucket
|
||||
</Badge>
|
||||
);
|
||||
case "fixedWindow":
|
||||
return (
|
||||
<Badge variant="small" className="w-fit bg-purple-500/20 text-purple-400">
|
||||
Fixed window
|
||||
</Badge>
|
||||
);
|
||||
case "slidingWindow":
|
||||
return (
|
||||
<Badge variant="small" className="w-fit bg-green-500/20 text-green-400">
|
||||
Sliding window
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) {
|
||||
if (config.type === "tokenBucket") {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-0.5 text-xs">
|
||||
<span className="tabular-nums">
|
||||
<span className="text-text-dimmed">Max tokens:</span>{" "}
|
||||
<span className="font-medium text-text-bright">{formatNumber(config.maxTokens)}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
<span className="text-text-dimmed">Refill:</span>{" "}
|
||||
<span className="font-medium text-text-bright">
|
||||
{formatNumber(config.refillRate)}/{config.interval}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === "fixedWindow" || config.type === "slidingWindow") {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-0.5 text-xs">
|
||||
<span className="tabular-nums">
|
||||
<span className="text-text-dimmed">Tokens:</span>{" "}
|
||||
<span className="font-medium text-text-bright">{formatNumber(config.tokens)}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
<span className="text-text-dimmed">Window:</span>{" "}
|
||||
<span className="font-medium text-text-bright">{config.window}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-text-dimmed">–</span>;
|
||||
}
|
||||
|
||||
function QuotasSection({
|
||||
quotas,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
}: {
|
||||
quotas: LimitsResult["quotas"];
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
}) {
|
||||
// Collect all quotas that should be shown
|
||||
const quotaRows: QuotaInfo[] = [];
|
||||
|
||||
// Always show projects
|
||||
quotaRows.push(quotas.projects);
|
||||
|
||||
// Add plan-based quotas if they exist
|
||||
if (quotas.teamMembers) quotaRows.push(quotas.teamMembers);
|
||||
if (quotas.schedules) quotaRows.push(quotas.schedules);
|
||||
if (quotas.alerts) quotaRows.push(quotas.alerts);
|
||||
if (quotas.branches) quotaRows.push(quotas.branches);
|
||||
if (quotas.realtimeConnections) quotaRows.push(quotas.realtimeConnections);
|
||||
if (quotas.logRetentionDays) quotaRows.push(quotas.logRetentionDays);
|
||||
|
||||
// Include batch processing concurrency
|
||||
quotaRows.push(quotas.batchProcessingConcurrency);
|
||||
|
||||
// Add queue size quota if set
|
||||
if (quotas.queueSize.limit !== null) quotaRows.push(quotas.queueSize);
|
||||
|
||||
// Metric & query quotas
|
||||
if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards);
|
||||
if (quotas.metricWidgetsPerDashboard) quotaRows.push(quotas.metricWidgetsPerDashboard);
|
||||
if (quotas.queryPeriodDays) quotaRows.push(quotas.queryPeriodDays);
|
||||
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1">
|
||||
<IconTallymark4 className="size-6 text-blue-500" />
|
||||
Quotas
|
||||
<InfoIconTooltip
|
||||
content="Quotas define the maximum resources available to your organization."
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Header2>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Quota</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Limit</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Current</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Source</TableHeaderCell>
|
||||
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{quotaRows.map((quota) => (
|
||||
<QuotaRow
|
||||
key={quota.name}
|
||||
quota={quota}
|
||||
isOnTopPlan={isOnTopPlan}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaRow({
|
||||
quota,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
showSelfServe,
|
||||
}: {
|
||||
quota: QuotaInfo;
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
showSelfServe: boolean;
|
||||
}) {
|
||||
// For log retention and query period, we don't show current usage as it's a duration, not a count
|
||||
// For widgets per dashboard, the usage varies per dashboard so we don't show a single number
|
||||
const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period";
|
||||
const isPerItemQuota = quota.name === "Charts per dashboard";
|
||||
const isRetentionQuota = isDurationQuota || isPerItemQuota;
|
||||
const isQueueSizeQuota = quota.name === "Max queued runs";
|
||||
const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota;
|
||||
|
||||
const percentage =
|
||||
!hideCurrentUsage && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
|
||||
|
||||
// Special handling for duration-based quotas (Log retention, Query period)
|
||||
if (isDurationQuota) {
|
||||
const canUpgrade = !isOnTopPlan;
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="flex w-full items-center gap-1">
|
||||
<span className="text-sm">{quota.name}</span>
|
||||
<InfoIconTooltip content={quota.description} disableHoverableContent />
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="font-medium tabular-nums">
|
||||
{quota.limit !== null
|
||||
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
|
||||
: "Unlimited"}
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums text-text-dimmed">
|
||||
–
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<SourceBadge source={quota.source} />
|
||||
</TableCell>
|
||||
{showSelfServe ? (
|
||||
<TableCell alignment="right">
|
||||
<div className="flex justify-end">
|
||||
{canUpgrade ? (
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const renderUpgrade = () => {
|
||||
// Projects always show Contact us (regardless of upgrade flags)
|
||||
if (quota.name === "Projects") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!quota.isUpgradable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Not on top plan - show View plans
|
||||
if (!isOnTopPlan) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On top plan - show Contact us if canExceed is true
|
||||
if (quota.canExceed) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On top plan but cannot exceed - no upgrade option
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="flex w-full items-center gap-1">
|
||||
<span className="text-sm">{quota.name}</span>
|
||||
<InfoIconTooltip content={quota.description} disableHoverableContent />
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="font-medium tabular-nums">
|
||||
{quota.limit !== null
|
||||
? isDurationQuota
|
||||
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
|
||||
: formatNumber(quota.limit)
|
||||
: "Unlimited"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
hideCurrentUsage ? "text-text-dimmed" : getUsageColorClass(percentage, "usage")
|
||||
)}
|
||||
>
|
||||
{hideCurrentUsage ? "–" : formatNumber(quota.currentUsage)}
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<SourceBadge source={quota.source} />
|
||||
</TableCell>
|
||||
{showSelfServe ? <TableCell alignment="right">{renderUpgrade()}</TableCell> : null}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturesSection({
|
||||
features,
|
||||
isOnTopPlan,
|
||||
billingPath,
|
||||
}: {
|
||||
features: LimitsResult["features"];
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
}) {
|
||||
// For staging environment: show View plans if not enabled (i.e., on Free plan)
|
||||
const stagingUpgradeType = features.hasStagingEnvironment.enabled ? "none" : "view-plans";
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1.5">
|
||||
<IconDiamondFilled className="size-5 text-green-600" />
|
||||
Plan features
|
||||
</Header2>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Feature</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Status</TableHeaderCell>
|
||||
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<FeatureRow
|
||||
feature={features.hasStagingEnvironment}
|
||||
upgradeType={stagingUpgradeType}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature={features.support}
|
||||
upgradeType={isOnTopPlan ? "contact-us" : "view-plans"}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature={features.includedUsage}
|
||||
upgradeType={isOnTopPlan ? "contact-us" : "view-plans"}
|
||||
billingPath={billingPath}
|
||||
showSelfServe={showSelfServe}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureRow({
|
||||
feature,
|
||||
upgradeType,
|
||||
billingPath,
|
||||
showSelfServe,
|
||||
}: {
|
||||
feature: FeatureInfo;
|
||||
upgradeType: "view-plans" | "contact-us" | "none";
|
||||
billingPath: string;
|
||||
showSelfServe: boolean;
|
||||
}) {
|
||||
const displayValue = () => {
|
||||
if (feature.name === "Included compute" && typeof feature.value === "number") {
|
||||
if (!feature.enabled || feature.value === 0) {
|
||||
return <span className="text-text-dimmed">None</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-medium text-text-bright">${formatNumber(feature.value / 100)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (feature.value !== undefined) {
|
||||
return <span className="font-medium text-text-bright">{feature.value}</span>;
|
||||
}
|
||||
|
||||
return feature.enabled ? (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<CheckIcon className="size-4" />
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-text-dimmed">Not available</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpgrade = () => {
|
||||
switch (upgradeType) {
|
||||
case "view-plans":
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<LinkButton to={billingPath} variant="secondary/small">
|
||||
View plans
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
case "contact-us":
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Contact us</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="flex w-full items-center gap-1">
|
||||
<span className="text-sm">{feature.name}</span>
|
||||
<InfoIconTooltip content={feature.description} disableHoverableContent />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{displayValue()}</TableCell>
|
||||
{showSelfServe ? <TableCell alignment="right">{renderUpgrade()}</TableCell> : null}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate color class based on usage percentage.
|
||||
* @param percentage - The usage percentage (0-1 scale)
|
||||
* @param mode - "usage" means higher is worse (quotas), "remaining" means lower is worse (rate limits)
|
||||
* @returns Tailwind color class
|
||||
*/
|
||||
function getUsageColorClass(
|
||||
percentage: number | null,
|
||||
mode: "usage" | "remaining" = "usage"
|
||||
): string {
|
||||
if (percentage === null) return "text-text-dimmed";
|
||||
|
||||
if (mode === "remaining") {
|
||||
// For remaining tokens: 0 = bad (red), <=10% = warning (orange)
|
||||
if (percentage <= 0) return "text-error";
|
||||
if (percentage <= 0.1) return "text-warning";
|
||||
return "text-text-bright";
|
||||
} else {
|
||||
// For usage: 100% = bad (red), >=90% = warning (orange)
|
||||
if (percentage >= 1) return "text-error";
|
||||
if (percentage >= 0.9) return "text-warning";
|
||||
return "text-text-bright";
|
||||
}
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: "default" | "plan" | "override" }) {
|
||||
const variants: Record<typeof source, { label: string; className: string }> = {
|
||||
default: {
|
||||
label: "Default",
|
||||
className: "bg-indigo-500/20 text-indigo-400",
|
||||
},
|
||||
plan: {
|
||||
label: "Plan",
|
||||
className: "bg-purple-500/20 text-purple-400",
|
||||
},
|
||||
override: {
|
||||
label: "Override",
|
||||
className: "bg-amber-500/20 text-amber-400",
|
||||
},
|
||||
};
|
||||
|
||||
const variant = variants[source];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
variant.className
|
||||
)}
|
||||
>
|
||||
{variant.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user