import { Link, useLocation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { useEffect, useState, useRef, useCallback } from "react"; import { S2, S2Error } from "@s2-dev/streamstore"; import { Clipboard, ClipboardCheck, ChevronDown, ChevronUp, TerminalSquareIcon, LayoutDashboardIcon, GitBranchIcon, ServerIcon, } from "lucide-react"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { GitMetadata } from "~/components/GitMetadata"; import { VercelLink } from "~/components/integrations/VercelLink"; import { RuntimeIcon } from "~/components/RuntimeIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Badge } from "~/components/primitives/Badge"; import { LinkButton } from "~/components/primitives/Buttons"; import { DateTimeAccurate } from "~/components/primitives/DateTime"; import { Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; import { DeploymentError } from "~/components/runs/v3/DeploymentError"; import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "~/components/primitives/Tooltip"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server"; 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"; import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam, deploymentParam } = v3DeploymentParams.parse(params); try { const presenter = new DeploymentPresenter(); const { deployment, eventStream } = await presenter.call({ userId, organizationSlug, projectSlug: projectParam, environmentSlug: envParam, deploymentShortCode: deploymentParam, }); return typedjson({ deployment, eventStream }); } catch (error) { console.error(error); throw new Response(undefined, { status: 400, statusText: "Something went wrong, if this problem persists please contact support.", }); } }; type LogEntry = { message: string; timestamp: Date; level: "info" | "error" | "warn" | "debug"; }; function getTriggeredViaDisplay(triggeredVia: string | null | undefined): { icon: React.ReactNode; label: string; } | null { if (!triggeredVia) return null; const iconClass = "size-4 text-text-dimmed"; switch (triggeredVia) { case "cli:manual": return { icon: , label: "CLI (Manual)", }; case "cli:github_actions": return { icon: , label: "CLI (GitHub Actions)", }; case "cli:gitlab_ci": return { icon: , label: "CLI (GitLab CI)", }; case "cli:circleci": return { icon: , label: "CLI (CircleCI)", }; case "cli:jenkins": return { icon: , label: "CLI (Jenkins)", }; case "cli:azure_pipelines": return { icon: , label: "CLI (Azure Pipelines)", }; case "cli:bitbucket_pipelines": return { icon: , label: "CLI (Bitbucket Pipelines)", }; case "cli:travis_ci": return { icon: , label: "CLI (Travis CI)", }; case "cli:buildkite": return { icon: , label: "CLI (Buildkite)", }; case "cli:ci_other": return { icon: , label: "CLI (CI)", }; case "git_integration:github": return { icon: , label: "GitHub Integration", }; case "dashboard": return { icon: , label: "Dashboard", }; default: // Handle any unknown values gracefully if (triggeredVia.startsWith("cli:")) { return { icon: , label: `CLI (${triggeredVia.replace("cli:", "")})`, }; } return { icon: , label: triggeredVia, }; } } export default function Page() { const { deployment, eventStream } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const location = useLocation(); const page = new URLSearchParams(location.search).get("page"); const logsDisabled = eventStream === undefined; const [logs, setLogs] = useState([]); const [isStreaming, setIsStreaming] = useState(true); const [streamError, setStreamError] = useState(null); const isPending = deployment.status === "PENDING"; useEffect(() => { if (logsDisabled) return; const abortController = new AbortController(); setLogs([]); setStreamError(null); setIsStreaming(true); const streamLogs = async () => { try { const s2 = new S2({ accessToken: eventStream.s2.accessToken }); const basin = s2.basin(eventStream.s2.basin); const stream = basin.stream(eventStream.s2.stream); const readSession = await stream.readSession( { start: { from: { seqNum: 0 }, clamp: true }, stop: { waitSecs: 60 }, }, { signal: abortController.signal } ); for await (const record of readSession) { const decoded = record.body; const result = DeploymentEventFromString.safeParse(decoded); if (!result.success) { // fallback to the previous format in s2 logs for compatibility try { const headers: Record = {}; if (record.headers) { for (const [name, value] of record.headers) { headers[name] = value; } } const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info"; setLogs((prevLogs) => [ ...prevLogs, { timestamp: new Date(record.timestamp), message: decoded, level, }, ]); } catch (err) { console.error("Failed to parse log record:", err); } continue; } const event = result.data; if (event.type !== "log") { continue; } setLogs((prevLogs) => [ ...prevLogs, { timestamp: new Date(record.timestamp), message: event.data.message, level: event.data.level, }, ]); } } catch (error) { if (abortController.signal.aborted) return; const isNotFoundError = error instanceof S2Error && error.code && ["permission_denied", "stream_not_found"].includes(error.code); if (isNotFoundError) return; console.error("Failed to stream logs:", error); setStreamError("Failed to stream logs"); } finally { if (!abortController.signal.aborted) { setIsStreaming(false); } } }; streamLogs(); return () => { abortController.abort(); }; }, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]); return ( Deploy: {deployment.shortCode} ID {deployment.id} Project ID {deployment.projectId} Org ID {deployment.organizationId} {deployment.imageReference && ( Image {deployment.imageReference} )} Platform {deployment.imagePlatform} {deployment.externalBuildData && ( Build Server {deployment.externalBuildData.buildId} )} Deploy {deployment.shortCode} {deployment.label && ( {deployment.label} )} Environment Version {deployment.version} Status {!logsDisabled && ( Logs )} {deployment.canceledAt && ( Canceled at <> UTC > )} {deployment.canceledReason && ( Cancelation reason {deployment.canceledReason} )} Tasks {deployment.tasks ? deployment.tasks.length : "–"} SDK Version {deployment.sdkVersion ? deployment.sdkVersion : "–"} CLI Version {deployment.cliVersion ? deployment.cliVersion : "–"} Runtime Worker type {capitalizeWord(deployment.type)} Started at {deployment.startedAt ? ( <> UTC > ) : ( "–" )} Installed at {deployment.installedAt ? ( <> UTC > ) : ( "–" )} Built at {deployment.builtAt ? ( <> UTC > ) : ( "–" )} Deployed at {deployment.deployedAt ? ( <> UTC > ) : ( "–" )} Git Deployed by {deployment.git?.source === "trigger_github_app" ? ( ) : deployment.deployedBy ? ( ) : ( "–" )} Triggered via {(() => { const display = getTriggeredViaDisplay(deployment.triggeredVia); if (!display) return "–"; return ( {display.icon} {display.label} ); })()} {deployment.vercelDeploymentUrl && ( Linked )} {deployment.errorData && } {deployment.tasks && ( Task File path {deployment.tasks.map((t) => { const path = v3RunsPath(organization, project, environment, { tasks: [t.slug], }); return ( {t.slug} {t.filePath} ); })} )} ); } function LogsDisplay({ logs, isStreaming, streamError, initialCollapsed = false, }: { logs: LogEntry[]; isStreaming: boolean; streamError: string | null; initialCollapsed?: boolean; }) { const [copied, setCopied] = useState(false); const [mouseOver, setMouseOver] = useState(false); const [collapsed, setCollapsed] = useState(initialCollapsed); const logsContainerRef = useRef(null); useEffect(() => { setCollapsed(initialCollapsed); }, [initialCollapsed]); // auto-scroll log container to bottom when new logs arrive useEffect(() => { if (logsContainerRef.current) { logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight; } }, [logs]); const onCopyLogs = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); const logsText = logs.map((log) => log.message).join("\n"); navigator.clipboard.writeText(logsText); setCopied(true); setTimeout(() => { setCopied(false); }, 1500); }, [logs] ); const errorCount = logs.filter((log) => log.level === "error").length; const warningCount = logs.filter((log) => log.level === "warn").length; return ( 0 ? "bg-error/80" : "bg-surface-control" )} /> {`${errorCount} ${errorCount === 1 ? "error" : "errors"}`} 0 ? "bg-warning/80" : "bg-surface-control" )} /> {`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`} {logs.length > 0 && ( setMouseOver(true)} onMouseLeave={() => setMouseOver(false)} className={cn( "transition-colors duration-100 focus-custom hover:cursor-pointer", copied ? "text-success" : "text-text-dimmed hover:text-text-bright" )} > {copied ? ( ) : ( )} {copied ? "Copied" : "Copy"} setCollapsed(!collapsed)} className={cn( "transition-colors duration-100 focus-custom hover:cursor-pointer", "text-text-dimmed hover:text-text-bright" )} > {collapsed ? ( ) : ( )} {collapsed ? "Expand" : "Collapse"} )} {logs.length === 0 && ( {streamError ? ( Failed fetching logs ) : ( {isStreaming ? "Waiting for logs..." : "No logs yet"} )} )} {logs.map((log, index) => { return ( {log.message} ); })} {collapsed && ( )} ); }