diff --git a/.changeset/angry-trainers-perform.md b/.changeset/angry-trainers-perform.md new file mode 100644 index 000000000..ac468823e --- /dev/null +++ b/.changeset/angry-trainers-perform.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Stop failing attempt spans when a run is cancelled diff --git a/.changeset/kind-kids-teach.md b/.changeset/kind-kids-teach.md new file mode 100644 index 000000000..65e19fbe9 --- /dev/null +++ b/.changeset/kind-kids-teach.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Added INSTALLING status to the deployment status enum. diff --git a/CHANGESETS.md b/CHANGESETS.md index cf6600766..722fe64eb 100644 --- a/CHANGESETS.md +++ b/CHANGESETS.md @@ -30,14 +30,16 @@ Please follow the best-practice of adding changesets in the same commit as the c ## Snapshot instructions -1. Delete the `.changeset/pre.json` file (if it exists) +1. Update the `.changeset/config.json` file to set the `"changelog"` field to this: + +```json +"changelog": "@changesets/cli/changelog", +``` 2. Do a temporary commit (do NOT push this, you should undo it after) -3. Copy the `GITHUB_TOKEN` line from the .env file +3. Run `./scripts/publish-prerelease.sh prerelease` -4. Run `GITHUB_TOKEN=github_pat_12345 ./scripts/publish-prerelease.sh re2` +You can choose a different tag if you want, but usually `prerelease` is fine. -Make sure to replace the token with yours. `re2` is the tag that will be used for the pre-release. - -5. Undo the commit where you deleted the pre.json file. +5. Undo the commit where you updated the config.json file. diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index 99cb16425..6d6bbd27d 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -89,6 +89,7 @@ const Env = z.object({ KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0), KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit + KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB // Placement tags settings PLACEMENT_TAGS_ENABLED: BoolEnv.default(false), diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index 55281c56a..0f5e89c80 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -25,6 +25,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { private readonly cpuRequestRatio = env.KUBERNETES_CPU_REQUEST_RATIO; private readonly memoryRequestMinGb = env.KUBERNETES_MEMORY_REQUEST_MIN_GB; private readonly memoryRequestRatio = env.KUBERNETES_MEMORY_REQUEST_RATIO; + private readonly memoryOverheadGb = env.KUBERNETES_MEMORY_OVERHEAD_GB; constructor(private opts: WorkloadManagerOptions) { this.k8s = createK8sApi(); @@ -319,9 +320,13 @@ export class KubernetesWorkloadManager implements WorkloadManager { } #getResourceLimitsForMachine(preset: MachinePreset): ResourceQuantities { + const memoryLimit = this.memoryOverheadGb + ? preset.memory + this.memoryOverheadGb + : preset.memory; + return { cpu: `${preset.cpu}`, - memory: `${preset.memory}G`, + memory: `${memoryLimit}G`, }; } diff --git a/apps/supervisor/src/workloadServer/index.ts b/apps/supervisor/src/workloadServer/index.ts index e7e391bce..35d53d360 100644 --- a/apps/supervisor/src/workloadServer/index.ts +++ b/apps/supervisor/src/workloadServer/index.ts @@ -16,7 +16,6 @@ import { type WorkloadRunAttemptCompleteResponseBody, WorkloadRunAttemptStartRequestBody, type WorkloadRunAttemptStartResponseBody, - type WorkloadRunLatestSnapshotResponseBody, WorkloadRunSnapshotsSinceResponseBody, type WorkloadServerToClientEvents, type WorkloadSuspendRunResponseBody, @@ -126,7 +125,7 @@ export class WorkloadServer extends EventEmitter { } private createHttpServer({ host, port }: { host: string; port: number }) { - return new HttpServer({ + const httpServer = new HttpServer({ port, host, metrics: { @@ -322,28 +321,6 @@ export class WorkloadServer extends EventEmitter { }, } ) - .route("/api/v1/workload-actions/runs/:runFriendlyId/snapshots/latest", "GET", { - paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }), - handler: async ({ req, reply, params }) => { - const latestSnapshotResponse = await this.workerClient.getLatestSnapshot( - params.runFriendlyId, - this.runnerIdFromRequest(req) - ); - - if (!latestSnapshotResponse.success) { - this.logger.error("Failed to get latest snapshot", { - runId: params.runFriendlyId, - error: latestSnapshotResponse.error, - }); - reply.empty(500); - return; - } - - reply.json({ - execution: latestSnapshotResponse.data.execution, - } satisfies WorkloadRunLatestSnapshotResponseBody); - }, - }) .route( "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/since/:snapshotFriendlyId", "GET", @@ -369,23 +346,6 @@ export class WorkloadServer extends EventEmitter { }, } ) - .route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", { - paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }), - bodySchema: WorkloadDebugLogRequestBody, - handler: async ({ req, reply, params, body }) => { - reply.empty(204); - - if (!env.SEND_RUN_DEBUG_LOGS) { - return; - } - - await this.workerClient.sendDebugLog( - params.runFriendlyId, - body, - this.runnerIdFromRequest(req) - ); - }, - }) .route("/api/v1/workload-actions/deployments/:deploymentId/dequeue", "GET", { paramsSchema: z.object({ deploymentId: z.string(), @@ -410,6 +370,31 @@ export class WorkloadServer extends EventEmitter { reply.json(dequeueResponse.data satisfies WorkloadDequeueFromVersionResponseBody); }, }); + + if (env.SEND_RUN_DEBUG_LOGS) { + httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", { + paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }), + bodySchema: WorkloadDebugLogRequestBody, + handler: async ({ req, reply, params, body }) => { + reply.empty(204); + + await this.workerClient.sendDebugLog( + params.runFriendlyId, + body, + this.runnerIdFromRequest(req) + ); + }, + }); + } else { + // Lightweight mock route without schemas + httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", { + handler: async ({ reply }) => { + reply.empty(204); + }, + }); + } + + return httpServer; } private createWebsocketServer() { diff --git a/apps/webapp/app/assets/icons/MoveToTopIcon.tsx b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx new file mode 100644 index 000000000..46938fd39 --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx @@ -0,0 +1,34 @@ +export function MoveToTopIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoveUpIcon.tsx b/apps/webapp/app/assets/icons/MoveUpIcon.tsx new file mode 100644 index 000000000..6e5d8a84b --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveUpIcon.tsx @@ -0,0 +1,41 @@ +export function MoveUpIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/DefinitionTooltip.tsx b/apps/webapp/app/components/DefinitionTooltip.tsx index 0e2d4d436..5bb3a7139 100644 --- a/apps/webapp/app/components/DefinitionTooltip.tsx +++ b/apps/webapp/app/components/DefinitionTooltip.tsx @@ -14,7 +14,7 @@ export function DefinitionTip({ return ( - + {children} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 718166b55..ab328afde 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -147,6 +147,12 @@ function ShortcutContent() { + + + + + +
Schedules page diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 99664b3dc..67e01af79 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -8,10 +8,12 @@ export function CopyableText({ value, copyValue, className, + asChild, }: { value: string; copyValue?: string; className?: string; + asChild?: boolean; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); @@ -35,6 +37,7 @@ export function CopyableText({ diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx index 38fd1525c..d0186268c 100644 --- a/apps/webapp/app/components/primitives/TextLink.tsx +++ b/apps/webapp/app/components/primitives/TextLink.tsx @@ -1,6 +1,10 @@ import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; import { Icon, type RenderIcon } from "./Icon"; +import { useRef } from "react"; +import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { ShortcutKey } from "./ShortcutKey"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; const variations = { primary: @@ -17,6 +21,9 @@ type TextLinkProps = { trailingIconClassName?: string; variant?: keyof typeof variations; children: React.ReactNode; + shortcut?: ShortcutDefinition; + hideShortcutKey?: boolean; + tooltip?: React.ReactNode; } & React.AnchorHTMLAttributes; export function TextLink({ @@ -27,20 +34,61 @@ export function TextLink({ trailingIcon, trailingIconClassName, variant = "primary", + shortcut, + hideShortcutKey, + tooltip, ...props }: TextLinkProps) { + const innerRef = useRef(null); const classes = variations[variant]; - return to ? ( - + + if (shortcut) { + useShortcutKeys({ + shortcut: shortcut, + action: () => { + if (innerRef.current) { + innerRef.current.click(); + } + }, + }); + } + + const renderShortcutKey = () => + shortcut && + !hideShortcutKey && ; + + const linkContent = ( + <> {children}{" "} {trailingIcon && } + {shortcut && !tooltip && renderShortcutKey()} + + ); + + const linkElement = to ? ( + + {linkContent} ) : href ? ( - - {children}{" "} - {trailingIcon && } + + {linkContent} ) : ( Need to define a path or href ); + + if (tooltip) { + return ( + + + {linkElement} + + {tooltip} {shortcut && renderShortcutKey()} + + + + ); + } + + return linkElement; } diff --git a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx index 6adea62f2..a2a6d199a 100644 --- a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx +++ b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx @@ -2,6 +2,7 @@ import { CheckCircleIcon, ExclamationTriangleIcon, NoSymbolIcon, + RectangleStackIcon, XCircleIcon, } from "@heroicons/react/20/solid"; import type { WorkerDeploymentStatus } from "@trigger.dev/database"; @@ -49,6 +50,10 @@ export function DeploymentStatusIcon({ }) { switch (status) { case "PENDING": + return ( + + ); + case "INSTALLING": case "BUILDING": case "DEPLOYING": return ; @@ -73,6 +78,8 @@ export function DeploymentStatusIcon({ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string { switch (status) { case "PENDING": + return "text-charcoal-500"; + case "INSTALLING": case "BUILDING": case "DEPLOYING": return "text-pending"; @@ -92,7 +99,9 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string { switch (status) { case "PENDING": - return "Pending…"; + return "Queued…"; + case "INSTALLING": + return "Installing…"; case "BUILDING": return "Building…"; case "DEPLOYING": @@ -121,17 +130,22 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b // PENDING and CANCELED are not used so are ommited from the UI export const deploymentStatuses: WorkerDeploymentStatus[] = [ + "PENDING", + "INSTALLING", "BUILDING", "DEPLOYING", "DEPLOYED", "FAILED", "TIMED_OUT", + "CANCELED", ]; export function deploymentStatusDescription(status: WorkerDeploymentStatus): string { switch (status) { case "PENDING": return "The deployment is queued and waiting to be processed."; + case "INSTALLING": + return "The project dependencies are being installed."; case "BUILDING": return "The code is being built and prepared for deployment."; case "DEPLOYING": diff --git a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx b/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx deleted file mode 100644 index 50df47809..000000000 --- a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { ArrowPathIcon } from "@heroicons/react/20/solid"; -import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useNavigation } from "@remix-run/react"; -import { Button } from "~/components/primitives/Buttons"; -import { - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, -} from "~/components/primitives/Dialog"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; - -type RollbackDeploymentDialogProps = { - projectId: string; - deploymentShortCode: string; - redirectPath: string; -}; - -export function RollbackDeploymentDialog({ - projectId, - deploymentShortCode, - redirectPath, -}: RollbackDeploymentDialogProps) { - const navigation = useNavigation(); - - const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`; - const isLoading = navigation.formAction === formAction; - - return ( - - Rollback to this deployment? - - This deployment will become the default for all future runs. Tasks triggered but not - included in this deploy will remain queued until you roll back to or create a new deployment - with these tasks included. - - - - - -
- -
-
-
- ); -} - -export function PromoteDeploymentDialog({ - projectId, - deploymentShortCode, - redirectPath, -}: RollbackDeploymentDialogProps) { - const navigation = useNavigation(); - - const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; - const isLoading = navigation.formAction === formAction; - - return ( - - Promote this deployment? - - This deployment will become the default for all future runs not explicitly tied to a - specific deployment. - - - - - -
- -
-
-
- ); -} diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 059195d54..77ae2e831 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -312,6 +312,10 @@ const EnvironmentSchema = z .number() .int() .default(60 * 1000 * 8), // 8 minutes + DEPLOY_QUEUE_TIMEOUT_MS: z.coerce + .number() + .int() + .default(60 * 1000 * 15), // 15 minutes OBJECT_STORE_BASE_URL: z.string().optional(), OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), @@ -519,8 +523,8 @@ const EnvironmentSchema = z RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(100), RUN_ENGINE_TIMEOUT_PENDING_EXECUTING: z.coerce.number().int().default(60_000), RUN_ENGINE_TIMEOUT_PENDING_CANCEL: z.coerce.number().int().default(60_000), - RUN_ENGINE_TIMEOUT_EXECUTING: z.coerce.number().int().default(60_000), - RUN_ENGINE_TIMEOUT_EXECUTING_WITH_WAITPOINTS: z.coerce.number().int().default(60_000), + RUN_ENGINE_TIMEOUT_EXECUTING: z.coerce.number().int().default(300_000), // 5 minutes + RUN_ENGINE_TIMEOUT_EXECUTING_WITH_WAITPOINTS: z.coerce.number().int().default(300_000), // 5 minutes RUN_ENGINE_TIMEOUT_SUSPENDED: z.coerce .number() .int() @@ -735,6 +739,7 @@ const EnvironmentSchema = z RUN_ENGINE_RUN_QUEUE_LOG_LEVEL: z .enum(["log", "error", "warn", "info", "debug"]) .default("info"), + RUN_ENGINE_TREAT_PRODUCTION_EXECUTION_STALLS_AS_OOM: z.string().default("0"), /** How long should the presence ttl last */ DEV_PRESENCE_SSE_TIMEOUT: z.coerce.number().int().default(30_000), @@ -1023,8 +1028,9 @@ const EnvironmentSchema = z TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"), TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute - QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000), - QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000), + DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000), + BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000), + QUEUES_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000), SLACK_BOT_TOKEN: z.string().optional(), SLACK_SIGNUP_REASON_CHANNEL_ID: z.string().optional(), diff --git a/apps/webapp/app/hooks/useAutoRevalidate.ts b/apps/webapp/app/hooks/useAutoRevalidate.ts new file mode 100644 index 000000000..4205b03bc --- /dev/null +++ b/apps/webapp/app/hooks/useAutoRevalidate.ts @@ -0,0 +1,48 @@ +import { useRevalidator } from "@remix-run/react"; +import { useEffect } from "react"; + +type UseAutoRevalidateOptions = { + interval?: number; // in milliseconds + onFocus?: boolean; + disabled?: boolean; +}; + +export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { + const { interval = 5000, onFocus = true, disabled = false } = options; + const revalidator = useRevalidator(); + + useEffect(() => { + if (!interval || interval <= 0 || disabled) return; + + const intervalId = setInterval(() => { + if (revalidator.state === "loading") { + return; + } + revalidator.revalidate(); + }, interval); + + return () => clearInterval(intervalId); + }, [interval, disabled]); + + useEffect(() => { + if (!onFocus || disabled) return; + + const handleFocus = () => { + if (document.visibilityState === "visible" && revalidator.state !== "loading") { + revalidator.revalidate(); + } + }; + + // Revalidate when the page becomes visible + document.addEventListener("visibilitychange", handleFocus); + // Revalidate when the window gains focus + window.addEventListener("focus", handleFocus); + + return () => { + document.removeEventListener("visibilitychange", handleFocus); + window.removeEventListener("focus", handleFocus); + }; + }, [onFocus, disabled]); + + return revalidator; +} diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index b22a53866..0b920e294 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -1,5 +1,5 @@ import { - Prisma, + type Prisma, type WorkerDeploymentStatus, type WorkerInstanceGroupType, } from "@trigger.dev/database"; @@ -9,6 +9,7 @@ import { type Project } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; import { processGitMetadata } from "./BranchesPresenter.server"; +import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github"; const pageSize = 20; @@ -56,6 +57,18 @@ export class DeploymentListPresenter { }, }, }, + connectedGithubRepository: { + select: { + branchTracking: true, + previewDeploymentsEnabled: true, + repository: { + select: { + htmlUrl: true, + fullName: true, + }, + }, + }, + }, }, where: { slug: projectSlug, @@ -131,7 +144,7 @@ export class DeploymentListPresenter { wd."git" FROM ${sqlDatabaseSchema}."WorkerDeployment" as wd -INNER JOIN +LEFT JOIN ${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id" WHERE wd."projectId" = ${project.id} @@ -140,9 +153,28 @@ ORDER BY string_to_array(wd."version", '.')::int[] DESC LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; + const { connectedGithubRepository } = project; + + const branchTrackingOrError = + connectedGithubRepository && + BranchTrackingConfigSchema.safeParse(connectedGithubRepository.branchTracking); + const environmentGitHubBranch = + branchTrackingOrError && branchTrackingOrError.success + ? getTrackedBranchForEnvironment( + branchTrackingOrError.data, + connectedGithubRepository.previewDeploymentsEnabled, + { + type: environment.type, + branchName: environment.branchName ?? undefined, + } + ) + : undefined; + return { currentPage: page, totalPages: Math.ceil(totalCount / pageSize), + connectedGithubRepository: project.connectedGithubRepository ?? undefined, + environmentGitHubBranch, deployments: deployments.map((deployment, index) => { const label = labeledDeployments.find( (labeledDeployment) => labeledDeployment.deploymentId === deployment.id diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index 35e6e4184..8387269cb 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -102,6 +102,10 @@ export class DeploymentPresenter { builtAt: true, deployedAt: true, createdAt: true, + startedAt: true, + installedAt: true, + canceledAt: true, + canceledReason: true, git: true, promotions: { select: { @@ -145,8 +149,12 @@ export class DeploymentPresenter { version: deployment.version, status: deployment.status, createdAt: deployment.createdAt, + startedAt: deployment.startedAt, + installedAt: deployment.installedAt, builtAt: deployment.builtAt, deployedAt: deployment.deployedAt, + canceledAt: deployment.canceledAt, + canceledReason: deployment.canceledReason, tasks: deployment.worker?.tasks, label: deployment.promotions?.[0]?.label, environment: { diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index d3ea20992..400e872a2 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -1,6 +1,6 @@ import { millisecondsToNanoseconds } from "@trigger.dev/core/v3"; import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView"; -import { prisma, PrismaClient } from "~/db.server"; +import { prisma, type PrismaClient } from "~/db.server"; import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents"; import { getUsername } from "~/utils/username"; import { eventRepository } from "~/v3/eventRepository.server"; @@ -58,7 +58,13 @@ export class RunPresenter { rootTaskRun: { select: { friendlyId: true, - taskIdentifier: true, + spanId: true, + createdAt: true, + }, + }, + parentTaskRun: { + select: { + friendlyId: true, spanId: true, createdAt: true, }, @@ -111,6 +117,7 @@ export class RunPresenter { completedAt: run.completedAt, logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt, rootTaskRun: run.rootTaskRun, + parentTaskRun: run.parentTaskRun, environment: { id: run.runtimeEnvironment.id, organizationId: run.runtimeEnvironment.organizationId, @@ -202,8 +209,6 @@ export class RunPresenter { trace: { rootSpanStatus, events: events, - parentRunFriendlyId: - tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId, duration: totalDuration, rootStartedAt: tree?.data.startTime, startedAt: run.startedAt, diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index a00ffa3f3..7919e075b 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -251,6 +251,7 @@ export class SpanPresenter extends BasePresenter { engine: run.engine, region, workerQueue: run.workerQueue, + traceId: run.traceId, spanId: run.spanId, isCached: !!span.originalRun, machinePreset: machine?.name, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 7726e6a9b..f356ee6c8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -130,7 +130,10 @@ export async function action({ request }: ActionFunctionArgs) { } const upsertBranchService = new UpsertBranchService(); - const result = await upsertBranchService.call(userId, submission.value); + const result = await upsertBranchService.call( + { type: "userMembership", userId }, + submission.value + ); if (result.success) { if (result.alreadyExisted) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index 197257624..0bd53caac 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -1,10 +1,9 @@ import { ArrowPathIcon } from "@heroicons/react/20/solid"; -import { Form, useRevalidator } from "@remix-run/react"; +import { Form } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import type { BulkActionType } from "@trigger.dev/database"; import { motion } from "framer-motion"; -import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; @@ -18,8 +17,9 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; import { UserAvatar } from "~/components/UserProfilePhoto"; +import { env } from "~/env.server"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { useEventSource } from "~/hooks/useEventSource"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; @@ -72,7 +72,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Error(error.message); } - return typedjson({ bulkAction: data }); + const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS; + + return typedjson({ bulkAction: data, autoReloadPollIntervalMs }); } catch (error) { console.error(error); throw new Response(undefined, { @@ -130,30 +132,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { bulkAction } = useTypedLoaderData(); + const { bulkAction, autoReloadPollIntervalMs } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const disabled = bulkAction.status !== "PENDING"; - - const streamedEvents = useEventSource( - `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.id}/runs/bulkaction/${bulkAction.friendlyId}/stream`, - { - event: "progress", - disabled, - } - ); - - const revalidation = useRevalidator(); - - useEffect(() => { - if (disabled || streamedEvents === null) { - return; - } - - revalidation.revalidate(); - }, [streamedEvents, disabled]); + useAutoRevalidate({ + interval: autoReloadPollIntervalMs, + onFocus: true, + disabled: bulkAction.status !== "PENDING", + }); return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 0575d7e75..63c0fc41a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -4,7 +4,6 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { GitMetadata } from "~/components/GitMetadata"; import { RuntimeIcon } from "~/components/RuntimeIcon"; -import { UserAvatar } from "~/components/UserProfilePhoto"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Badge } from "~/components/primitives/Badge"; @@ -32,6 +31,7 @@ import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder"; import { capitalizeWord } from "~/utils/string"; +import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -131,7 +131,11 @@ export default function Page() { Deploy {deployment.shortCode} - {deployment.label && {deployment.label}} + {deployment.label && ( + + {deployment.label} + + )} @@ -154,6 +158,22 @@ export default function Page() { /> + {deployment.canceledAt && ( + + Canceled at + + <> + UTC + + + + )} + {deployment.canceledReason && ( + + Cancelation reason + {deployment.canceledReason} + + )} Tasks {deployment.tasks ? deployment.tasks.length : "–"} @@ -187,7 +207,25 @@ export default function Page() { Started at - UTC + {deployment.startedAt ? ( + <> + UTC + + ) : ( + "–" + )} + + + + Installed at + + {deployment.installedAt ? ( + <> + UTC + + ) : ( + "–" + )} @@ -226,17 +264,16 @@ export default function Page() { Deployed by - {deployment.deployedBy ? ( -
- - - {deployment.deployedBy.name ?? deployment.deployedBy.displayName} - -
+ {deployment.git?.source === "trigger_github_app" ? ( + + ) : deployment.deployedBy ? ( + ) : ( "–" )} 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 4ae43e3e8..7f1f94dc3 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 @@ -1,11 +1,26 @@ -import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react"; +import { + ArrowPathIcon, + ArrowUturnLeftIcon, + BookOpenIcon, + NoSymbolIcon, +} from "@heroicons/react/20/solid"; +import { + Form, + type MetaFunction, + Outlet, + useLocation, + useNavigate, + useNavigation, + useParams, +} from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { CogIcon, GitBranchIcon } from "lucide-react"; import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PromoteIcon } from "~/assets/icons/PromoteIcon"; import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels"; +import { OctoKitty } from "~/components/GitHubLoginButton"; import { GitMetadata } from "~/components/GitMetadata"; import { RuntimeIcon } from "~/components/RuntimeIcon"; import { UserAvatar } from "~/components/UserProfilePhoto"; @@ -13,7 +28,15 @@ import { MainCenteredContainer, PageBody, PageContainer } from "~/components/lay import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; -import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { + Dialog, + DialogDescription, + DialogContent, + DialogHeader, + DialogTrigger, + DialogFooter, +} from "~/components/primitives/Dialog"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -37,10 +60,6 @@ import { deploymentStatusDescription, deploymentStatuses, } from "~/components/runs/v3/DeploymentStatus"; -import { - PromoteDeploymentDialog, - RollbackDeploymentDialog, -} from "~/components/runs/v3/RollbackDeploymentDialog"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; @@ -50,9 +69,18 @@ import { } from "~/presenters/v3/DeploymentListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { titleCase } from "~/utils"; -import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; +import { + EnvironmentParamSchema, + docsPath, + v3DeploymentPath, + v3ProjectSettingsPath, +} from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { env } from "~/env.server"; +import { DialogClose } from "@radix-ui/react-dialog"; export const meta: MetaFunction = () => { return [ @@ -108,7 +136,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ? result.deployments.find((d) => d.version === version) : undefined; - return typedjson({ ...result, selectedDeployment }); + const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS; + + return typedjson({ ...result, selectedDeployment, autoReloadPollIntervalMs }); } catch (error) { console.error(error); throw new Response(undefined, { @@ -122,14 +152,23 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { deployments, currentPage, totalPages, selectedDeployment } = - useTypedLoaderData(); + const { + deployments, + currentPage, + totalPages, + selectedDeployment, + connectedGithubRepository, + environmentGitHubBranch, + autoReloadPollIntervalMs, + } = useTypedLoaderData(); const hasDeployments = totalPages > 0; const { deploymentParam } = useParams(); const location = useLocation(); const navigate = useNavigate(); + useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); + // If we have a selected deployment from the version param, show it useEffect(() => { if (selectedDeployment && !deploymentParam) { @@ -160,8 +199,8 @@ export default function Page() { {hasDeployments ? ( -
- +
+
Deploy @@ -286,11 +325,38 @@ export default function Page() { )}
- {totalPages > 1 && ( -
- -
- )} +
+ {connectedGithubRepository && environmentGitHubBranch && ( +
+ + Automatically triggered by pushes to{" "} +
+ + {environmentGitHubBranch} +
{" "} + in + + {connectedGithubRepository.repository.fullName} + + +
+ )} + +
) : environment.type === "DEVELOPMENT" ? ( @@ -317,7 +383,7 @@ export default function Page() { ); } -function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) { +export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) { return (
@@ -347,7 +413,10 @@ function DeploymentActionsCell({ compareDeploymentVersions(deployment.version, currentDeployment.version) === -1; const canBePromoted = canBeMadeCurrent && !canBeRolledBack; - if (!canBeRolledBack && !canBePromoted) { + const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"]; + const canBeCanceled = !finalStatuses.includes(deployment.status); + + if (!canBeRolledBack && !canBePromoted && !canBeCanceled) { return ( {""} @@ -371,7 +440,7 @@ function DeploymentActionsCell({ fullWidth textAlignLeft > - Rollback… + Rollback - Promote… + Promote )} + {canBeCanceled && ( + + + + + + + )} } /> ); } + +type RollbackDeploymentDialogProps = { + projectId: string; + deploymentShortCode: string; + redirectPath: string; +}; + +function RollbackDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`; + const isLoading = navigation.formAction === formAction; + + return ( + + Rollback to this deployment? + + This deployment will become the default for all future runs. Tasks triggered but not + included in this deploy will remain queued until you roll back to or create a new deployment + with these tasks included. + + + + + +
+ +
+
+
+ ); +} + +function PromoteDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; + const isLoading = navigation.formAction === formAction; + + return ( + + Promote this deployment? + + This deployment will become the default for all future runs not explicitly tied to a + specific deployment. + + + + + +
+ +
+
+
+ ); +} + +function CancelDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`; + const isLoading = navigation.formAction === formAction; + + return ( + + Cancel this deployment? + Canceling a deployment cannot be undone. Are you sure? + + + + +
+ +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 95110490a..80d6855ce 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -8,14 +8,7 @@ import { RectangleStackIcon, } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { - Form, - useNavigate, - useNavigation, - useRevalidator, - useSearchParams, - type MetaFunction, -} from "@remix-run/react"; +import { Form, useNavigation, useSearchParams, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { useEffect, useState } from "react"; @@ -30,7 +23,7 @@ import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { BigNumber } from "~/components/metrics/BigNumber"; import { Badge } from "~/components/primitives/Badge"; -import { Button, ButtonVariant, LinkButton } from "~/components/primitives/Buttons"; +import { Button, type ButtonVariant, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -56,7 +49,6 @@ import { TooltipTrigger, } from "~/components/primitives/Tooltip"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { useEventSource } from "~/hooks/useEventSource"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; @@ -74,6 +66,8 @@ import { Header3 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; import { useThrottle } from "~/hooks/useThrottle"; import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { env } from "~/env.server"; const SearchParamsSchema = z.object({ query: z.string().optional(), @@ -121,9 +115,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const environmentQueuePresenter = new EnvironmentQueuePresenter(); + const autoReloadPollIntervalMs = env.QUEUES_AUTORELOAD_POLL_INTERVAL_MS; + return typedjson({ ...queues, environment: await environmentQueuePresenter.call(environment), + autoReloadPollIntervalMs, }); } catch (error) { console.error(error); @@ -217,28 +214,23 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { environment, queues, success, pagination, code, totalQueues, hasFilters } = - useTypedLoaderData(); + const { + environment, + queues, + success, + pagination, + code, + totalQueues, + hasFilters, + autoReloadPollIntervalMs, + } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const env = useEnvironment(); const plan = useCurrentPlan(); - // Reload the page periodically - const streamedEvents = useEventSource( - `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`, - { - event: "update", - } - ); - - const revalidation = useRevalidator(); - useEffect(() => { - if (streamedEvents) { - revalidation.revalidate(); - } - }, [streamedEvents]); + useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); const limitStatus = environment.running === environment.concurrencyLimit * environment.burstFactor 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 49a0a70c1..890ed0043 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -11,7 +11,7 @@ import { MagnifyingGlassPlusIcon, StopCircleIcon, } from "@heroicons/react/20/solid"; -import { useLoaderData, useParams, useRevalidator } from "@remix-run/react"; +import { useLoaderData, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime"; import { type Virtualizer } from "@tanstack/react-virtual"; import { @@ -25,7 +25,8 @@ import { motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; -import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon"; +import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon"; +import { MoveUpIcon } from "~/assets/icons/MoveUpIcon"; import tileBgPath from "~/assets/images/error-banner-tile@2x.png"; import { DevDisconnectedBanner, useCrossEngineIsConnected } from "~/components/DevPresence"; import { WarmStartIconWithTooltip } from "~/components/WarmStarts"; @@ -87,7 +88,6 @@ import { docsPath, v3BillingPath, v3RunParamsSchema, - v3RunPath, v3RunRedirectPath, v3RunSpanPath, v3RunStreamingPath, @@ -302,8 +302,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade return <>; } - const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt, queuedDuration } = - trace; + const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration } = trace; const shouldLiveReload = events.length <= maximumLiveReloadingSetting; const changeToSpan = useDebounce((selectedSpan: string) => { @@ -340,7 +339,6 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade selectedId={selectedSpanId} key={events[0]?.id ?? "-"} events={events} - parentRunFriendlyId={parentRunFriendlyId} onSelectedIdChanged={(selectedSpan) => { //instantly close the panel if no span is selected if (!selectedSpan) { @@ -358,6 +356,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade shouldLiveReload={shouldLiveReload} maximumLiveReloadingSetting={maximumLiveReloadingSetting} rootRun={run.rootTaskRun} + parentRun={run.parentTaskRun} isCompleted={run.completedAt !== null} /> @@ -476,7 +475,6 @@ function NoLogsView({ run, resizable }: LoaderData) { type TasksTreeViewProps = { events: TraceEvent[]; selectedId?: string; - parentRunFriendlyId?: string; onSelectedIdChanged: (selectedId: string | undefined) => void; totalDuration: number; rootSpanStatus: "executing" | "completed" | "failed"; @@ -487,7 +485,10 @@ type TasksTreeViewProps = { maximumLiveReloadingSetting: number; rootRun: { friendlyId: string; - taskIdentifier: string; + spanId: string; + } | null; + parentRun: { + friendlyId: string; spanId: string; } | null; isCompleted: boolean; @@ -496,7 +497,6 @@ type TasksTreeViewProps = { function TasksTreeView({ events, selectedId, - parentRunFriendlyId, onSelectedIdChanged, totalDuration, rootSpanStatus, @@ -506,6 +506,7 @@ function TasksTreeView({ shouldLiveReload, maximumLiveReloadingSetting, rootRun, + parentRun, isCompleted, }: TasksTreeViewProps) { const isAdmin = useHasAdminAccess(); @@ -596,20 +597,30 @@ function TasksTreeView({ id={resizableSettings.tree.tree.id} default={resizableSettings.tree.tree.default} min={resizableSettings.tree.tree.min} - className="pl-3" >
-
- {rootRun ? ( - + {rootRun || parentRun ? ( + - ) : parentRunFriendlyId ? ( - ) : ( - + This is the root task )} @@ -628,6 +639,7 @@ function TasksTreeView({ nodes={nodes} getNodeProps={getNodeProps} getTreeProps={getTreeProps} + parentClassName="pl-3" renderNode={({ node, state, index }) => ( <>
; } -function ShowParentLink({ - runFriendlyId, - spanId, - isRoot, +function ShowParentOrRootLinks({ + relationships, }: { - runFriendlyId: string; - spanId?: string; - isRoot: boolean; + relationships: { + root?: { + friendlyId: string; + spanId: string; + isParent?: boolean; + }; + parent?: { + friendlyId: string; + spanId: string; + }; + }; }) { - const [mouseOver, setMouseOver] = useState(false); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { spanParam } = useParams(); - const span = spanId ? spanId : spanParam; - - return ( - setMouseOver(true)} - onMouseLeave={() => setMouseOver(false)} - fullWidth - textAlignLeft - shortcut={{ key: "p" }} - className="flex-1" - > - {mouseOver ? ( - - ) : ( - - )} - + Jump to root and parent run + +
+ } + className="text-xs" > - {isRoot ? "Show root run" : "Show parent run"} -
- + Root/parent + + ); + } + + // Case 2: Root and Parent are different runs + return ( +
+ {relationships.root && ( + + Jump to root run + +
+ } + className="text-xs" + > + Root + + )} + {relationships.parent && ( + + Jump to parent run + +
+ } + className="text-xs" + > + Parent + + )} +
); } 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 db6f641f5..1ba5f2640 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,17 +1,35 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; +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 { 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 } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -25,13 +43,40 @@ import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/Page import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { prisma } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { DeleteProjectService } from "~/services/deleteProject.server"; +import { + redirectBackWithErrorMessage, + redirectBackWithSuccessMessage, + redirectWithErrorMessage, + redirectWithSuccessMessage, +} from "~/models/message.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; +import { + organizationPath, + v3ProjectPath, + githubAppInstallPath, + EnvironmentParamSchema, + v3ProjectSettingsPath, +} 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"; export const meta: MetaFunction = () => { return [ @@ -41,6 +86,98 @@ export const meta: MetaFunction = () => { ]; }; +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); + + const projectSettingsPresenter = new ProjectSettingsPresenter(); + const resultOrFail = await projectSettingsPresenter.getProjectSettings( + organizationSlug, + projectParam, + userId + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "project_not_found": { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed loading project settings", { + error: resultOrFail.error, + }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, please try again!", + }); + } + } + } + + const { gitHubApp, buildSettings } = resultOrFail.value; + + return typedjson({ + githubAppEnabled: gitHubApp.enabled, + githubAppInstallations: gitHubApp.installations, + connectedGithubRepository: gitHubApp.connectedRepository, + 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 + .string() + .trim() + .optional() + .transform((val) => (val ? val.replace(/^\/+/, "") : val)) + .refine((val) => !val || val.length <= 255, { + message: "Config file path must not exceed 255 characters", + }), + installDirectory: z + .string() + .trim() + .optional() + .transform((val) => (val ? val.replace(/^\/+/, "") : val)) + .refine((val) => !val || val.length <= 255, { + message: "Install directory must not exceed 255 characters", + }), + installCommand: z + .string() + .trim() + .optional() + .refine((val) => !val || !val.includes("\n"), { + message: "Install command must be a single line", + }) + .refine((val) => !val || val.length <= 500, { + message: "Install command must not exceed 500 characters", + }), +}); + +type UpdateBuildSettingsFormSchema = z.infer; + export function createSchema( constraints: { getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; @@ -72,6 +209,12 @@ export function createSchema( } }), }), + ConnectGitHubRepoFormSchema, + UpdateGitSettingsFormSchema, + UpdateBuildSettingsFormSchema, + z.object({ + action: z.literal("disconnect-repo"), + }), ]); } @@ -95,63 +238,211 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission); } - try { - switch (submission.value.action) { - case "rename": { - await prisma.project.update({ - where: { - slug: projectParam, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - data: { - name: submission.value.projectName, - }, - }); + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); - return redirectWithSuccessMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project renamed to ${submission.value.projectName}` - ); - } - case "delete": { - const deleteProjectService = new DeleteProjectService(); - try { - await deleteProjectService.call({ projectSlug: projectParam, userId }); + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - "Project deleted" - ); - } catch (error: unknown) { - logger.error("Project could not be deleted", { - error: error instanceof Error ? error.message : JSON.stringify(error), - }); - return redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project ${projectParam} could not be deleted` - ); + const { projectId, organizationId } = membershipResultOrFail.value; + + switch (submission.value.action) { + case "rename": { + const resultOrFail = await projectSettingsService.renameProject( + projectId, + submission.value.projectName + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to rename project", { + error: resultOrFail.error, + }); + return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); + } } } + + return redirectWithSuccessMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project renamed to ${submission.value.projectName}` + ); + } + case "delete": { + const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to delete project", { + error: resultOrFail.error, + }); + return redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project ${projectParam} could not be deleted` + ); + } + } + } + + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + "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 { installDirectory, installCommand, triggerConfigFilePath } = submission.value; + + const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { + installDirectory: installDirectory || undefined, + installCommand: installCommand || undefined, + triggerConfigFilePath: triggerConfigFilePath || undefined, + }); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to update build settings", { + error: resultOrFail.error, + }); + return redirectBackWithErrorMessage(request, "Failed to update build settings"); + } + } + } + + return redirectBackWithSuccessMessage(request, "Build settings updated successfully"); + } + default: { + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); } - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); } }; export default function Page() { + const { githubAppInstallations, connectedGithubRepository, githubAppEnabled, buildSettings } = + useTypedLoaderData(); const project = useProject(); + const organization = useOrganization(); + const environment = useEnvironment(); const lastSubmission = useActionData(); const navigation = useNavigation(); + const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); + const [renameForm, { projectName }] = useForm({ id: "rename-project", // TODO: type this @@ -187,10 +478,12 @@ export default function Page() { navigation.formData?.get("action") === "delete" && (navigation.state === "submitting" || navigation.state === "loading"); + const [deleteInputValue, setDeleteInputValue] = useState(""); + return ( - + @@ -212,91 +505,122 @@ export default function Page() { - -
- Project settings -
+
-
- - - - - This goes in your{" "} - trigger.config file. - - -
+
+ General +
+
+ + + + + This goes in your{" "} + trigger.config file. + + +
+
+
+ + + { + setHasRenameFormChanges(e.target.value !== project.name); + }} + /> + {projectName.error} + + + Save + + } + /> +
+
+
+
-
- -
- - - - {projectName.error} - - - Rename project - - } - className="border-t-0" - /> -
-
+ {githubAppEnabled && ( + +
+ Git settings +
+ {connectedGithubRepository ? ( + + ) : ( + + )} +
+
+ +
+ Build settings +
+ +
+
+
+ )}
Danger zone -
- -
- - - + +
+ + + setDeleteInputValue(e.target.value)} + /> + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete + + } /> - {projectSlug.error} - {deleteForm.error} - - This change is irreversible, so please be certain. Type in the Project slug - {project.slug} and then press - Delete. - - - - Delete project - - } - /> -
- +
+ +
@@ -304,3 +628,580 @@ 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, +}: { + connectedGitHubRepo: ConnectedGitHubRepo; +}) { + 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"); + + 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 commit on 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, + })); + }} + /> +
+ {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(); + + const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); + const [buildSettingsValues, setBuildSettingsValues] = useState({ + installDirectory: buildSettings?.installDirectory || "", + installCommand: buildSettings?.installCommand || "", + triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "", + }); + + useEffect(() => { + const hasChanges = + buildSettingsValues.installDirectory !== (buildSettings?.installDirectory || "") || + buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || + buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || ""); + setHasBuildSettingsChanges(hasChanges); + }, [buildSettingsValues, buildSettings]); + + const [buildSettingsForm, fields] = useForm({ + id: "update-build-settings", + lastSubmission: lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: UpdateBuildSettingsFormSchema, + }); + }, + }); + + const isBuildSettingsLoading = + navigation.formData?.get("action") === "update-build-settings" && + (navigation.state === "submitting" || navigation.state === "loading"); + + return ( +
+
+ + + { + setBuildSettingsValues((prev) => ({ + ...prev, + triggerConfigFilePath: e.target.value, + })); + }} + /> + + Path to your Trigger configuration file, relative to the root directory of your repo. + + + {fields.triggerConfigFilePath.error} + + + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + installCommand: e.target.value, + })); + }} + /> + Command to install your project dependencies. Auto-detected by default. + {fields.installCommand.error} + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + installDirectory: e.target.value, + })); + }} + /> + The directory where the install command is run in. Auto-detected by default. + + {fields.installDirectory.error} + + + {buildSettingsForm.error} + + Save + + } + /> +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx index 844c5d66e..37401263c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx @@ -1,7 +1,7 @@ -import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { BackgroundWrapper } from "~/components/BackgroundWrapper"; -import { AppContainer } from "~/components/layout/AppLayout"; +import { AppContainer, MainBody, PageBody } from "~/components/layout/AppLayout"; import { Header1 } from "~/components/primitives/Headers"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; @@ -49,22 +49,24 @@ export default function ChoosePlanPage() { useTypedLoaderData(); return ( - - -
- Subscribe for full access -
- + + + +
+ Subscribe for full access +
+ +
-
- + + ); } diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts new file mode 100644 index 000000000..dd209d449 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts @@ -0,0 +1,72 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { authenticateRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { DeploymentService } from "~/v3/services/deployment.server"; + +const ParamsSchema = z.object({ + deploymentId: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid params" }, { status: 400 }); + } + + const authenticationResult = await authenticateRequest(request, { + apiKey: true, + organizationAccessToken: false, + personalAccessToken: false, + }); + + if (!authenticationResult || !authenticationResult.result.ok) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const { environment: authenticatedEnv } = authenticationResult.result; + const { deploymentId } = parsedParams.data; + + const [, rawBody] = await tryCatch(request.json()); + const body = CancelDeploymentRequestBody.safeParse(rawBody ?? {}); + + if (!body.success) { + return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 }); + } + + const deploymentService = new DeploymentService(); + + return await deploymentService + .cancelDeployment(authenticatedEnv, deploymentId, { + canceledReason: body.data.reason, + }) + .match( + () => { + return new Response(null, { status: 204 }); + }, + (error) => { + switch (error.type) { + case "deployment_not_found": + return json({ error: "Deployment not found" }, { status: 404 }); + case "failed_to_delete_deployment_timeout": + return new Response(null, { status: 204 }); // not a critical error, ignore + case "deployment_cannot_be_cancelled": + return json( + { error: "Deployment is already in a final state and cannot be canceled" }, + { status: 409 } + ); + case "other": + default: + error.type satisfies "other"; + return json({ error: "Internal server error" }, { status: 500 }); + } + } + ); +} diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts new file mode 100644 index 000000000..beb0fcd7c --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts @@ -0,0 +1,76 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { ProgressDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { authenticateRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { DeploymentService } from "~/v3/services/deployment.server"; + +const ParamsSchema = z.object({ + deploymentId: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid params" }, { status: 400 }); + } + + const authenticationResult = await authenticateRequest(request, { + apiKey: true, + organizationAccessToken: false, + personalAccessToken: false, + }); + + if (!authenticationResult || !authenticationResult.result.ok) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const { environment: authenticatedEnv } = authenticationResult.result; + const { deploymentId } = parsedParams.data; + + const [, rawBody] = await tryCatch(request.json()); + const body = ProgressDeploymentRequestBody.safeParse(rawBody ?? {}); + + if (!body.success) { + return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 }); + } + + const deploymentService = new DeploymentService(); + + return await deploymentService + .progressDeployment(authenticatedEnv, deploymentId, { + contentHash: body.data.contentHash, + git: body.data.gitMeta, + runtime: body.data.runtime, + }) + .match( + () => { + return new Response(null, { status: 204 }); + }, + (error) => { + switch (error.type) { + case "failed_to_extend_deployment_timeout": + return new Response(null, { status: 204 }); // ignore these errors for now + case "deployment_not_found": + return json({ error: "Deployment not found" }, { status: 404 }); + case "deployment_cannot_be_progressed": + return json( + { error: "Deployment is not in a progressable state (PENDING or INSTALLING)" }, + { status: 409 } + ); + case "failed_to_create_remote_build": + return json({ error: "Failed to create remote build" }, { status: 500 }); + case "other": + default: + error.type satisfies "other"; + return json({ error: "Internal server error" }, { status: 500 }); + } + } + ); +} diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index 643b1f8a1..ca3417b75 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -1,4 +1,5 @@ -import { LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; @@ -52,7 +53,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { shortCode: deployment.shortCode, version: deployment.version, imageReference: deployment.imageReference, - errorData: deployment.errorData, + imagePlatform: deployment.imagePlatform, + externalBuildData: + deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"], + errorData: deployment.errorData as GetDeploymentResponseBody["errorData"], worker: deployment.worker ? { id: deployment.worker.friendlyId, @@ -65,5 +69,5 @@ export async function loader({ request, params }: LoaderFunctionArgs) { })), } : undefined, - }); + } satisfies GetDeploymentResponseBody); } diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index c80e180d8..8b3280cbb 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -1,8 +1,8 @@ -import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { ApiDeploymentListSearchParams, InitializeDeploymentRequestBody, - InitializeDeploymentResponseBody, + type InitializeDeploymentResponseBody, } from "@trigger.dev/core/v3"; import { $replica } from "~/db.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts index 76147979c..64119b5a4 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts @@ -2,9 +2,9 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { z } from "zod"; import { prisma } from "~/db.server"; +import { authenticateRequest } from "~/services/apiAuth.server"; import { ArchiveBranchService } from "~/services/archiveBranch.server"; import { logger } from "~/services/logger.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -21,7 +21,12 @@ export async function action({ request, params }: ActionFunctionArgs) { logger.info("Archive branch", { url: request.url, params }); - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); + if (!authenticationResult) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); } @@ -50,13 +55,16 @@ export async function action({ request, params }: ActionFunctionArgs) { archivedAt: true, }, where: { - organization: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, + organization: + authenticationResult.type === "organizationAccessToken" + ? { id: authenticationResult.result.organizationId } + : { + members: { + some: { + userId: authenticationResult.result.userId, + }, + }, + }, project: { externalRef: projectRef, }, @@ -74,9 +82,14 @@ export async function action({ request, params }: ActionFunctionArgs) { } const service = new ArchiveBranchService(); - const result = await service.call(authenticationResult.userId, { - environmentId: environment.id, - }); + const result = await service.call( + authenticationResult.type === "organizationAccessToken" + ? { type: "orgId", organizationId: authenticationResult.result.organizationId } + : { type: "userMembership", userId: authenticationResult.result.userId }, + { + environmentId: environment.id, + } + ); if (result.success) { return json(result); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts index 6ae6a133e..8678ef1f9 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts @@ -1,7 +1,8 @@ -import { json, LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; +import { authenticateRequest } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; import { UpsertBranchService } from "~/services/upsertBranch.server"; @@ -19,7 +20,11 @@ export async function action({ request, params }: ActionFunctionArgs) { logger.info("project upsert branch", { url: request.url }); - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); } @@ -38,13 +43,16 @@ export async function action({ request, params }: ActionFunctionArgs) { }, where: { externalRef: projectRef, - organization: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, + organization: + authenticationResult.type === "organizationAccessToken" + ? { id: authenticationResult.result.organizationId } + : { + members: { + some: { + userId: authenticationResult.result.userId, + }, + }, + }, }, }); if (!project) { @@ -81,11 +89,16 @@ export async function action({ request, params }: ActionFunctionArgs) { const { branch, env, git } = parsed.data; const service = new UpsertBranchService(); - const result = await service.call(authenticationResult.userId, { - branchName: branch, - parentEnvironmentId: previewEnvironment.id, - git, - }); + const result = await service.call( + authenticationResult.type === "organizationAccessToken" + ? { type: "orgId", organizationId: authenticationResult.result.organizationId } + : { type: "userMembership", userId: authenticationResult.result.userId }, + { + branchName: branch, + parentEnvironmentId: previewEnvironment.id, + git, + } + ); if (!result.success) { return json({ error: result.error }, { status: 400 }); diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index 11613427a..129bf4c3c 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -1,3 +1,4 @@ +import { EngineServiceValidationError } from "@internal/run-engine"; import { json } from "@remix-run/server-runtime"; import { generateJWT as internal_generateJWT, @@ -8,7 +9,6 @@ import { TaskRun } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { EngineServiceValidationError } from "~/runEngine/concerns/errors"; import { ApiAuthenticationResultSuccess, getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts new file mode 100644 index 000000000..c802d115a --- /dev/null +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts @@ -0,0 +1,133 @@ +import { parse } from "@conform-to/zod"; +import { type ActionFunction, json } from "@remix-run/node"; +import { errAsync, fromPromise, okAsync } from "neverthrow"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { DeploymentService } from "~/v3/services/deployment.server"; + +export const cancelSchema = z.object({ + redirectUrl: z.string(), +}); + +const ParamSchema = z.object({ + projectId: z.string(), + deploymentShortCode: z.string(), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { projectId, deploymentShortCode } = ParamSchema.parse(params); + + const formData = await request.formData(); + const submission = parse(formData, { schema: cancelSchema }); + + if (!submission.value) { + return json(submission); + } + + const verifyProjectMembership = () => + fromPromise( + prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + select: { + id: true, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((project) => { + if (!project) { + return errAsync({ type: "project_not_found" as const }); + } + return okAsync(project); + }); + + const findDeploymentFriendlyId = ({ id }: { id: string }) => + fromPromise( + prisma.workerDeployment.findUnique({ + select: { + friendlyId: true, + projectId: true, + }, + where: { + projectId_shortCode: { + projectId: id, + shortCode: deploymentShortCode, + }, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((deployment) => { + if (!deployment) { + return errAsync({ type: "deployment_not_found" as const }); + } + return okAsync(deployment); + }); + + const deploymentService = new DeploymentService(); + const result = await verifyProjectMembership() + .andThen(findDeploymentFriendlyId) + .andThen((deployment) => + deploymentService.cancelDeployment({ projectId: deployment.projectId }, deployment.friendlyId) + ); + + if (result.isErr()) { + logger.error( + `Failed to cancel deployment: ${result.error.type}`, + result.error.type === "other" + ? { + cause: result.error.cause, + } + : undefined + ); + + switch (result.error.type) { + case "project_not_found": + return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); + case "deployment_not_found": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + case "deployment_cannot_be_cancelled": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment is already in a final state and cannot be canceled" + ); + case "failed_to_delete_deployment_timeout": + // not a critical error, ignore + return redirectWithSuccessMessage( + submission.value.redirectUrl, + request, + `Canceled deployment ${deploymentShortCode}.` + ); + case "other": + default: + result.error.type satisfies "other"; + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Internal server error" + ); + } + } + + return redirectWithSuccessMessage( + submission.value.redirectUrl, + request, + `Canceled deployment ${deploymentShortCode}.` + ); +}; diff --git a/apps/webapp/app/routes/resources.branches.archive.tsx b/apps/webapp/app/routes/resources.branches.archive.tsx index 6658738ce..57ba061bf 100644 --- a/apps/webapp/app/routes/resources.branches.archive.tsx +++ b/apps/webapp/app/routes/resources.branches.archive.tsx @@ -37,7 +37,12 @@ export async function action({ request }: ActionFunctionArgs) { const archiveBranchService = new ArchiveBranchService(); - const result = await archiveBranchService.call(userId, submission.value); + const result = await archiveBranchService.call( + { type: "userMembership", userId }, + { + environmentId: submission.value.environmentId, + } + ); if (result.success) { return redirectWithSuccessMessage( diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx deleted file mode 100644 index b4104dfe3..000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { $replica } from "~/db.server"; -import { env } from "~/env.server"; -import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; -import { createSSELoader } from "~/utils/sse"; - -export const loader = createSSELoader({ - timeout: env.QUEUE_SSE_AUTORELOAD_TIMEOUT_MS, - interval: env.QUEUE_SSE_AUTORELOAD_INTERVAL_MS, - debug: false, - handler: async ({ request, params }) => { - const userId = await requireUserId(request); - const { projectParam, envParam } = EnvironmentParamSchema.parse(params); - - const environment = await $replica.runtimeEnvironment.findFirst({ - where: { - slug: envParam, - OR: [ - { - type: { - in: ["PREVIEW", "STAGING", "PRODUCTION"], - }, - }, - { - type: "DEVELOPMENT", - orgMember: { - userId, - }, - }, - ], - project: { - slug: projectParam, - }, - }, - }); - - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } - - return { - beforeStream: async () => { - logger.debug("Start queue page SSE session", { - environmentId: environment.id, - }); - }, - initStream: async ({ send }) => { - send({ event: "time", data: new Date().toISOString() }); - }, - iterator: async ({ send }) => { - send({ - event: "update", - data: new Date().toISOString(), - }); - }, - cleanup: async () => { - logger.debug("End queue page SSE session", { - environmentId: environment.id, - }); - }, - }; - }, -}); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 6a4e29476..66d166294 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -14,6 +14,7 @@ import { assertNever } from "assert-never"; import { useEffect } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { FlagIcon } from "~/assets/icons/RegionIcons"; import { AdminDebugRun } from "~/components/admin/debugRun"; import { CodeBlock } from "~/components/code/CodeBlock"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -22,6 +23,7 @@ import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { MachineTooltipInfo } from "~/components/MachineTooltipInfo"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -76,7 +78,6 @@ import { } from "~/utils/pathBuilder"; import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents"; import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; -import { FlagIcon } from "~/assets/icons/RegionIcons"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { projectParam, organizationSlug, envParam, runParam, spanParam } = @@ -409,6 +410,7 @@ function RunBody({ } content={descriptionForTaskRunStatus(run.status)} + disableHoverableContent /> @@ -422,82 +424,129 @@ function RunBody({ tasks: [run.taskIdentifier], })} > - {run.taskIdentifier} + } - content={`Filter runs by ${run.taskIdentifier}`} + content={`View runs filtered by ${run.taskIdentifier}`} + disableHoverableContent /> {run.relationships.root ? ( run.relationships.root.isParent ? ( - Root & Parent + Root & Parent run - - {run.relationships.root.taskIdentifier} - - ({run.relationships.root.friendlyId}) - - - - - ) : ( - <> - - Root - - - {run.relationships.root.taskIdentifier} - - ({run.relationships.root.friendlyId}) - - - - - {run.relationships.parent ? ( - - Parent - + - {run.relationships.parent.taskIdentifier} + - ({run.relationships.parent.friendlyId}) + + } + content={`Jump to root/parent run`} + disableHoverableContent + /> + + + ) : ( + <> + + Root run + + + + + + + + } + content={`Jump to root run`} + disableHoverableContent + /> + + + {run.relationships.parent ? ( + + Parent run + + + + + + + + } + content={`Jump to parent run`} + disableHoverableContent + /> ) : null} @@ -511,10 +560,15 @@ function RunBody({ - {run.batch.friendlyId} + } - content={`Jump to ${run.batch.friendlyId}`} + content={`View batches filtered by ${run.batch.friendlyId}`} + disableHoverableContent /> @@ -540,7 +594,7 @@ function RunBody({ {run.version ? ( environment.type === "DEVELOPMENT" ? ( - run.version + ) : ( - {run.version} + } content={"Jump to deployment"} @@ -606,13 +660,23 @@ function RunBody({ Replayed from - - {run.replayedFromTaskRunFriendlyId} - + + + + } + content={`Jump to replayed run`} + disableHoverableContent + /> )} @@ -747,11 +811,15 @@ function RunBody({ Run ID - {run.friendlyId} + + + Internal ID - {run.id} + + + Run Engine @@ -772,6 +840,14 @@ function RunBody({ Worker queue {run.workerQueue} + + Trace ID + {run.traceId} + + + Span ID + {run.spanId} +
)} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx deleted file mode 100644 index b46cbc385..000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { z } from "zod"; -import { $replica } from "~/db.server"; -import { env } from "~/env.server"; -import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; -import { createSSELoader, type SendFunction } from "~/utils/sse"; - -const Params = EnvironmentParamSchema.extend({ - bulkActionParam: z.string(), -}); - -export const loader = createSSELoader({ - timeout: env.DEV_PRESENCE_SSE_TIMEOUT, - interval: env.DEV_PRESENCE_POLL_MS, - debug: false, - handler: async ({ id, controller, debug, request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, bulkActionParam } = Params.parse(params); - - const environment = await $replica.runtimeEnvironment.findFirst({ - where: { - id: envParam, - project: { - slug: projectParam, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - }, - }); - - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } - - const getBulkActionProgress = async (send: SendFunction) => { - try { - const bulkAction = await $replica.bulkActionGroup.findFirst({ - select: { - status: true, - successCount: true, - failureCount: true, - }, - where: { - friendlyId: bulkActionParam, - environmentId: environment.id, - }, - }); - - send({ - event: "progress", - data: JSON.stringify({ - status: bulkAction?.status, - successCount: bulkAction?.successCount, - failureCount: bulkAction?.failureCount, - }), - }); - - return bulkAction; - } catch (error) { - // Handle the case where the controller is closed - logger.debug("Failed to send bulk action progress data, stream might be closed", { error }); - return null; - } - }; - - return { - beforeStream: async () => { - logger.debug("Start dev presence listening SSE session", { - environmentId: environment.id, - }); - }, - initStream: async ({ send }) => { - const bulkAction = await getBulkActionProgress(send); - - send({ event: "time", data: new Date().toISOString() }); - - if (bulkAction?.status !== "PENDING") { - return false; - } - - return true; - }, - iterator: async ({ send, date }) => { - const bulkAction = await getBulkActionProgress(send); - - if (bulkAction?.status !== "PENDING") { - return false; - } - - return true; - }, - cleanup: async ({ send }) => { - await getBulkActionProgress(send); - }, - }; - }, -}); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 8299d775f..8f511f0b0 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -171,6 +171,10 @@ const pricingDefinitions = { title: "Concurrent runs", content: "The number of runs that can be executed at the same time.", }, + additionalConcurrency: { + title: "Additional concurrency", + content: "Then $50/month per 50", + }, taskRun: { title: "Task runs", content: "A single execution of a task.", @@ -188,6 +192,10 @@ const pricingDefinitions = { title: "Schedules", content: "You can attach recurring schedules to tasks using cron syntax.", }, + additionalSchedules: { + title: "Additional schedules", + content: "Then $10/month per 1,000", + }, alerts: { title: "Alert destination", content: @@ -198,9 +206,22 @@ const pricingDefinitions = { content: "Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.", }, + additionalRealtimeConnections: { + title: "Additional Realtime connections", + content: "Then $10/month per 100", + }, + additionalSeats: { + title: "Additional seats", + content: "Then $20/month per seat", + }, branches: { title: "Branches", - content: "The number of preview branches that can be active (you can archive old ones).", + content: + "Preview branches allow you to test changes before deploying to production. You can have a limited number active at once (but can archive old ones).", + }, + additionalBranches: { + title: "Additional branches", + content: "Then $10/month per branch", }, }; @@ -338,7 +359,7 @@ export function TierFree({
@@ -411,6 +432,7 @@ export function TierFree({ Why are you thinking of downgrading?
    {[ + "The Free plan is all I need", "Subscription or usage costs too expensive", "Bugs or technical issues", "No longer need the service", @@ -445,7 +467,7 @@ export function TierFree({
- @@ -568,11 +590,11 @@ export function TierHobby({ - @@ -682,7 +704,7 @@ export function TierPro({ -