diff --git a/apps/webapp/app/assets/icons/MoveToTopIcon.tsx b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx new file mode 100644 index 0000000000..46938fd391 --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx @@ -0,0 +1,34 @@ +export function MoveToTopIcon({ className }: { className?: string }) { + return ( + <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clipPath="url(#clip0_17186_103975)"> + <path + d="M12 21L12 9" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M3 3L21 3" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M16.5 11.5L12 7L7.5 11.5" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + </g> + <defs> + <clipPath id="clip0_17186_103975"> + <rect width="24" height="24" fill="currentColor" /> + </clipPath> + </defs> + </svg> + ); +} diff --git a/apps/webapp/app/assets/icons/MoveUpIcon.tsx b/apps/webapp/app/assets/icons/MoveUpIcon.tsx new file mode 100644 index 0000000000..6e5d8a84ba --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveUpIcon.tsx @@ -0,0 +1,41 @@ +export function MoveUpIcon({ className }: { className?: string }) { + return ( + <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clipPath="url(#clip0_17177_110851)"> + <path + d="M12 21L12 13" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M3 3L21 3" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M3 7L21 7" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M16.5 15.5L12 11L7.5 15.5" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + </g> + <defs> + <clipPath id="clip0_17177_110851"> + <rect width="24" height="24" fill="currentColor" /> + </clipPath> + </defs> + </svg> + ); +} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 8349ed970f..bf64b4c5f3 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -147,6 +147,12 @@ function ShortcutContent() { </Paragraph> <ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" /> </Shortcut> + <Shortcut name="Jump to root run"> + <ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" /> + </Shortcut> + <Shortcut name="Jump to parent run"> + <ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" /> + </Shortcut> </div> <div className="space-y-3"> <Header3>Schedules page</Header3> diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx index 38fd1525c5..d0186268c0 100644 --- a/apps/webapp/app/components/primitives/TextLink.tsx +++ b/apps/webapp/app/components/primitives/TextLink.tsx @@ -1,6 +1,10 @@ import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; import { Icon, type RenderIcon } from "./Icon"; +import { useRef } from "react"; +import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { ShortcutKey } from "./ShortcutKey"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; const variations = { primary: @@ -17,6 +21,9 @@ type TextLinkProps = { trailingIconClassName?: string; variant?: keyof typeof variations; children: React.ReactNode; + shortcut?: ShortcutDefinition; + hideShortcutKey?: boolean; + tooltip?: React.ReactNode; } & React.AnchorHTMLAttributes<HTMLAnchorElement>; export function TextLink({ @@ -27,20 +34,61 @@ export function TextLink({ trailingIcon, trailingIconClassName, variant = "primary", + shortcut, + hideShortcutKey, + tooltip, ...props }: TextLinkProps) { + const innerRef = useRef<HTMLAnchorElement>(null); const classes = variations[variant]; - return to ? ( - <Link to={to} className={cn(classes, className)} {...props}> + + if (shortcut) { + useShortcutKeys({ + shortcut: shortcut, + action: () => { + if (innerRef.current) { + innerRef.current.click(); + } + }, + }); + } + + const renderShortcutKey = () => + shortcut && + !hideShortcutKey && <ShortcutKey className="ml-1.5" shortcut={shortcut} variant="small" />; + + const linkContent = ( + <> {children}{" "} {trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />} + {shortcut && !tooltip && renderShortcutKey()} + </> + ); + + const linkElement = to ? ( + <Link ref={innerRef} to={to} className={cn(classes, className)} {...props}> + {linkContent} </Link> ) : href ? ( - <a href={href} className={cn(classes, className)} {...props}> - {children}{" "} - {trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />} + <a ref={innerRef} href={href} className={cn(classes, className)} {...props}> + {linkContent} </a> ) : ( <span>Need to define a path or href</span> ); + + if (tooltip) { + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild>{linkElement}</TooltipTrigger> + <TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs"> + {tooltip} {shortcut && renderShortcutKey()} + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + } + + return linkElement; } diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index d3ea209924..8ece0dfc3d 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -63,6 +63,14 @@ export class RunPresenter { createdAt: true, }, }, + parentTaskRun: { + select: { + friendlyId: true, + taskIdentifier: true, + spanId: true, + createdAt: true, + }, + }, runtimeEnvironment: { select: { id: true, @@ -111,6 +119,7 @@ export class RunPresenter { completedAt: run.completedAt, logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt, rootTaskRun: run.rootTaskRun, + parentTaskRun: run.parentTaskRun, environment: { id: run.runtimeEnvironment.id, organizationId: run.runtimeEnvironment.organizationId, @@ -202,8 +211,6 @@ export class RunPresenter { trace: { rootSpanStatus, events: events, - parentRunFriendlyId: - tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId, duration: totalDuration, rootStartedAt: tree?.data.startTime, startedAt: run.startedAt, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 9e9145be9a..e3b4ea1971 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -11,7 +11,7 @@ import { MagnifyingGlassPlusIcon, StopCircleIcon, } from "@heroicons/react/20/solid"; -import { useLoaderData, useParams, useRevalidator } from "@remix-run/react"; +import { useLoaderData, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime"; import { type Virtualizer } from "@tanstack/react-virtual"; import { @@ -25,7 +25,8 @@ import { motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; -import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon"; +import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon"; +import { MoveUpIcon } from "~/assets/icons/MoveUpIcon"; import tileBgPath from "~/assets/images/error-banner-tile@2x.png"; import { DevDisconnectedBanner, useCrossEngineIsConnected } from "~/components/DevPresence"; import { WarmStartIconWithTooltip } from "~/components/WarmStarts"; @@ -87,7 +88,6 @@ import { docsPath, v3BillingPath, v3RunParamsSchema, - v3RunPath, v3RunRedirectPath, v3RunSpanPath, v3RunStreamingPath, @@ -301,8 +301,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade return <></>; } - const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt, queuedDuration } = - trace; + const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration } = trace; const shouldLiveReload = events.length <= maximumLiveReloadingSetting; const changeToSpan = useDebounce((selectedSpan: string) => { @@ -339,7 +338,6 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade selectedId={selectedSpanId} key={events[0]?.id ?? "-"} events={events} - parentRunFriendlyId={parentRunFriendlyId} onSelectedIdChanged={(selectedSpan) => { //instantly close the panel if no span is selected if (!selectedSpan) { @@ -357,6 +355,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade shouldLiveReload={shouldLiveReload} maximumLiveReloadingSetting={maximumLiveReloadingSetting} rootRun={run.rootTaskRun} + parentRun={run.parentTaskRun} isCompleted={run.completedAt !== null} /> </ResizablePanel> @@ -475,7 +474,6 @@ function NoLogsView({ run, resizable }: LoaderData) { type TasksTreeViewProps = { events: TraceEvent[]; selectedId?: string; - parentRunFriendlyId?: string; onSelectedIdChanged: (selectedId: string | undefined) => void; totalDuration: number; rootSpanStatus: "executing" | "completed" | "failed"; @@ -489,13 +487,17 @@ type TasksTreeViewProps = { taskIdentifier: string; spanId: string; } | null; + parentRun: { + friendlyId: string; + taskIdentifier: string; + spanId: string; + } | null; isCompleted: boolean; }; function TasksTreeView({ events, selectedId, - parentRunFriendlyId, onSelectedIdChanged, totalDuration, rootSpanStatus, @@ -505,6 +507,7 @@ function TasksTreeView({ shouldLiveReload, maximumLiveReloadingSetting, rootRun, + parentRun, isCompleted, }: TasksTreeViewProps) { const isAdmin = useHasAdminAccess(); @@ -595,20 +598,32 @@ function TasksTreeView({ id={resizableSettings.tree.tree.id} default={resizableSettings.tree.tree.default} min={resizableSettings.tree.tree.min} - className="pl-3" > <div className="grid h-full grid-rows-[2rem_1fr] overflow-hidden"> - <div className="flex items-center pr-2"> - {rootRun ? ( - <ShowParentLink - runFriendlyId={rootRun.friendlyId} - isRoot={true} - spanId={rootRun.spanId} + <div className="flex items-center pl-1 pr-2"> + {rootRun || parentRun ? ( + <ShowParentOrRootLinks + relationships={{ + root: rootRun + ? { + friendlyId: rootRun.friendlyId, + taskIdentifier: rootRun.taskIdentifier, + spanId: rootRun.spanId, + isParent: parentRun ? rootRun.friendlyId === parentRun.friendlyId : true, + } + : undefined, + parent: + parentRun && rootRun?.friendlyId !== parentRun.friendlyId + ? { + friendlyId: parentRun.friendlyId, + taskIdentifier: "", + spanId: "", + } + : undefined, + }} /> - ) : parentRunFriendlyId ? ( - <ShowParentLink runFriendlyId={parentRunFriendlyId} isRoot={false} /> ) : ( - <Paragraph variant="small" className="flex-1 text-charcoal-500"> + <Paragraph variant="extra-small" className="flex-1 pl-3 text-charcoal-500"> This is the root task </Paragraph> )} @@ -627,6 +642,7 @@ function TasksTreeView({ nodes={nodes} getNodeProps={getNodeProps} getTreeProps={getTreeProps} + parentClassName="pl-3" renderNode={({ node, state, index }) => ( <> <div @@ -1138,60 +1154,110 @@ function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boole return <div className={cn("h-8 w-2 border-r border-grid-bright")} />; } -function ShowParentLink({ - runFriendlyId, - spanId, - isRoot, +function ShowParentOrRootLinks({ + relationships, }: { - runFriendlyId: string; - spanId?: string; - isRoot: boolean; + relationships: { + root?: { + friendlyId: string; + taskIdentifier: string; + spanId: string; + isParent?: boolean; + }; + parent?: { + friendlyId: string; + taskIdentifier: string; + spanId: string; + }; + }; }) { - const [mouseOver, setMouseOver] = useState(false); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { spanParam } = useParams(); - const span = spanId ? spanId : spanParam; + // Case 1: Root is also the parent + if (relationships.root?.isParent === true) { + return ( + <LinkButton + variant="minimal/small" + to={v3RunSpanPath( + organization, + project, + environment, + { friendlyId: relationships.root.friendlyId }, + { spanId: relationships.root.spanId } + )} + LeadingIcon={MoveToTopIcon} + leadingIconClassName="gap-x-2" + shortcut={{ key: "p" }} + hideShortcutKey + tooltip={ + <div className="-mr-1 flex items-center gap-1"> + <Paragraph variant="extra-small">Jump to root and parent run</Paragraph> + <ShortcutKey shortcut={{ key: "p" }} variant="small" /> + </div> + } + className="text-xs" + > + Root/parent + </LinkButton> + ); + } + // Case 2: Root and Parent are different runs return ( - <LinkButton - variant="minimal/medium" - to={ - span - ? v3RunSpanPath( - organization, - project, - environment, - { - friendlyId: runFriendlyId, - }, - { spanId: span } - ) - : v3RunPath(organization, project, environment, { - friendlyId: runFriendlyId, - }) - } - onMouseEnter={() => setMouseOver(true)} - onMouseLeave={() => setMouseOver(false)} - fullWidth - textAlignLeft - shortcut={{ key: "p" }} - className="flex-1" - > - {mouseOver ? ( - <ShowParentIconSelected className="h-4 w-4 text-indigo-500" /> - ) : ( - <ShowParentIcon className="h-4 w-4 text-charcoal-650" /> + <div className="flex items-center"> + {relationships.root && ( + <LinkButton + variant="minimal/small" + to={v3RunSpanPath( + organization, + project, + environment, + { friendlyId: relationships.root.friendlyId }, + { spanId: relationships.root.spanId } + )} + LeadingIcon={MoveToTopIcon} + leadingIconClassName="gap-x-2" + shortcut={{ key: "t" }} + hideShortcutKey + tooltip={ + <div className="-mr-1 flex items-center gap-1"> + <Paragraph variant="extra-small">Jump to root run</Paragraph> + <ShortcutKey shortcut={{ key: "t" }} variant="small" /> + </div> + } + className="text-xs" + > + Root + </LinkButton> )} - <Paragraph - variant="small" - className={cn(mouseOver ? "text-indigo-500" : "text-charcoal-500")} - > - {isRoot ? "Show root run" : "Show parent run"} - </Paragraph> - </LinkButton> + {relationships.parent && ( + <LinkButton + variant="minimal/small" + to={v3RunSpanPath( + organization, + project, + environment, + { friendlyId: relationships.parent.friendlyId }, + { spanId: relationships.parent.spanId } + )} + LeadingIcon={MoveUpIcon} + leadingIconClassName="gap-x-2" + shortcut={{ key: "p" }} + hideShortcutKey + tooltip={ + <div className="-mr-1 flex items-center gap-1"> + <Paragraph variant="extra-small">Jump to parent run</Paragraph> + <ShortcutKey shortcut={{ key: "p" }} variant="small" /> + </div> + } + className="text-xs" + > + Parent + </LinkButton> + )} + </div> ); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 4871a2d0b7..98ffecb50b 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -7,16 +7,16 @@ import { } from "@heroicons/react/20/solid"; import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline"; import { Form, useLocation, useNavigation } from "@remix-run/react"; -import { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { uiComponent } from "@team-plain/typescript-sdk"; import { GitHubLightIcon } from "@trigger.dev/companyicons"; import { - FreePlanDefinition, - Limits, - PaidPlanDefinition, - Plans, - SetPlanBody, - SubscriptionResult, + type FreePlanDefinition, + type Limits, + type PaidPlanDefinition, + type Plans, + type SetPlanBody, + type SubscriptionResult, } from "@trigger.dev/platform/v3"; import React, { useEffect, useState } from "react"; import { z } from "zod"; @@ -168,6 +168,10 @@ const pricingDefinitions = { title: "Concurrent runs", content: "The number of runs that can be executed at the same time.", }, + additionalConcurrency: { + title: "Additional concurrency", + content: "Then $50/month per 50", + }, taskRun: { title: "Task runs", content: "A single execution of a task.", @@ -185,6 +189,10 @@ const pricingDefinitions = { title: "Schedules", content: "You can attach recurring schedules to tasks using cron syntax.", }, + additionalSchedules: { + title: "Additional schedules", + content: "Then $10/month per 1,000", + }, alerts: { title: "Alert destination", content: @@ -195,9 +203,22 @@ const pricingDefinitions = { content: "Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.", }, + additionalRealtimeConnections: { + title: "Additional Realtime connections", + content: "Then $10/month per 100", + }, + additionalSeats: { + title: "Additional seats", + content: "Then $20/month per seat", + }, branches: { title: "Branches", - content: "The number of preview branches that can be active (you can archive old ones).", + content: + "Preview branches allow you to test changes before deploying to production. You can have a limited number active at once (but can archive old ones).", + }, + additionalBranches: { + title: "Additional branches", + content: "Then $10/month per branch", }, }; @@ -381,7 +402,7 @@ export function TierFree({ <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="cancel"> <DialogTrigger asChild> <div className="my-6"> - <Button variant="tertiary/large" fullWidth className="text-md font-medium"> + <Button variant="secondary/large" fullWidth className="text-md font-medium"> {`Downgrade to ${plan.title}`} </Button> </div> @@ -504,7 +525,7 @@ export function TierFree({ <LogRetention limits={plan.limits} /> <SupportLevel limits={plan.limits} /> <Alerts limits={plan.limits} /> - <RealtimeConnecurrency limits={plan.limits} /> + <RealtimeConcurrency limits={plan.limits} /> </ul> </> )} @@ -581,7 +602,7 @@ export function TierHobby({ </Dialog> ) : ( <Button - variant={isHighlighted ? "primary/large" : "tertiary/large"} + variant={isHighlighted ? "primary/large" : "secondary/large"} fullWidth className="text-md font-medium" form="subscribe-hobby" @@ -621,7 +642,7 @@ export function TierHobby({ <LogRetention limits={plan.limits} /> <SupportLevel limits={plan.limits} /> <Alerts limits={plan.limits} /> - <RealtimeConnecurrency limits={plan.limits} /> + <RealtimeConcurrency limits={plan.limits} /> </ul> </TierContainer> ); @@ -695,7 +716,7 @@ export function TierPro({ </Dialog> ) : ( <Button - variant="tertiary/large" + variant="secondary/large" fullWidth form="subscribe-pro" className="text-md font-medium" @@ -721,7 +742,9 @@ export function TierPro({ </div> </Form> <ul className="flex flex-col gap-2.5"> - <ConcurrentRuns limits={plan.limits} /> + <ConcurrentRuns limits={plan.limits}> + {pricingDefinitions.additionalConcurrency.content} + </ConcurrentRuns> <FeatureItem checked> Unlimited{" "} <DefinitionTip @@ -731,14 +754,16 @@ export function TierPro({ tasks </DefinitionTip> </FeatureItem> - <TeamMembers limits={plan.limits} /> + <TeamMembers limits={plan.limits}>{pricingDefinitions.additionalSeats.content}</TeamMembers> <Environments limits={plan.limits} /> - <Branches limits={plan.limits} /> - <Schedules limits={plan.limits} /> + <Branches limits={plan.limits}>{pricingDefinitions.additionalBranches.content}</Branches> + <Schedules limits={plan.limits}>{pricingDefinitions.additionalSchedules.content}</Schedules> <LogRetention limits={plan.limits} /> <SupportLevel limits={plan.limits} /> <Alerts limits={plan.limits} /> - <RealtimeConnecurrency limits={plan.limits} /> + <RealtimeConcurrency limits={plan.limits}> + {pricingDefinitions.additionalRealtimeConnections.content} + </RealtimeConcurrency> </ul> </TierContainer> ); @@ -785,7 +810,7 @@ export function TierEnterprise() { <Feedback defaultValue="enterprise" button={ - <div className="flex h-10 w-full cursor-pointer items-center justify-center rounded bg-tertiary px-8 text-base font-medium transition hover:bg-charcoal-600"> + <div className="flex h-10 w-full cursor-pointer items-center justify-center rounded border border-charcoal-600 bg-tertiary text-base font-medium transition hover:border-charcoal-550 hover:bg-charcoal-600"> <span className="text-center text-text-bright">Contact us</span> </div> } @@ -809,7 +834,7 @@ function TierContainer({ <div className={cn( "flex w-full min-w-[16rem] flex-col p-6", - isHighlighted ? "border border-primary" : "border border-grid-dimmed", + isHighlighted ? "border border-indigo-500" : "border border-grid-dimmed", className )} > @@ -840,7 +865,10 @@ function PricingHeader({ return ( <div className="flex flex-col gap-2"> <h2 - className={cn("text-xl font-medium", isHighlighted ? "text-primary" : "text-text-dimmed")} + className={cn( + "text-xl font-medium", + isHighlighted ? "text-indigo-500" : "text-text-dimmed" + )} > {title} </h2> @@ -896,16 +924,16 @@ function FeatureItem({ children: React.ReactNode; }) { return ( - <li className="flex items-center gap-2"> + <li className="flex items-start gap-2"> {checked ? ( <CheckIcon className={cn( - "size-4 min-w-4", + "mt-0.5 size-4 min-w-4", checkedColor === "primary" ? "text-primary" : "text-text-bright" )} /> ) : ( - <XMarkIcon className="size-4 min-w-4 text-charcoal-500" /> + <XMarkIcon className="mt-0.5 size-4 min-w-4 text-charcoal-500" /> )} <div className={cn( @@ -919,26 +947,42 @@ function FeatureItem({ ); } -function ConcurrentRuns({ limits }: { limits: Limits }) { +function ConcurrentRuns({ limits, children }: { limits: Limits; children?: React.ReactNode }) { return ( <FeatureItem checked> - {limits.concurrentRuns.number} - {limits.concurrentRuns.canExceed ? "+" : ""}{" "} - <DefinitionTip - title={pricingDefinitions.concurrentRuns.title} - content={pricingDefinitions.concurrentRuns.content} - > - concurrent runs - </DefinitionTip> + <div className="flex flex-col gap-y-0.5"> + <div className="flex items-center gap-1"> + {limits.concurrentRuns.canExceed ? ( + <> + {limits.concurrentRuns.number} + {"+"} + </> + ) : ( + <>{limits.concurrentRuns.number} </> + )}{" "} + <DefinitionTip + title={pricingDefinitions.concurrentRuns.title} + content={pricingDefinitions.concurrentRuns.content} + > + concurrent runs + </DefinitionTip> + </div> + {children && <span className="text-xs text-text-dimmed">{children}</span>} + </div> </FeatureItem> ); } -function TeamMembers({ limits }: { limits: Limits }) { +function TeamMembers({ limits, children }: { limits: Limits; children?: React.ReactNode }) { return ( <FeatureItem checked> - {limits.teamMembers.number} - {limits.concurrentRuns.canExceed ? "+" : ""} team members + <div className="flex flex-col gap-y-0.5"> + <div className="flex items-center gap-1"> + {limits.teamMembers.number} + {limits.teamMembers.canExceed ? "+" : ""} team members + </div> + {children && <span className="text-xs text-text-dimmed">{children}</span>} + </div> </FeatureItem> ); } @@ -957,17 +1001,22 @@ function Environments({ limits }: { limits: Limits }) { ); } -function Schedules({ limits }: { limits: Limits }) { +function Schedules({ limits, children }: { limits: Limits; children?: React.ReactNode }) { return ( <FeatureItem checked> - {limits.schedules.number} - {limits.schedules.canExceed ? "+" : ""}{" "} - <DefinitionTip - title={pricingDefinitions.schedules.title} - content={pricingDefinitions.schedules.content} - > - schedules - </DefinitionTip> + <div className="flex flex-col gap-y-0.5"> + <div className="flex items-center gap-1"> + {limits.schedules.number} + {limits.schedules.canExceed ? "+" : ""}{" "} + <DefinitionTip + title={pricingDefinitions.schedules.title} + content={pricingDefinitions.schedules.content} + > + schedules + </DefinitionTip> + </div> + {children && <span className="text-xs text-text-dimmed">{children}</span>} + </div> </FeatureItem> ); } @@ -1012,32 +1061,52 @@ function Alerts({ limits }: { limits: Limits }) { ); } -function RealtimeConnecurrency({ limits }: { limits: Limits }) { +function RealtimeConcurrency({ limits, children }: { limits: Limits; children?: React.ReactNode }) { return ( <FeatureItem checked> - {limits.realtimeConcurrentConnections.number} - {limits.realtimeConcurrentConnections.canExceed ? "+" : ""}{" "} - <DefinitionTip - title={pricingDefinitions.realtime.title} - content={pricingDefinitions.realtime.content} - > - concurrent Realtime connections - </DefinitionTip> + <div className="flex flex-col gap-y-0.5"> + <div className="flex items-start gap-1"> + {limits.realtimeConcurrentConnections.canExceed ? ( + <> + {limits.realtimeConcurrentConnections.number} + {"+"} + </> + ) : ( + <>{limits.realtimeConcurrentConnections.number} </> + )}{" "} + <DefinitionTip + title={pricingDefinitions.realtime.title} + content={pricingDefinitions.realtime.content} + > + concurrent Realtime connections + </DefinitionTip> + </div> + {children && <span className="text-xs text-text-dimmed">{children}</span>} + </div> </FeatureItem> ); } -function Branches({ limits }: { limits: Limits }) { +function Branches({ limits, children }: { limits: Limits; children?: React.ReactNode }) { return ( <FeatureItem checked={limits.branches.number > 0}> - {limits.branches.number} - {limits.branches.canExceed ? "+ " : " "} - <DefinitionTip - title={pricingDefinitions.branches.title} - content={pricingDefinitions.branches.content} - > - preview branches - </DefinitionTip> + <div className="flex flex-col gap-y-0.5"> + <div className="flex items-center gap-1"> + {limits.branches.number > 0 && ( + <> + {limits.branches.number} + {limits.branches.canExceed ? "+ " : " "} + </> + )} + <DefinitionTip + title={pricingDefinitions.branches.title} + content={pricingDefinitions.branches.content} + > + {limits.branches.number > 0 ? "preview" : "Preview"} branches + </DefinitionTip> + </div> + {children && <span className="text-xs text-text-dimmed">{children}</span>} + </div> </FeatureItem> ); }