diff --git a/.changeset/great-pillows-look.md b/.changeset/great-pillows-look.md new file mode 100644 index 000000000..72a0589b2 --- /dev/null +++ b/.changeset/great-pillows-look.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": minor +--- + +feat(cli): enable zstd compression for deployment images diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 380a6d990..d0e798f16 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -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 ( - -
-
-
- - Deploy your tasks to {environmentFullTitle(environment)} -
-
- - } - content="Deploy docs" - /> - - } - content="Troubleshooting docs" - /> - -
-
- - - - This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read - the full guide. - - - - - - - Read the GitHub Actions guide to - get started. - - - - - This page will automatically refresh when your tasks are deployed. - -
-
- ); + return ; } export function SchedulesNoPossibleTaskPanel() { @@ -266,45 +218,7 @@ export function TestHasNoTasks() { } export function DeploymentsNone() { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - - return ( - - - There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration - service like GitHub Actions. Make sure you{" "} - - set your environment variables - {" "} - first. - -
- - Deploy with the CLI - - - Deploy with GitHub actions - -
-
- ); + return ; } export function DeploymentsNoneDev() { @@ -313,46 +227,52 @@ export function DeploymentsNoneDev() { const environment = useEnvironment(); return ( -
- - + <> +
+
+ + Deploy your tasks +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + This is the Development environment. When you're ready to deploy your tasks, switch to a different environment. - - There are several ways to deploy your tasks. You can use the CLI or a Continuous - Integration service like GitHub Actions. Make sure you{" "} - - set your environment variables - {" "} - first. - -
- - Deploy with the CLI - - - Deploy with GitHub actions - -
-
- -
+ + + ); } @@ -670,3 +590,99 @@ export function BulkActionsNone() { ); } + +function DeploymentOnboardingSteps() { + const environment = useEnvironment(); + const organization = useOrganization(); + const project = useProject(); + + return ( + +
+
+ + Deploy your tasks to {environmentFullTitle(environment)} +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + + GitHub + + + Manual + + + GitHub Actions + + + + + + + Deploy automatically with every push. Read the{" "} + full guide. + +
+ +
+
+
+ + + + + This will deploy your tasks to the {environmentFullTitle(environment)} environment. + Read the full guide. + + + + + + + + + Read the GitHub Actions guide to + get started. + + + +
+ + + + This page will automatically refresh when your tasks are deployed. + +
+ ); +} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index ab328afde..b21c55659 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -134,6 +134,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 47c8d3d67..67ba3c092 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -372,7 +372,7 @@ export const LinkButton = ({ {({ isActive, isPending }) => ( diff --git a/apps/webapp/app/components/primitives/ClientTabs.tsx b/apps/webapp/app/components/primitives/ClientTabs.tsx index 52d10b8cf..bc3943e82 100644 --- a/apps/webapp/app/components/primitives/ClientTabs.tsx +++ b/apps/webapp/app/components/primitives/ClientTabs.tsx @@ -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(undefined); + +function useClientTabsContext() { + return React.useContext(ClientTabsContext); +} const ClientTabs = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->((props, ref) => ); +>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => { + const [value, setValue] = React.useState(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(() => ({ value }), [value]); + + return ( + + + + ); +}); ClientTabs.displayName = TabsPrimitive.Root.displayName; const ClientTabsList = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); + React.ComponentPropsWithoutRef & { + 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 ; +}); ClientTabsList.displayName = TabsPrimitive.List.displayName; const ClientTabsTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); + React.ComponentPropsWithoutRef & { + 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 ( + +
+ + {children} + +
+ {isActive ? ( + layoutId ? ( + + ) : ( +
+ ) + ) : null} + + ); + } + + if (variant === "underline") { + return ( + + + {children} + + {layoutId ? ( + isActive ? ( + + ) : ( +
+ ) + ) : isActive ? ( +
+ ) : ( +
+ )} + + ); + } + + return ( + + {children} + + ); +}); 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 ( - - {tabs.map((tab, index) => { - const isActive = currentValue === tab.value; - return ( - - - {tab.label} - - {isActive ? ( - - ) : ( -
- )} - - ); - })} - - ); -} - -export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent }; +export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger }; diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 67e01af79..fa02e5647 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -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 ( - setIsHovered(false)} - > - setIsHovered(true)}>{value} + const resolvedVariant = variant ?? "icon-right"; + + if (resolvedVariant === "icon-right") { + return ( 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)} > - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - className="font-sans" - disableHoverableContent - asChild={asChild} - /> + setIsHovered(true)}>{value} + e.stopPropagation()} + className={cn( + "absolute -right-6 top-0 z-10 size-6 font-sans", + isHovered ? "flex" : "hidden" + )} + > + + {copied ? ( + + ) : ( + + )} + + } + content={copied ? "Copied!" : "Copy"} + className="font-sans" + disableHoverableContent + asChild={asChild} + /> + - - ); + ); + } + + if (resolvedVariant === "text-below") { + return ( + { + 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 + )} + > + {value} + + } + content={copied ? "Copied" : "Click to copy"} + className="font-sans px-2 py-1" + disableHoverableContent + open={isHovered || copied} + onOpenChange={setIsHovered} + /> + ); + } + + return null; } diff --git a/apps/webapp/app/components/primitives/ShortcutKey.tsx b/apps/webapp/app/components/primitives/ShortcutKey.tsx index 04b1f3673..567cf68d6 100644 --- a/apps/webapp/app/components/primitives/ShortcutKey.tsx +++ b/apps/webapp/app/components/primitives/ShortcutKey.tsx @@ -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": diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index e3d3183d9..cbc5cf427 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -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 ( - + {tabs.map((tab, index) => ( - + {tab.label} ))} @@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) { ); } -export function TabContainer({ children, className }: { children: ReactNode; className?: string }) { - return ( -
- {children} -
- ); +export function TabContainer({ + children, + className, + variant = "underline", +}: { + children: ReactNode; + className?: string; + variant?: Variants; +}) { + if (variant === "segmented") { + return ( +
+ {children} +
+ ); + } + + if (variant === "underline") { + return ( +
{children}
+ ); + } + + return
{children}
; } export function TabLink({ to, children, layoutId, + variant = "underline", }: { to: string; children: ReactNode; layoutId: string; + variant?: Variants; }) { + if (variant === "segmented") { + return ( + + {({ isActive, isPending }) => { + const active = isActive || isPending; + return ( + <> +
+ + {children} + +
+ {active && ( + + )} + + ); + }} +
+ ); + } + + if (variant === "pipe-divider") { + return ( + + {({ isActive, isPending }) => { + const active = isActive || isPending; + return ( + + {children} + + ); + }} + + ); + } + + // underline variant (default) return ( {({ isActive, isPending }) => { @@ -51,13 +138,19 @@ export function TabLink({ {children} {isActive || isPending ? ( - + ) : (
)} @@ -106,17 +199,18 @@ export function TabButton({ <>
{props.children} {shortcut && }
{isActive ? ( - + ) : (
)} diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 15dd72894..5c681927b 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -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 ( - + ({ } case "Left": case "ArrowLeft": { + if (e.metaKey) { + return; + } + e.preventDefault(); const selected = selectedIdFromState(state.nodes); diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 60017c69f..c0ae8d2f6 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -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({ ) : ( 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 ( {allowSelection && ( { + onChange={() => { toggle(run.friendlyId); }} ref={(r) => { diff --git a/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts new file mode 100644 index 000000000..c3f715def --- /dev/null +++ b/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts @@ -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, + })); + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 6f161eea9..9b2b78f98 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -359,11 +359,11 @@ export default function Page() {
) : environment.type === "DEVELOPMENT" ? ( - + ) : ( - + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 79ab0b8e5..5253e56f2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -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["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 { + 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; export default function Page() { - const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData(); + const { run, trace, resizable, maximumLiveReloadingSetting, runsList } = useLoaderData(); 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 ( <> } + title={<> + + {tableState && (
+ + +
)} + } /> {environment.type === "DEVELOPMENT" && } @@ -276,14 +401,10 @@ export default function Page() { run={run} trace={trace} maximumLiveReloadingSetting={maximumLiveReloadingSetting} - resizable={resizable} /> ) : ( )} @@ -291,7 +412,7 @@ export default function Page() { ); } -function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) { +function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick) { 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) { const plan = useCurrentPlan(); const organization = useOrganization(); @@ -1432,6 +1553,7 @@ function KeyboardShortcuts({ return ( <> + expandAllBelowDepth(0)} @@ -1448,6 +1570,16 @@ function KeyboardShortcuts({ ); } +function AdjacentRunsShortcuts() { + return (
+ + + + Adjacent runs + +
); +} + function ArrowKeyShortcuts() { return (
@@ -1494,7 +1626,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void return (
0 - + 9 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 ( +
+ !to && e.preventDefault()} + shortcut={{ key: "[" }} + tooltip="Previous Run" + disabled={!to} + /> +
+ ); +} + +function NextRunButton({ to }: { to: string | null }) { + return ( +
+ !to && e.preventDefault()} + shortcut={{ key: "]" }} + tooltip="Next Run" + disabled={!to} + /> +
+ ); +} + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 06b6f6ad8..66ea64cb3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -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(); + const { githubAppEnabled, buildSettings } = useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); const environment = useEnvironment(); @@ -578,19 +416,12 @@ export default function Page() {
Git settings
- {connectedGithubRepository ? ( - - ) : ( - - )} +
@@ -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( - 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 ( - - - - - - Connect GitHub repository -
-
- - Choose a GitHub repository to connect to your project. - -
- - - - {installationId.error} - - - - - - Configure repository access in{" "} - - GitHub - - . - - {repositoryId.error} - - {form.error} - - Connect repository - - } - cancelButton={ - - - - } - /> -
-
-
-
-
- ); -} - -function GitHubConnectionPrompt({ - gitHubAppInstallations, - organizationSlug, - projectSlug, - environmentSlug, -}: { - gitHubAppInstallations: GitHubAppInstallation[]; - organizationSlug: string; - projectSlug: string; - environmentSlug: string; -}) { - return ( -
- - {gitHubAppInstallations.length === 0 && ( - - Install GitHub app - - )} - {gitHubAppInstallations.length !== 0 && ( -
- - - GitHub app is installed - -
- )} - - Connect your GitHub repository to automatically deploy your changes. -
-
- ); -} - -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 ( - <> -
-
- - - {connectedGitHubRepo.repository.fullName} - - {connectedGitHubRepo.repository.private && ( - - )} - - - -
- - - - - - Disconnect GitHub repository -
- - Are you sure you want to disconnect{" "} - {connectedGitHubRepo.repository.fullName}? - This will stop automatic deployments from GitHub. - - - - - - } - cancelButton={ - - - - } - /> -
-
-
-
- -
-
- - - Every push to the selected tracking branch creates a deployment in the corresponding - environment. - -
-
- - - {environmentFullTitle({ type: "PRODUCTION" })} - -
- { - setGitSettingsValues((prev) => ({ - ...prev, - productionBranch: e.target.value, - })); - }} - /> -
- - - {environmentFullTitle({ type: "STAGING" })} - -
- { - setGitSettingsValues((prev) => ({ - ...prev, - stagingBranch: e.target.value, - })); - }} - /> - -
- - - {environmentFullTitle({ type: "PREVIEW" })} - -
-
- { - setGitSettingsValues((prev) => ({ - ...prev, - previewDeploymentsEnabled: checked, - })); - }} - /> - {!previewEnvironmentEnabled && ( - - Upgrade your plan to - enable preview branches - - } - /> - )} -
-
- {fields.productionBranch?.error} - {fields.stagingBranch?.error} - {fields.previewDeploymentsEnabled?.error} - {gitSettingsForm.error} -
- - - Save - - } - /> -
-
- - ); -} - function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx new file mode 100644 index 000000000..bb7406ed4 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -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 = { + 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( + 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 ( + + + + + + Connect GitHub repository +
+
+ {redirectUrl && } + + Choose a GitHub repository to connect to your project. + +
+ + + + {installationId.error} + + + + + + Configure repository access in{" "} + + GitHub + + . + + {repositoryId.error} + + {form.error} + + Connect repository + + } + cancelButton={ + + + + } + /> +
+
+
+
+
+ ); +} + +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 ( +
+ + {gitHubAppInstallations.length === 0 && ( + + Install GitHub app + + )} + {gitHubAppInstallations.length !== 0 && ( +
+ + + GitHub app is installed + +
+ )} +
+
+ ); +} + +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 ( + <> +
+
+ + + {connectedGitHubRepo.repository.fullName} + + {connectedGitHubRepo.repository.private && ( + + )} + + + +
+ + + + + + Disconnect GitHub repository +
+ + Are you sure you want to disconnect{" "} + {connectedGitHubRepo.repository.fullName}? + This will stop automatic deployments from GitHub. + + + + {redirectUrl && } + + + } + cancelButton={ + + + + } + /> +
+
+
+
+ +
+ {redirectUrl && } +
+ + + Every push to the selected tracking branch creates a deployment in the corresponding + environment. + +
+
+ + + {environmentFullTitle({ type: "PRODUCTION" })} + +
+ { + setGitSettingsValues((prev) => ({ + ...prev, + productionBranch: e.target.value, + })); + }} + /> +
+ + + {environmentFullTitle({ type: "STAGING" })} + +
+ { + setGitSettingsValues((prev) => ({ + ...prev, + stagingBranch: e.target.value, + })); + }} + /> + +
+ + + {environmentFullTitle({ type: "PREVIEW" })} + +
+
+ { + setGitSettingsValues((prev) => ({ + ...prev, + previewDeploymentsEnabled: checked, + })); + }} + /> + {!previewEnvironmentEnabled && ( + + Upgrade your plan to enable preview + branches + + } + /> + )} +
+
+ {fields.productionBranch?.error} + {fields.stagingBranch?.error} + {fields.previewDeploymentsEnabled?.error} + {gitSettingsForm.error} +
+ + + Save + + } + /> +
+
+ + ); +} + +// ============================================================================ +// Main GitHub Settings Panel Component +// ============================================================================ + +export function GitHubSettingsPanel({ + organizationSlug, + projectSlug, + environmentSlug, + billingPath, +}: { + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + billingPath: string; +}) { + const fetcher = useTypedFetcher(); + 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 ( +
+ + Loading GitHub settings... +
+ ); + } + + // GitHub app not enabled + if (!data || !data.enabled) { + return null; + } + + // Connected repository exists - show form + if (data.connectedRepository) { + return ( + + ); + } + + // No connected repository - show connection prompt + return ( +
+ + {!data.connectedRepository && ( + + Connect your GitHub repository to automatically deploy your changes. + + )} +
+ + ); +} diff --git a/apps/webapp/app/routes/storybook.tabs.$tabNumber/route.tsx b/apps/webapp/app/routes/storybook.tabs.$tabNumber/route.tsx index 549108143..8cf7aaa16 100644 --- a/apps/webapp/app/routes/storybook.tabs.$tabNumber/route.tsx +++ b/apps/webapp/app/routes/storybook.tabs.$tabNumber/route.tsx @@ -3,7 +3,7 @@ import { useParams } from "@remix-run/react"; export default function Story() { const { tabNumber } = useParams(); return ( -
+

{tabNumber}

); diff --git a/apps/webapp/app/routes/storybook.tabs/route.tsx b/apps/webapp/app/routes/storybook.tabs/route.tsx index fc0c8003a..f3389f2af 100644 --- a/apps/webapp/app/routes/storybook.tabs/route.tsx +++ b/apps/webapp/app/routes/storybook.tabs/route.tsx @@ -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 ( -
- - +
+
+
+
+ {""} (updates the URL) + Variant="underline" +
+ + +
+
+ Variant="pipe-divider" + + +
+
+ Variant="segmented" + + +
+
+
+
+
+ {""} + Variant="underline" +
+ +
+ + + First tab + + + Second tab + + + Third tab + + +
+ +
+

1

+
+
+ +
+

2

+
+
+ +
+

3

+
+
+
+
+ +
+ Variant="pipe-divider" + +
+ + + First tab + + + Second tab + + + Third tab + + +
+ +
+

1

+
+
+ +
+

2

+
+
+ +
+

3

+
+
+
+
+
+ Variant="segmented" + + + + First tab + + + Second tab + + + Third tab + + + +
+

1

+
+
+ +
+

2

+
+
+ +
+

3

+
+
+
+
+
); } diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 416d7834e..4cf27450a 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -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", diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index f82165ae9..3061082ed 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -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( diff --git a/docs/self-hosting/kubernetes.mdx b/docs/self-hosting/kubernetes.mdx index f3a827701..4506d6da9 100644 --- a/docs/self-hosting/kubernetes.mdx +++ b/docs/self-hosting/kubernetes.mdx @@ -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:** diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 818051d68..f4de03281 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -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; @@ -157,6 +161,40 @@ export function configureDeployCommand(program: Command) { "If provided, will save logs even for successful builds" ).hideHelp() ) + .addOption( + new CommandOption( + "--compression ", + "Compression algorithm for image layers: zstd or gzip (default: zstd)" + ) + .choices(["zstd", "gzip"]) + .hideHelp() + ) + .addOption( + new CommandOption( + "--cache-compression ", + "Compression algorithm for build cache: zstd or gzip (default: zstd)" + ) + .choices(["zstd", "gzip"]) + .hideHelp() + ) + .addOption( + new CommandOption( + "--compression-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); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index f060ce030..2225d7db0 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -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; + compression?: "zstd" | "gzip"; + compressionLevel?: number; + forceCompression?: boolean; onLog?: (log: string) => void; } @@ -180,6 +198,15 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise 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 void; } async function localBuildImage(options: SelfHostedBuildImageOptions): Promise { - 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