Merge remote-tracking branch 'origin/main' into stable-builds

This commit is contained in:
Saadi Myftija
2025-12-11 16:57:24 +01:00
23 changed files with 2164 additions and 967 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": minor
---
feat(cli): enable zstd compression for deployment images
+148 -132
View File
@@ -52,6 +52,13 @@ import {
} from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { V4Badge } from "./V4Badge";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "./primitives/ClientTabs";
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export function HasNoTasksDev() {
return (
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
}
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
return <DeploymentOnboardingSteps />;
}
export function SchedulesNoPossibleTaskPanel() {
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
}
export function DeploymentsNone() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploy for the first time"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
);
return <DeploymentOnboardingSteps />;
}
export function DeploymentsNoneDev() {
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
const environment = useEnvironment();
return (
<div className="space-y-8">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploying tasks"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
<>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="" title="Switch to a deployed environment" />
<StepContentContainer className="mb-4 flex flex-col gap-4">
<Paragraph>
This is the Development environment. When you're ready to deploy your tasks, switch to a
different environment.
</Paragraph>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous
Integration service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
<SwitcherPanel />
</div>
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
/>
</StepContentContainer>
</>
);
}
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
</div>
);
}
function DeploymentOnboardingSteps() {
const environment = useEnvironment();
const organization = useOrganization();
const project = useProject();
return (
<PackageManagerProvider>
<div className="mb-2 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<ClientTabs defaultValue="github">
<ClientTabsList variant="segmented" className="mb-6">
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
GitHub
</ClientTabsTrigger>
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
Manual
</ClientTabsTrigger>
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
GitHub Actions
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"github"}>
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
<StepContentContainer>
<Paragraph spacing>
Deploy automatically with every push. Read the{" "}
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
</Paragraph>
<div className="w-fit">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"cli"}>
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"github-actions"}>
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
</ClientTabsContent>
</ClientTabs>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</PackageManagerProvider>
);
}
+4
View File
@@ -134,6 +134,10 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to adjacent">
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Expand all">
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
</Shortcut>
@@ -372,7 +372,7 @@ export const LinkButton = ({
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -387,7 +387,7 @@ export const LinkButton = ({
<Link
to={to}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -408,7 +408,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
return (
<NavLink
to={to}
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
target={target}
>
{({ isActive, isPending }) => (
@@ -1,41 +1,185 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "~/utils/cn";
import { type Variants } from "./Tabs";
type ClientTabsContextValue = {
value?: string;
};
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
function useClientTabsContext() {
return React.useContext(ClientTabsContext);
}
const ClientTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
React.useEffect(() => {
if (valueProp !== undefined) {
setValue(valueProp);
}
}, [valueProp]);
const handleValueChange = React.useCallback(
(nextValue: string) => {
if (valueProp === undefined) {
setValue(nextValue);
}
onValueChange?.(nextValue);
},
[onValueChange, valueProp]
);
const controlledProps =
valueProp !== undefined
? { value: valueProp }
: defaultValue !== undefined
? { defaultValue }
: {};
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
return (
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
onValueChange={handleValueChange}
{...controlledProps}
{...props}
/>
</ClientTabsContext.Provider>
);
});
ClientTabs.displayName = TabsPrimitive.Root.displayName;
const ClientTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn("inline-flex items-center justify-center transition duration-100", className)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
variant?: Variants;
}
>(({ className, variant = "pipe-divider", ...props }, ref) => {
const variantClassName = (() => {
switch (variant) {
case "segmented":
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
case "underline":
return "flex gap-x-6 border-b border-grid-bright";
default:
return "inline-flex items-center justify-center transition duration-100";
}
})();
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
});
ClientTabsList.displayName = TabsPrimitive.List.displayName;
const ClientTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
variant?: Variants;
layoutId?: string;
}
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
const context = useClientTabsContext();
const activeValue = context?.value;
const isActive = activeValue === props.value;
if (variant === "segmented") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
className
)}
{...props}
>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
isActive
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{isActive ? (
layoutId ? (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
) : (
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
)
) : null}
</TabsPrimitive.Trigger>
);
}
if (variant === "underline") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{layoutId ? (
isActive ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)
) : isActive ? (
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
}
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
</TabsPrimitive.Trigger>
);
});
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const ClientTabsContent = React.forwardRef<
@@ -61,39 +205,7 @@ export type TabsProps = {
currentValue: string;
className?: string;
layoutId: string;
variant?: Variants;
};
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
return (
<TabsPrimitive.List
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
>
{tabs.map((tab, index) => {
const isActive = currentValue === tab.value;
return (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
className={cn(`group flex flex-col items-center`, className)}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-indigo-500" : "text-charcoal-200"
)}
>
{tab.label}
</span>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
})}
</TabsPrimitive.List>
);
}
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
@@ -3,59 +3,95 @@ import { useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
export function CopyableText({
value,
copyValue,
className,
asChild,
variant,
}: {
value: string;
copyValue?: string;
className?: string;
asChild?: boolean;
variant?: "icon-right" | "text-below";
}) {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(copyValue ?? value);
return (
<span
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
const resolvedVariant = variant ?? "icon-right";
if (resolvedVariant === "icon-right") {
return (
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
</span>
</span>
</span>
);
);
}
if (resolvedVariant === "text-below") {
return (
<SimpleTooltip
button={
<Button
variant="minimal/small"
onClick={(e) => {
e.stopPropagation();
copy();
}}
className={cn(
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
className
)}
>
<span>{value}</span>
</Button>
}
content={copied ? "Copied" : "Click to copy"}
className="font-sans px-2 py-1"
disableHoverableContent
open={isHovered || copied}
onOpenChange={setIsHovered}
/>
);
}
return null;
}
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
const medium =
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
medium: cn(medium, "group-hover:border-charcoal-550"),
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
};
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
key = key.toLowerCase();
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
switch (key) {
case "enter":
+112 -18
View File
@@ -1,10 +1,12 @@
import { NavLink } from "@remix-run/react";
import { motion } from "framer-motion";
import { ReactNode, useRef } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type ReactNode, useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
export type Variants = "underline" | "pipe-divider" | "segmented";
export type TabsProps = {
tabs: {
label: string;
@@ -12,13 +14,14 @@ export type TabsProps = {
}[];
className?: string;
layoutId: string;
variant?: Variants;
};
export function Tabs({ tabs, className, layoutId }: TabsProps) {
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
return (
<TabContainer className={className}>
<TabContainer className={className} variant={variant}>
{tabs.map((tab, index) => (
<TabLink key={index} to={tab.to} layoutId={layoutId}>
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
{tab.label}
</TabLink>
))}
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
);
}
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
{children}
</div>
);
export function TabContainer({
children,
className,
variant = "underline",
}: {
children: ReactNode;
className?: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<div
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
>
{children}
</div>
);
}
if (variant === "underline") {
return (
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
);
}
return <div className={cn(`flex`, className)}>{children}</div>;
}
export function TabLink({
to,
children,
layoutId,
variant = "underline",
}: {
to: string;
children: ReactNode;
layoutId: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<NavLink
to={to}
className="group relative flex h-full grow items-center justify-center focus-custom"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
active
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{active && (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
)}
</>
);
}}
</NavLink>
);
}
if (variant === "pipe-divider") {
return (
<NavLink
to={to}
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<span
className={cn(
"text-sm transition duration-200",
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
)}
>
{children}
</span>
);
}}
</NavLink>
);
}
// underline variant (default)
return (
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
{({ isActive, isPending }) => {
@@ -51,13 +138,19 @@ export function TabLink({
<span
className={cn(
"text-sm transition duration-200",
isActive || isPending ? "text-text-bright" : "text-text-bright"
isActive || isPending
? "text-text-bright"
: "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{isActive || isPending ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -106,17 +199,18 @@ export function TabButton({
<>
<div className="flex items-center gap-1">
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-bright"
)}
className={"text-sm transition duration-200 text-text-bright"}
>
{props.children}
</span>
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
</div>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
const variantClasses = {
basic:
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
};
type Variant = keyof typeof variantClasses;
@@ -64,6 +64,8 @@ function SimpleTooltip({
buttonStyle,
asChild = false,
sideOffset,
open,
onOpenChange,
}: {
button: React.ReactNode;
content: React.ReactNode;
@@ -76,10 +78,12 @@ function SimpleTooltip({
buttonStyle?: React.CSSProperties;
asChild?: boolean;
sideOffset?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip>
<Tooltip open={open} onOpenChange={onOpenChange}>
<TooltipTrigger
tabIndex={-1}
className={cn("h-fit", buttonClassName)}
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
}
case "Left":
case "ArrowLeft": {
if (e.metaKey) {
return;
}
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
@@ -55,6 +55,7 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
type RunsTableProps = {
total: number;
@@ -81,6 +82,8 @@ export function TaskRunsTable({
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
const { isManagedCloud } = useFeatures();
const location = useOptimisticLocation();
const tableStateParam = encodeURIComponent(location.search ? `${location.search}&rt=1` : "rt=1");
const showCompute = isManagedCloud;
@@ -293,16 +296,20 @@ export function TaskRunsTable({
<BlankState isLoading={isLoading} filters={filters} />
) : (
runs.map((run, index) => {
const searchParams = new URLSearchParams();
if (tableStateParam) {
searchParams.set("tableState", tableStateParam);
}
const path = v3RunSpanPath(organization, project, run.environment, run, {
spanId: run.spanId,
});
}, searchParams);
return (
<TableRow key={run.id}>
{allowSelection && (
<TableCell className="pl-3 pr-0">
<Checkbox
checked={has(run.friendlyId)}
onChange={(element) => {
onChange={() => {
toggle(run.friendlyId);
}}
ref={(r) => {
@@ -0,0 +1,137 @@
import { type PrismaClient } from "@trigger.dev/database";
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
import { env } from "~/env.server";
import { BranchTrackingConfigSchema } from "~/v3/github";
import { BasePresenter } from "./basePresenter.server";
type GitHubSettingsOptions = {
projectId: string;
organizationId: string;
};
export class GitHubSettingsPresenter extends BasePresenter {
public call({ projectId, organizationId }: GitHubSettingsOptions) {
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
if (!githubAppEnabled) {
return ok({
enabled: false,
connectedRepository: undefined,
installations: undefined,
isPreviewEnvironmentEnabled: undefined,
});
}
const findConnectedGithubRepository = () =>
fromPromise(
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
createdAt: true,
repository: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
},
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((connectedGithubRepository) => {
if (!connectedGithubRepository) {
return undefined;
}
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedGithubRepository.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedGithubRepository,
branchTracking,
};
});
const listGithubAppInstallations = () =>
fromPromise(
(this._replica as PrismaClient).githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
const isPreviewEnvironmentEnabled = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
return ResultAsync.combine([
isPreviewEnvironmentEnabled(),
findConnectedGithubRepository(),
listGithubAppInstallations(),
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
enabled: true,
connectedRepository: connectedGithubRepository,
installations: githubAppInstallations,
isPreviewEnvironmentEnabled,
}));
}
}
@@ -359,11 +359,11 @@ export default function Page() {
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNoneDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNone />
</MainCenteredContainer>
)}
@@ -2,6 +2,7 @@ import {
ArrowUturnLeftIcon,
BoltSlashIcon,
BookOpenIcon,
ChevronUpIcon,
ChevronDownIcon,
ChevronRightIcon,
InformationCircleIcon,
@@ -22,7 +23,7 @@ import {
} from "@trigger.dev/core/v3";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { redirect } from "remix-typedjson";
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
@@ -68,7 +69,6 @@ import {
eventBorderClassName,
} from "~/components/runs/v3/SpanTitle";
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
import { env } from "~/env.server";
import { useDebounce } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
@@ -88,6 +88,7 @@ import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
@@ -98,6 +99,13 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP
import { useSearchParams } from "~/hooks/useSearchParam";
import { CopyableText } from "~/components/primitives/CopyableText";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
const resizableSettings = {
parent: {
@@ -131,6 +139,101 @@ const resizableSettings = {
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
type RunsListNavigation = {
runs: Array<{ friendlyId: string }>;
pagination: { next?: string; previous?: string };
prevPageLastRun?: { friendlyId: string; cursor: string };
nextPageFirstRun?: { friendlyId: string; cursor: string };
};
async function getRunsListFromTableState({
tableStateParam,
organizationSlug,
projectParam,
envParam,
runParam,
userId,
}: {
tableStateParam: string | null;
organizationSlug: string;
projectParam: string;
envParam: string;
runParam: string;
userId: string;
}): Promise<RunsListNavigation | null> {
if (!tableStateParam) {
return null;
}
try {
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
if (!project || !environment) {
return null;
}
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
pageSize: 25, // Load enough runs to provide navigation context
});
const runsList: RunsListNavigation = {
runs: currentPageResult.runs,
pagination: currentPageResult.pagination,
};
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
cursor: currentPageResult.pagination.previous,
direction: "backward",
pageSize: 1, // We only need the last run from the previous page
});
if (prevPageResult.runs.length > 0) {
runsList.prevPageLastRun = {
friendlyId: prevPageResult.runs[0].friendlyId,
cursor: currentPageResult.pagination.previous,
};
}
}
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
cursor: currentPageResult.pagination.next,
direction: "forward",
pageSize: 1, // We only need the first run from the next page
});
if (nextPageResult.runs.length > 0) {
runsList.nextPageFirstRun = {
friendlyId: nextPageResult.runs[0].friendlyId,
cursor: currentPageResult.pagination.next,
};
}
}
return runsList;
} catch (error) {
logger.error("Error loading runs list from tableState:", { error });
return null;
}
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -169,6 +272,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
const runsList = await getRunsListFromTableState({
tableStateParam: url.searchParams.get("tableState"),
organizationSlug,
projectParam,
envParam,
runParam,
userId,
});
return json({
run: result.run,
trace: result.trace,
@@ -177,13 +289,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
parent,
tree,
},
runsList,
});
};
type LoaderData = SerializeFrom<typeof loader>;
export default function Page() {
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
const { run, trace, resizable, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -191,16 +304,28 @@ export default function Page() {
logCount: trace?.events.length ?? 0,
isCompleted: run.completedAt !== null,
});
const { value } = useSearchParams();
const tableState = decodeURIComponent(value("tableState") ?? "");
const tableStateSearchParams = new URLSearchParams(tableState);
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList});
return (
<>
<NavBar>
<PageTitle
backButton={{
to: v3RunsPath(organization, project, environment),
to: v3RunsPath(organization, project, environment, filters),
text: "Runs",
}}
title={<CopyableText value={run.friendlyId} />}
title={<>
<CopyableText value={run.friendlyId} variant="text-below" className="font-mono"/>
{tableState && (<div className="flex">
<PreviousRunButton to={previousRunPath} />
<NextRunButton to={nextRunPath} />
</div>)}
</>}
/>
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
<PageAccessories>
@@ -276,14 +401,10 @@ export default function Page() {
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
) : (
<NoLogsView
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
)}
</PageBody>
@@ -291,7 +412,7 @@ export default function Page() {
);
}
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -385,7 +506,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
);
}
function NoLogsView({ run, resizable }: LoaderData) {
function NoLogsView({ run }: Pick<LoaderData, "run">) {
const plan = useCurrentPlan();
const organization = useOrganization();
@@ -1432,6 +1553,7 @@ function KeyboardShortcuts({
return (
<>
<ArrowKeyShortcuts />
<AdjacentRunsShortcuts />
<ShortcutWithAction
shortcut={{ key: "e" }}
action={() => expandAllBelowDepth(0)}
@@ -1448,6 +1570,16 @@ function KeyboardShortcuts({
);
}
function AdjacentRunsShortcuts() {
return (<div className="flex items-center gap-0.5">
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Adjacent runs
</Paragraph>
</div>);
}
function ArrowKeyShortcuts() {
return (
<div className="flex items-center gap-0.5">
@@ -1494,7 +1626,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
return (
<div className="flex items-center gap-0.5">
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
<span className="text-[0.75rem] text-text-dimmed"></span>
<span className="text-[0.65rem] text-text-dimmed"></span>
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Toggle level
@@ -1526,3 +1658,111 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
/>
);
}
function useAdjacentRunPaths({
organization,
project,
environment,
tableState,
run,
runsList,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
tableState: string;
run: { friendlyId: string };
runsList: RunsListNavigation | null;
}): [string | null, string | null] {
return useMemo(() => {
if (!runsList || runsList.runs.length === 0) {
return [null, null];
}
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
if (currentIndex === -1) {
return [null, null];
}
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
let previousRun: { friendlyId: string } | null = null;
const previousRunTableState = new URLSearchParams(tableState);
if (currentIndex > 0) {
previousRun = runsList.runs[currentIndex - 1];
} else if (runsList.prevPageLastRun) {
previousRun = runsList.prevPageLastRun;
// Update tableState with the new cursor for the previous page
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
previousRunTableState.set("direction", "backward");
}
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
let nextRun: { friendlyId: string } | null = null;
const nextRunTableState = new URLSearchParams(tableState);
if (currentIndex < runsList.runs.length - 1) {
nextRun = runsList.runs[currentIndex + 1];
} else if (runsList.nextPageFirstRun) {
nextRun = runsList.nextPageFirstRun;
// Update tableState with the new cursor for the next page
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
nextRunTableState.set("direction", "forward");
}
const previousURLSearchParams = new URLSearchParams();
previousURLSearchParams.set("tableState", previousRunTableState.toString());
const previousRunPath = previousRun
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
: null;
const nextURLSearchParams = new URLSearchParams();
nextURLSearchParams.set("tableState", nextRunTableState.toString());
const nextRunPath = nextRun
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
: null;
return [previousRunPath, nextRunPath];
}, [organization, project, environment, tableState, run.friendlyId, runsList]);
}
function PreviousRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : '#'}
variant={"minimal/small"}
LeadingIcon={ChevronUpIcon}
className={cn(
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
!to && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "[" }}
tooltip="Previous Run"
disabled={!to}
/>
</div>
);
}
function NextRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : '#'}
variant={"minimal/small"}
TrailingIcon={ChevronDownIcon}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
!to && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "]" }}
tooltip="Next Run"
disabled={!to}
/>
</div>
);
}
@@ -1,35 +1,18 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
FolderIcon,
TrashIcon,
LockClosedIcon,
PlusIcon,
} from "@heroicons/react/20/solid";
import {
Form,
type MetaFunction,
useActionData,
useNavigation,
useNavigate,
useSearchParams,
} from "@remix-run/react";
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { OctoKitty } from "~/components/GitHubLoginButton";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -55,32 +38,12 @@ import {
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
organizationPath,
v3ProjectPath,
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
docsPath,
v3BillingPath,
} from "~/utils/pathBuilder";
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import React, { useEffect, useState } from "react";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Switch } from "~/components/primitives/Switch";
import { type BranchTrackingConfig } from "~/v3/github";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { GitBranchIcon } from "lucide-react";
import { useEnvironment } from "~/hooks/useEnvironment";
import { DateTime } from "~/components/primitives/DateTime";
import { TextLink } from "~/components/primitives/TextLink";
import { cn } from "~/utils/cn";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { type BuildSettings } from "~/v3/buildSettings";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export const meta: MetaFunction = () => {
return [
@@ -128,29 +91,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
githubAppEnabled: gitHubApp.enabled,
githubAppInstallations: gitHubApp.installations,
connectedGithubRepository: gitHubApp.connectedRepository,
isPreviewEnvironmentEnabled: gitHubApp.isPreviewEnvironmentEnabled,
buildSettings,
});
};
const ConnectGitHubRepoFormSchema = z.object({
action: z.literal("connect-repo"),
installationId: z.string(),
repositoryId: z.string(),
});
const UpdateGitSettingsFormSchema = z.object({
action: z.literal("update-git-settings"),
productionBranch: z.string().trim().optional(),
stagingBranch: z.string().trim().optional(),
previewDeploymentsEnabled: z
.string()
.optional()
.transform((val) => val === "on"),
});
const UpdateBuildSettingsFormSchema = z.object({
action: z.literal("update-build-settings"),
triggerConfigFilePath: z
@@ -220,12 +164,7 @@ export function createSchema(
}
}),
}),
ConnectGitHubRepoFormSchema,
UpdateGitSettingsFormSchema,
UpdateBuildSettingsFormSchema,
z.object({
action: z.literal("disconnect-repo"),
}),
]);
}
@@ -260,7 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId, organizationId } = membershipResultOrFail.value;
const { projectId } = membershipResultOrFail.value;
switch (submission.value.action) {
case "rename": {
@@ -316,101 +255,6 @@ export const action: ActionFunction = async ({ request, params }) => {
"Project deleted"
);
}
case "disconnect-repo": {
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to disconnect GitHub repository", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
}
}
}
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
}
case "update-git-settings": {
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
const resultOrFail = await projectSettingsService.updateGitSettings(
projectId,
productionBranch,
stagingBranch,
previewDeploymentsEnabled
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "github_app_not_enabled": {
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
}
case "connected_gh_repository_not_found": {
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
}
case "production_tracking_branch_not_found": {
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
}
case "staging_tracking_branch_not_found": {
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to update Git settings", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
}
}
}
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
}
case "connect-repo": {
const { repositoryId, installationId } = submission.value;
const resultOrFail = await projectSettingsService.connectGitHubRepo(
projectId,
organizationId,
repositoryId,
installationId
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "gh_repository_not_found": {
return redirectBackWithErrorMessage(request, "GitHub repository not found");
}
case "project_already_has_connected_repository": {
return redirectBackWithErrorMessage(
request,
"Project already has a connected repository"
);
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to connect GitHub repository", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
}
}
}
return json({
...submission,
success: true,
});
}
case "update-build-settings": {
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
submission.value;
@@ -446,13 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const {
githubAppInstallations,
connectedGithubRepository,
githubAppEnabled,
buildSettings,
isPreviewEnvironmentEnabled,
} = useTypedLoaderData<typeof loader>();
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const environment = useEnvironment();
@@ -578,19 +416,12 @@ export default function Page() {
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
{connectedGithubRepository ? (
<ConnectedGitHubRepoForm
connectedGitHubRepo={connectedGithubRepository}
previewEnvironmentEnabled={isPreviewEnvironmentEnabled}
/>
) : (
<GitHubConnectionPrompt
gitHubAppInstallations={githubAppInstallations ?? []}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
/>
)}
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
@@ -650,489 +481,6 @@ export default function Page() {
);
}
type GitHubRepository = {
id: string;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
};
type GitHubAppInstallation = {
id: string;
appInstallationId: bigint;
targetType: string;
accountHandle: string;
repositories: GitHubRepository[];
};
function ConnectGitHubRepoModal({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
open?: boolean;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<
GitHubAppInstallation | undefined
>(gitHubAppInstallations.at(0));
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
undefined
);
const navigation = useNavigation();
const isConnectRepositoryLoading =
navigation.formData?.get("action") === "connect-repo" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [form, { installationId, repositoryId }] = useForm({
id: "connect-repo",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: ConnectGitHubRepoFormSchema,
});
},
});
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (params.get("openGithubRepoModal") === "1") {
setIsModalOpen(true);
params.delete("openGithubRepoModal");
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
}
}, [lastSubmission]);
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
Connect GitHub repo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Connect GitHub repository</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" {...form.props} className="w-full">
<Paragraph className="mb-3">
Choose a GitHub repository to connect to your project.
</Paragraph>
<Fieldset className="max-w-full gap-y-3">
<InputGroup className="max-w-full">
<Label htmlFor={installationId.id}>Account</Label>
<Select
name={installationId.name}
id={installationId.id}
value={selectedInstallation?.id}
defaultValue={gitHubAppInstallations.at(0)?.id}
setValue={(value) => {
if (Array.isArray(value)) return;
const installation = gitHubAppInstallations.find((i) => i.id === value);
setSelectedInstallation(installation);
setSelectedRepository(undefined);
}}
items={gitHubAppInstallations}
variant="tertiary/small"
placeholder="Select account"
dropdownIcon
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
>
{[
...gitHubAppInstallations.map((installation) => (
<SelectItem
key={installation.id}
value={installation.id}
icon={<OctoKitty className="size-3 text-text-dimmed" />}
>
{installation.accountHandle}
</SelectItem>
)),
<SelectItem
onClick={(e) => {
e.preventDefault();
navigate(
githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)
);
}}
key="new-account"
icon={<PlusIcon className="size-3 text-text-dimmed" />}
>
Add account
</SelectItem>,
]}
</Select>
<FormError id={installationId.errorId}>{installationId.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label htmlFor={repositoryId.id}>Repository</Label>
<Select
name={repositoryId.name}
id={repositoryId.id}
value={selectedRepository ? selectedRepository.id : undefined}
setValue={(value) => {
if (Array.isArray(value)) return;
const repository = selectedInstallation?.repositories.find(
(r) => r.id === value
);
setSelectedRepository(repository);
}}
variant="tertiary/small"
placeholder="Select repository"
heading="Filter repositories"
dropdownIcon
items={selectedInstallation?.repositories ?? []}
filter={{ keys: ["name"] }}
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
text={selectedRepository ? selectedRepository.name : null}
>
{(matches) =>
matches.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<div className="flex items-center gap-1">
{repo.name}
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
</div>
</SelectItem>
))
}
</Select>
<Hint className={cn("invisible", selectedInstallation && "visible")}>
Configure repository access in{" "}
<TextLink
target="_blank"
rel="noreferrer noopener"
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
>
GitHub
</TextLink>
.
</Hint>
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="connect-repo"
variant="primary/medium"
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
leadingIconClassName="text-white"
disabled={isConnectRepositoryLoading}
>
Connect repository
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function GitHubConnectionPrompt({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
}) {
return (
<Fieldset>
<InputGroup fullWidth>
{gitHubAppInstallations.length === 0 && (
<LinkButton
to={githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)}
variant={"secondary/medium"}
LeadingIcon={OctoKitty}
>
Install GitHub app
</LinkButton>
)}
{gitHubAppInstallations.length !== 0 && (
<div className="flex items-center gap-3">
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
</span>
</div>
)}
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
</InputGroup>
</Fieldset>
);
}
type ConnectedGitHubRepo = {
branchTracking: BranchTrackingConfig | undefined;
previewDeploymentsEnabled: boolean;
createdAt: Date;
repository: GitHubRepository;
};
function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
}: {
connectedGitHubRepo: ConnectedGitHubRepo;
previewEnvironmentEnabled?: boolean;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const organization = useOrganization();
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
const [gitSettingsValues, setGitSettingsValues] = useState({
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
});
useEffect(() => {
const hasChanges =
gitSettingsValues.productionBranch !==
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
gitSettingsValues.stagingBranch !==
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
setHasGitSettingsChanges(hasChanges);
}, [gitSettingsValues, connectedGitHubRepo]);
const [gitSettingsForm, fields] = useForm({
id: "update-git-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateGitSettingsFormSchema,
});
},
});
const isGitSettingsLoading =
navigation.formData?.get("action") === "update-git-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<OctoKitty className="size-4" />
<a
href={connectedGitHubRepo.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-bright hover:underline"
>
{connectedGitHubRepo.repository.fullName}
</a>
{connectedGitHubRepo.repository.private && (
<LockClosedIcon className="size-3 text-text-dimmed" />
)}
<span className="text-xs text-text-dimmed">
<DateTime
date={connectedGitHubRepo.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="minimal/small">Disconnect</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect GitHub repository</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
This will stop automatic deployments from GitHub.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="action" value="disconnect-repo" />
<Button type="submit" variant="danger/medium">
Disconnect repository
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
<Form method="post" {...gitSettingsForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Hint>
Every push to the selected tracking branch creates a deployment in the corresponding
environment.
</Hint>
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
{environmentFullTitle({ type: "PRODUCTION" })}
</span>
</div>
<Input
{...conform.input(fields.productionBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
productionBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
{environmentFullTitle({ type: "STAGING" })}
</span>
</div>
<Input
{...conform.input(fields.stagingBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
stagingBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
{environmentFullTitle({ type: "PREVIEW" })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Switch
name="previewDeploymentsEnabled"
disabled={!previewEnvironmentEnabled}
defaultChecked={
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
}
variant="small"
label="Create preview deployments for pull requests"
labelPosition="right"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
{!previewEnvironmentEnabled && (
<InfoIconTooltip
content={
<span className="text-xs">
<TextLink to={v3BillingPath(organization)}>Upgrade</TextLink> your plan to
enable preview branches
</span>
}
/>
)}
</div>
</div>
<FormError>{fields.productionBranch?.error}</FormError>
<FormError>{fields.stagingBranch?.error}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
<FormError>{gitSettingsForm.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</>
);
}
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
@@ -0,0 +1,877 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigation, useNavigate, useSearchParams, useLocation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import { z } from "zod";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import { TextLink } from "~/components/primitives/TextLink";
import { DateTime } from "~/components/primitives/DateTime";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { GitBranchIcon } from "lucide-react";
import {
redirectBackWithErrorMessage,
redirectBackWithSuccessMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
} from "~/utils/pathBuilder";
import { cn } from "~/utils/cn";
import { type BranchTrackingConfig } from "~/v3/github";
import { GitHubSettingsPresenter } from "~/presenters/v3/GitHubSettingsPresenter.server";
import { useEffect, useState } from "react";
// ============================================================================
// Types
// ============================================================================
export type GitHubRepository = {
id: string;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
};
export type GitHubAppInstallation = {
id: string;
appInstallationId: bigint;
targetType: string;
accountHandle: string;
repositories: GitHubRepository[];
};
export type ConnectedGitHubRepo = {
branchTracking: BranchTrackingConfig | undefined;
previewDeploymentsEnabled: boolean;
createdAt: Date;
repository: GitHubRepository;
};
// ============================================================================
// Schemas
// ============================================================================
export const ConnectGitHubRepoFormSchema = z.object({
action: z.literal("connect-repo"),
installationId: z.string(),
repositoryId: z.string(),
redirectUrl: z.string().optional(),
});
export const DisconnectGitHubRepoFormSchema = z.object({
action: z.literal("disconnect-repo"),
redirectUrl: z.string().optional(),
});
export const UpdateGitSettingsFormSchema = z.object({
action: z.literal("update-git-settings"),
productionBranch: z.string().trim().optional(),
stagingBranch: z.string().trim().optional(),
previewDeploymentsEnabled: z
.string()
.optional()
.transform((val) => val === "on"),
redirectUrl: z.string().optional(),
});
const GitHubActionSchema = z.discriminatedUnion("action", [
ConnectGitHubRepoFormSchema,
DisconnectGitHubRepoFormSchema,
UpdateGitSettingsFormSchema,
]);
// ============================================================================
// Loader
// ============================================================================
export async function loader({ 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("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new GitHubSettingsPresenter();
const resultOrFail = await presenter.call({
projectId: project.id,
organizationId: project.organizationId,
});
if (resultOrFail.isErr()) {
throw new Response("Failed to load GitHub settings", { status: 500 });
}
return typedjson(resultOrFail.value);
}
// ============================================================================
// Action
// ============================================================================
function redirectWithMessage(
request: Request,
redirectUrl: string | undefined,
message: string,
type: "success" | "error"
) {
if (type === "success") {
return redirectUrl
? redirectWithSuccessMessage(redirectUrl, request, message)
: redirectBackWithSuccessMessage(request, message);
}
return redirectUrl
? redirectWithErrorMessage(redirectUrl, request, message)
: redirectBackWithErrorMessage(request, message);
}
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const formData = await request.formData();
const submission = parse(formData, { schema: GitHubActionSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
userId
);
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId, organizationId } = membershipResultOrFail.value;
const { action: actionType } = submission.value;
// Handle connect-repo action
if (actionType === "connect-repo") {
const { repositoryId, installationId, redirectUrl } = submission.value;
const resultOrFail = await projectSettingsService.connectGitHubRepo(
projectId,
organizationId,
repositoryId,
installationId
);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"GitHub repository connected successfully",
"success"
);
}
const errorType = resultOrFail.error.type;
if (errorType === "gh_repository_not_found") {
return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error");
}
if (errorType === "project_already_has_connected_repository") {
return redirectWithMessage(
request,
redirectUrl,
"Project already has a connected repository",
"error"
);
}
logger.error("Failed to connect GitHub repository", { error: resultOrFail.error });
return redirectWithMessage(
request,
redirectUrl,
"Failed to connect GitHub repository",
"error"
);
}
// Handle disconnect-repo action
if (actionType === "disconnect-repo") {
const { redirectUrl } = submission.value;
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"GitHub repository disconnected successfully",
"success"
);
}
logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error });
return redirectWithMessage(
request,
redirectUrl,
"Failed to disconnect GitHub repository",
"error"
);
}
// Handle update-git-settings action
if (actionType === "update-git-settings") {
const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } =
submission.value;
const resultOrFail = await projectSettingsService.updateGitSettings(
projectId,
productionBranch,
stagingBranch,
previewDeploymentsEnabled
);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"Git settings updated successfully",
"success"
);
}
const errorType = resultOrFail.error.type;
const errorMessages: Record<string, string> = {
github_app_not_enabled: "GitHub app is not enabled",
connected_gh_repository_not_found: "Connected GitHub repository not found",
production_tracking_branch_not_found: "Production tracking branch not found",
staging_tracking_branch_not_found: "Staging tracking branch not found",
};
const message = errorMessages[errorType];
if (message) {
return redirectWithMessage(request, redirectUrl, message, "error");
}
logger.error("Failed to update Git settings", { error: resultOrFail.error });
return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error");
}
// Exhaustive check - this should never be reached
submission.value satisfies never;
return redirectBackWithErrorMessage(request, "Failed to process request");
}
// ============================================================================
// Helper: Build resource URL for fetching GitHub data
// ============================================================================
export function gitHubResourcePath(
organizationSlug: string,
projectSlug: string,
environmentSlug: string
) {
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/github`;
}
// ============================================================================
// Components
// ============================================================================
export function ConnectGitHubRepoModal({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
redirectUrl,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
redirectUrl?: string;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<
GitHubAppInstallation | undefined
>(gitHubAppInstallations.at(0));
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
undefined
);
const navigation = useNavigation();
const isConnectRepositoryLoading =
navigation.formData?.get("action") === "connect-repo" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [form, { installationId, repositoryId }] = useForm({
id: "connect-repo",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: ConnectGitHubRepoFormSchema,
});
},
});
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (params.get("openGithubRepoModal") === "1") {
setIsModalOpen(true);
params.delete("openGithubRepoModal");
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
}
}, [lastSubmission]);
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
Connect GitHub repo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Connect GitHub repository</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" action={actionUrl} {...form.props} className="w-full">
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Paragraph className="mb-3">
Choose a GitHub repository to connect to your project.
</Paragraph>
<Fieldset className="max-w-full gap-y-3">
<InputGroup className="max-w-full">
<Label htmlFor={installationId.id}>Account</Label>
<Select
name={installationId.name}
id={installationId.id}
value={selectedInstallation?.id}
defaultValue={gitHubAppInstallations.at(0)?.id}
setValue={(value) => {
if (Array.isArray(value)) return;
const installation = gitHubAppInstallations.find((i) => i.id === value);
setSelectedInstallation(installation);
setSelectedRepository(undefined);
}}
items={gitHubAppInstallations}
variant="tertiary/small"
placeholder="Select account"
dropdownIcon
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
>
{[
...gitHubAppInstallations.map((installation) => (
<SelectItem
key={installation.id}
value={installation.id}
icon={<OctoKitty className="size-3 text-text-dimmed" />}
>
{installation.accountHandle}
</SelectItem>
)),
<SelectItem
onClick={(e) => {
e.preventDefault();
navigate(
githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)
);
}}
key="new-account"
icon={<PlusIcon className="size-3 text-text-dimmed" />}
>
Add account
</SelectItem>,
]}
</Select>
<FormError id={installationId.errorId}>{installationId.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label htmlFor={repositoryId.id}>Repository</Label>
<Select
name={repositoryId.name}
id={repositoryId.id}
value={selectedRepository ? selectedRepository.id : undefined}
setValue={(value) => {
if (Array.isArray(value)) return;
const repository = selectedInstallation?.repositories.find(
(r) => r.id === value
);
setSelectedRepository(repository);
}}
variant="tertiary/small"
placeholder="Select repository"
heading="Filter repositories"
dropdownIcon
items={selectedInstallation?.repositories ?? []}
filter={{ keys: ["name"] }}
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
text={selectedRepository ? selectedRepository.name : null}
>
{(matches) =>
matches.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<div className="flex items-center gap-1">
{repo.name}
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
</div>
</SelectItem>
))
}
</Select>
<Hint className={cn("invisible", selectedInstallation && "visible")}>
Configure repository access in{" "}
<TextLink
target="_blank"
rel="noreferrer noopener"
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
>
GitHub
</TextLink>
.
</Hint>
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="connect-repo"
variant="primary/medium"
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
leadingIconClassName="text-white"
disabled={isConnectRepositoryLoading}
>
Connect repository
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
export function GitHubConnectionPrompt({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
redirectUrl,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
redirectUrl?: string;
}) {
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
return (
<Fieldset>
<InputGroup fullWidth>
{gitHubAppInstallations.length === 0 && (
<LinkButton
to={githubAppInstallPath(
organizationSlug,
`${githubInstallationRedirect}?openGithubRepoModal=1`
)}
variant={"secondary/medium"}
LeadingIcon={OctoKitty}
>
Install GitHub app
</LinkButton>
)}
{gitHubAppInstallations.length !== 0 && (
<div className="flex items-center gap-3">
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={redirectUrl}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
</span>
</div>
)}
</InputGroup>
</Fieldset>
);
}
export function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
organizationSlug,
projectSlug,
environmentSlug,
billingPath,
redirectUrl,
}: {
connectedGitHubRepo: ConnectedGitHubRepo;
previewEnvironmentEnabled?: boolean;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
billingPath: string;
redirectUrl?: string;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
const [gitSettingsValues, setGitSettingsValues] = useState({
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
});
useEffect(() => {
const hasChanges =
gitSettingsValues.productionBranch !==
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
gitSettingsValues.stagingBranch !==
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
setHasGitSettingsChanges(hasChanges);
}, [gitSettingsValues, connectedGitHubRepo]);
const [gitSettingsForm, fields] = useForm({
id: "update-git-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateGitSettingsFormSchema,
});
},
});
const isGitSettingsLoading =
navigation.formData?.get("action") === "update-git-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<OctoKitty className="size-4" />
<a
href={connectedGitHubRepo.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-bright hover:underline"
>
{connectedGitHubRepo.repository.fullName}
</a>
{connectedGitHubRepo.repository.private && (
<LockClosedIcon className="size-3 text-text-dimmed" />
)}
<span className="text-xs text-text-dimmed">
<DateTime
date={connectedGitHubRepo.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="minimal/small">Disconnect</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect GitHub repository</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
This will stop automatic deployments from GitHub.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disconnect-repo" />
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Button type="submit" variant="danger/medium">
Disconnect repository
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
<Form method="post" action={actionUrl} {...gitSettingsForm.props}>
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Fieldset>
<InputGroup fullWidth>
<Hint>
Every push to the selected tracking branch creates a deployment in the corresponding
environment.
</Hint>
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
{environmentFullTitle({ type: "PRODUCTION" })}
</span>
</div>
<Input
{...conform.input(fields.productionBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
productionBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
{environmentFullTitle({ type: "STAGING" })}
</span>
</div>
<Input
{...conform.input(fields.stagingBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
stagingBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
{environmentFullTitle({ type: "PREVIEW" })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Switch
name="previewDeploymentsEnabled"
disabled={!previewEnvironmentEnabled}
defaultChecked={
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
}
variant="small"
label="Create preview deployments for pull requests"
labelPosition="right"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
{!previewEnvironmentEnabled && (
<InfoIconTooltip
content={
<span className="text-xs">
<TextLink to={billingPath}>Upgrade</TextLink> your plan to enable preview
branches
</span>
}
/>
)}
</div>
</div>
<FormError>{fields.productionBranch?.error}</FormError>
<FormError>{fields.stagingBranch?.error}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
<FormError>{gitSettingsForm.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</>
);
}
// ============================================================================
// Main GitHub Settings Panel Component
// ============================================================================
export function GitHubSettingsPanel({
organizationSlug,
projectSlug,
environmentSlug,
billingPath,
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
billingPath: string;
}) {
const fetcher = useTypedFetcher<typeof loader>();
const location = useLocation();
// Use provided redirectUrl or fall back to current path (without search params)
const effectiveRedirectUrl = location.pathname;
useEffect(() => {
fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
}, [organizationSlug, projectSlug, environmentSlug]);
const data = fetcher.data;
// Loading state
if (fetcher.state === "loading" && !data) {
return (
<div className="flex items-center gap-2 text-text-dimmed">
<SpinnerWhite className="size-4" />
<span className="text-sm">Loading GitHub settings...</span>
</div>
);
}
// GitHub app not enabled
if (!data || !data.enabled) {
return null;
}
// Connected repository exists - show form
if (data.connectedRepository) {
return (
<ConnectedGitHubRepoForm
connectedGitHubRepo={data.connectedRepository}
previewEnvironmentEnabled={data.isPreviewEnvironmentEnabled}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
billingPath={billingPath}
redirectUrl={effectiveRedirectUrl}
/>
);
}
// No connected repository - show connection prompt
return (
<div className="flex flex-col gap-2">
<GitHubConnectionPrompt
gitHubAppInstallations={data.installations ?? []}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={effectiveRedirectUrl}
/>
{!data.connectedRepository && (
<Hint>
Connect your GitHub repository to automatically deploy your changes.
</Hint>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import { useParams } from "@remix-run/react";
export default function Story() {
const { tabNumber } = useParams();
return (
<div className="flex items-center justify-center py-8">
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">{tabNumber}</h1>
</div>
);
+180 -10
View File
@@ -1,18 +1,188 @@
import { Outlet } from "@remix-run/react";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Tabs } from "~/components/primitives/Tabs";
export default function Story() {
return (
<div className="w-96 p-8">
<Tabs
tabs={[
{ label: "My first tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs"
/>
<Outlet />
<div className="flex items-start justify-center gap-20 px-16 pt-20">
<div className="flex w-full max-w-2xl flex-col gap-4">
<div className="flex h-fit flex-col gap-2">
<div className="flex flex-col gap-2">
<Header1 spacing>{"<Tabs/>"} (updates the URL)</Header1>
<Paragraph>Variant="underline"</Paragraph>
</div>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-1"
variant="underline"
/>
<Outlet />
</div>
<div className="flex h-fit flex-col gap-2">
<Paragraph>Variant="pipe-divider"</Paragraph>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-2"
variant="pipe-divider"
/>
<Outlet />
</div>
<div className="flex h-fit flex-col gap-2">
<Paragraph>Variant="segmented"</Paragraph>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-3"
variant="segmented"
/>
<Outlet />
</div>
</div>
<div className="flex w-full max-w-2xl flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<Header1 spacing>{"<ClientTabs/>"}</Header1>
<Paragraph>Variant="underline"</Paragraph>
</div>
<ClientTabs defaultValue="tab-1">
<div className="flex items-center gap-4">
<ClientTabsList variant="underline">
<ClientTabsTrigger
value={"tab-1"}
variant="underline"
layoutId="client-tabs-underline"
>
First tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-2"}
variant="underline"
layoutId="client-tabs-underline"
>
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-3"}
variant="underline"
layoutId="client-tabs-underline"
>
Third tab
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
<div>
<Paragraph spacing>Variant="pipe-divider"</Paragraph>
<ClientTabs defaultValue="tab-1">
<div className="flex items-center gap-4">
<ClientTabsList variant="pipe-divider">
<ClientTabsTrigger value={"tab-1"} variant="pipe-divider">
First tab
</ClientTabsTrigger>
<ClientTabsTrigger value={"tab-2"} variant="pipe-divider">
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger value={"tab-3"} variant="pipe-divider">
Third tab
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
<div>
<Paragraph spacing>Variant="segmented"</Paragraph>
<ClientTabs defaultValue="tab-1">
<ClientTabsList variant="segmented">
<ClientTabsTrigger
value={"tab-1"}
variant="segmented"
layoutId="client-tabs-segmented"
>
First tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-2"}
variant="segmented"
layoutId="client-tabs-segmented"
>
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-3"}
variant="segmented"
layoutId="client-tabs-segmented"
>
Third tab
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
</div>
</div>
);
}
@@ -61,6 +61,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
"/api/v1/auth/jwt/claims",
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
/^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash
/^\/api\/v1\/deployments/, // /api/v1/deployments/*
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
+10 -5
View File
@@ -288,15 +288,17 @@ export function v3RunPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
run: v3RunForPath,
searchParams?: URLSearchParams
) {
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`;
const query = searchParams ? `?${searchParams.toString()}` : "";
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`;
}
export function v3RunRedirectPath(
organization: OrgForPath,
project: ProjectForPath,
run: v3RunForPath
run: v3RunForPath,
) {
return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
}
@@ -310,9 +312,12 @@ export function v3RunSpanPath(
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath,
span: v3SpanForPath
span: v3SpanForPath,
searchParams?: URLSearchParams
) {
return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`;
searchParams = searchParams ?? new URLSearchParams();
searchParams.set("span", span.spanId);
return `${v3RunPath(organization, project, environment, run, searchParams)}`;
}
export function v3RunStreamingPath(
+7 -7
View File
@@ -61,7 +61,7 @@ webapp:
```bash
helm upgrade -n trigger --install trigger \
oci://ghcr.io/triggerdotdev/charts/trigger \
--version "~4.0.0-beta" \
--version "~4.0.0" \
--create-namespace
```
@@ -107,11 +107,11 @@ The following commands will display the default values:
```bash
# Specific version
helm show values oci://ghcr.io/triggerdotdev/charts/trigger \
--version "4.0.0-beta.5"
--version "4.0.5"
# Latest v4 beta
# Latest v4
helm show values oci://ghcr.io/triggerdotdev/charts/trigger \
--version "~4.0.0-beta"
--version "~4.0.0"
```
### Custom values
@@ -171,7 +171,7 @@ Deploy with your custom values:
```bash
helm upgrade -n trigger --install trigger \
oci://ghcr.io/triggerdotdev/charts/trigger \
--version "~4.0.0-beta" \
--version "~4.0.0" \
--create-namespace \
-f values-custom.yaml
```
@@ -489,14 +489,14 @@ You can lock versions in two ways:
# Pin to a specific version for production
helm upgrade -n trigger --install trigger \
oci://ghcr.io/triggerdotdev/charts/trigger \
--version "4.0.0-beta.5"
--version "4.0.5"
# The app version will be different from the chart version
# This is the version of the Trigger.dev webapp and supervisor
# ..and should always match your Trigger.dev CLI version
helm show chart \
oci://ghcr.io/triggerdotdev/charts/trigger \
--version "4.0.0-beta.5" | grep appVersion
--version "4.0.5" | grep appVersion
```
**Specific image tags:**
+44 -2
View File
@@ -71,7 +71,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({
saveLogs: z.boolean().default(false),
skipUpdateCheck: z.boolean().default(false),
skipPromotion: z.boolean().default(false),
noCache: z.boolean().default(false),
cache: z.boolean().default(true),
envFile: z.string().optional(),
// Local build options
forceLocalBuild: z.boolean().optional(),
@@ -83,6 +83,10 @@ const DeployCommandOptions = CommonCommandOptions.extend({
nativeBuildServer: z.boolean().default(false),
detach: z.boolean().default(false),
plain: z.boolean().default(false),
compression: z.enum(["zstd", "gzip"]).default("zstd"),
cacheCompression: z.enum(["zstd", "gzip"]).default("zstd"),
compressionLevel: z.number().optional(),
forceCompression: z.boolean().default(true),
});
type DeployCommandOptions = z.infer<typeof DeployCommandOptions>;
@@ -157,6 +161,40 @@ export function configureDeployCommand(program: Command) {
"If provided, will save logs even for successful builds"
).hideHelp()
)
.addOption(
new CommandOption(
"--compression <algorithm>",
"Compression algorithm for image layers: zstd or gzip (default: zstd)"
)
.choices(["zstd", "gzip"])
.hideHelp()
)
.addOption(
new CommandOption(
"--cache-compression <algorithm>",
"Compression algorithm for build cache: zstd or gzip (default: zstd)"
)
.choices(["zstd", "gzip"])
.hideHelp()
)
.addOption(
new CommandOption(
"--compression-level <level>",
"The compression level to use when building the image."
).hideHelp()
)
.addOption(
new CommandOption(
"--force-compression",
"Force recompression of all layers. Enabled by default when using zstd."
).hideHelp()
)
.addOption(
new CommandOption(
"--no-force-compression",
"Disable forced recompression of layers."
).hideHelp()
)
// Local build options
.addOption(
new CommandOption("--force-local-build", "Deprecated alias for --local-build").implies({
@@ -480,7 +518,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
const buildResult = await buildImage({
isLocalBuild,
useRegistryCache: options.useRegistryCache,
noCache: options.noCache,
noCache: !options.cache,
deploymentId: deployment.id,
deploymentVersion: deployment.version,
imageTag: deployment.imageTag,
@@ -499,6 +537,10 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
authAccessToken: authorization.auth.accessToken,
compilationPath: destination.path,
buildEnvVars: buildManifest.build.env,
compression: options.compression,
cacheCompression: options.cacheCompression,
compressionLevel: options.compressionLevel,
forceCompression: options.forceCompression,
onLog: (logMessage) => {
if (options.plain || isCI) {
console.log(logMessage);
+104 -9
View File
@@ -20,6 +20,10 @@ export interface BuildImageOptions {
imagePlatform: string;
noCache?: boolean;
load?: boolean;
compression?: "zstd" | "gzip";
cacheCompression?: "zstd" | "gzip";
compressionLevel?: number;
forceCompression?: boolean;
// Local build options
push?: boolean;
@@ -79,6 +83,10 @@ export async function buildImage(options: BuildImageOptions): Promise<BuildImage
buildEnvVars,
network,
builder,
compression,
cacheCompression,
compressionLevel,
forceCompression,
onLog,
} = options;
@@ -105,6 +113,10 @@ export async function buildImage(options: BuildImageOptions): Promise<BuildImage
buildEnvVars,
network,
builder,
compression,
cacheCompression,
compressionLevel,
forceCompression,
onLog,
});
}
@@ -134,6 +146,9 @@ export async function buildImage(options: BuildImageOptions): Promise<BuildImage
apiKey,
branchName,
buildEnvVars,
compression,
compressionLevel,
forceCompression,
onLog,
});
}
@@ -157,6 +172,9 @@ export interface DepotBuildImageOptions {
noCache?: boolean;
extraCACerts?: string;
buildEnvVars?: Record<string, string | undefined>;
compression?: "zstd" | "gzip";
compressionLevel?: number;
forceCompression?: boolean;
onLog?: (log: string) => void;
}
@@ -180,6 +198,15 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise<BuildI
.filter(([key, value]) => value)
.flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]);
const outputOptions = getOutputOptions({
imageTag: undefined, // This is already handled via the --save flag
push: true, // We always push the image to the registry
load: options.load,
compression: options.compression,
compressionLevel: options.compressionLevel,
forceCompression: options.forceCompression,
});
const args = [
"build",
"-f",
@@ -187,7 +214,6 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise<BuildI
options.noCache ? "--no-cache" : undefined,
"--platform",
options.imagePlatform,
options.load ? "--load" : undefined,
"--provenance",
"false",
"--metadata-file",
@@ -210,12 +236,12 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise<BuildI
`TRIGGER_SECRET_KEY=${options.apiKey}`,
...(buildArgs || []),
...(options.extraCACerts ? ["--build-arg", `NODE_EXTRA_CA_CERTS=${options.extraCACerts}`] : []),
"--output",
"type=image,rewrite-timestamp=true",
"--progress",
"plain",
".",
"--save",
"--output",
outputOptions.join(","),
].filter(Boolean) as string[];
logger.debug(`depot ${args.join(" ")}`, { cwd: options.cwd });
@@ -318,11 +344,25 @@ interface SelfHostedBuildImageOptions {
network?: string;
builder: string;
load?: boolean;
compression?: "zstd" | "gzip";
cacheCompression?: "zstd" | "gzip";
compressionLevel?: number;
forceCompression?: boolean;
onLog?: (log: string) => void;
}
async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<BuildImageResults> {
const { builder, imageTag, deploymentId, apiClient, useRegistryCache } = options;
const {
builder,
imageTag,
deploymentId,
apiClient,
useRegistryCache,
compression,
cacheCompression,
compressionLevel,
forceCompression,
} = options;
// Ensure multi-platform build is supported on the local machine
let builderExists = false;
@@ -491,6 +531,15 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
const projectCacheRef = getProjectCacheRefFromImageTag(imageTag);
const outputOptions = getOutputOptions({
imageTag,
push,
load,
compression,
compressionLevel,
forceCompression,
});
const args = [
"buildx",
"build",
@@ -502,11 +551,15 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
...(useRegistryCache
? [
"--cache-to",
`type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=${projectCacheRef}`,
`type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=${projectCacheRef}${
cacheCompression === "zstd" ? ",compression=zstd" : ""
}`,
"--cache-from",
`type=registry,ref=${projectCacheRef}`,
]
: []),
"--output",
outputOptions.join(","),
"--platform",
options.imagePlatform,
options.network ? `--network=${options.network}` : undefined,
@@ -533,10 +586,6 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
`TRIGGER_SECRET_KEY=${options.apiKey}`,
...(buildArgs || []),
...(options.extraCACerts ? ["--build-arg", `NODE_EXTRA_CA_CERTS=${options.extraCACerts}`] : []),
"--output",
`type=image,name=${imageTag},rewrite-timestamp=true${push ? ",push=true" : ""}${
load ? ",load=true" : ""
}`,
"--progress",
"plain",
".", // The build context
@@ -1066,3 +1115,49 @@ function shouldLoad(load?: boolean, push?: boolean) {
}
}
}
function getOutputOptions({
imageTag,
push,
load,
compression,
compressionLevel,
forceCompression,
}: {
imageTag?: string;
push?: boolean;
load?: boolean;
compression?: "zstd" | "gzip";
compressionLevel?: number;
forceCompression?: boolean;
}): string[] {
// Always use OCI media types for compatibility
const outputOptions: string[] = ["type=image", "oci-mediatypes=true", "rewrite-timestamp=true"];
if (imageTag) {
outputOptions.push(`name=${imageTag}`);
}
if (push) {
outputOptions.push("push=true");
}
if (load) {
outputOptions.push("load=true");
}
// Only add compression args when using zstd (gzip is the default, no args needed)
if (compression === "zstd") {
outputOptions.push("compression=zstd");
if (compressionLevel !== undefined) {
outputOptions.push(`compression-level=${compressionLevel}`);
}
}
if (forceCompression) {
outputOptions.push("force-compression=true");
}
return outputOptions;
}