11import { Link , useLocation } from "@remix-run/react" ;
22import { type LoaderFunctionArgs } from "@remix-run/server-runtime" ;
33import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
4+ import { useEffect , useState , useRef , useCallback } from "react" ;
5+ import { S2 , S2Error } from "@s2-dev/streamstore" ;
6+ import { Clipboard , ClipboardCheck } from "lucide-react" ;
47import { ExitIcon } from "~/assets/icons/ExitIcon" ;
58import { GitMetadata } from "~/components/GitMetadata" ;
69import { RuntimeIcon } from "~/components/RuntimeIcon" ;
@@ -22,10 +25,15 @@ import {
2225} from "~/components/primitives/Table" ;
2326import { DeploymentError } from "~/components/runs/v3/DeploymentError" ;
2427import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus" ;
28+ import {
29+ Tooltip ,
30+ TooltipContent ,
31+ TooltipProvider ,
32+ TooltipTrigger ,
33+ } from "~/components/primitives/Tooltip" ;
2534import { useEnvironment } from "~/hooks/useEnvironment" ;
2635import { useOrganization } from "~/hooks/useOrganizations" ;
2736import { useProject } from "~/hooks/useProject" ;
28- import { useUser } from "~/hooks/useUser" ;
2937import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server" ;
3038import { requireUserId } from "~/services/session.server" ;
3139import { cn } from "~/utils/cn" ;
@@ -40,15 +48,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
4048
4149 try {
4250 const presenter = new DeploymentPresenter ( ) ;
43- const { deployment } = await presenter . call ( {
51+ const { deployment, s2Logs } = await presenter . call ( {
4452 userId,
4553 organizationSlug,
4654 projectSlug : projectParam ,
4755 environmentSlug : envParam ,
4856 deploymentShortCode : deploymentParam ,
4957 } ) ;
5058
51- return typedjson ( { deployment } ) ;
59+ return typedjson ( { deployment, s2Logs } ) ;
5260 } catch ( error ) {
5361 console . error ( error ) ;
5462 throw new Response ( undefined , {
@@ -58,15 +66,92 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5866 }
5967} ;
6068
69+ type LogEntry = {
70+ message : string ;
71+ timestamp : Date ;
72+ level : "info" | "error" | "warn" ;
73+ } ;
74+
6175export default function Page ( ) {
62- const { deployment } = useTypedLoaderData < typeof loader > ( ) ;
76+ const { deployment, s2Logs } = useTypedLoaderData < typeof loader > ( ) ;
6377 const organization = useOrganization ( ) ;
6478 const project = useProject ( ) ;
6579 const environment = useEnvironment ( ) ;
6680 const location = useLocation ( ) ;
67- const user = useUser ( ) ;
6881 const page = new URLSearchParams ( location . search ) . get ( "page" ) ;
6982
83+ const [ logs , setLogs ] = useState < LogEntry [ ] > ( [ ] ) ;
84+ const [ isStreaming , setIsStreaming ] = useState ( true ) ;
85+ const [ streamError , setStreamError ] = useState < string | null > ( null ) ;
86+
87+ useEffect ( ( ) => {
88+ const abortController = new AbortController ( ) ;
89+
90+ setLogs ( [ ] ) ;
91+ setStreamError ( null ) ;
92+
93+ const streamLogs = async ( ) => {
94+ try {
95+ const s2 = new S2 ( { accessToken : s2Logs . accessToken } ) ;
96+ const basin = s2 . basin ( s2Logs . basin ) ;
97+ const stream = basin . stream ( s2Logs . stream ) ;
98+
99+ const readSession = await stream . readSession (
100+ {
101+ seq_num : 0 ,
102+ wait : 60 ,
103+ as : "bytes" ,
104+ } ,
105+ { signal : abortController . signal }
106+ ) ;
107+
108+ const decoder = new TextDecoder ( ) ;
109+
110+ for await ( const record of readSession ) {
111+ try {
112+ const headers : Record < string , string > = { } ;
113+
114+ if ( record . headers ) {
115+ for ( const [ nameBytes , valueBytes ] of record . headers ) {
116+ headers [ decoder . decode ( nameBytes ) ] = decoder . decode ( valueBytes ) ;
117+ }
118+ }
119+ const level = ( headers [ "level" ] ?. toLowerCase ( ) as LogEntry [ "level" ] ) ?? "info" ;
120+
121+ setLogs ( ( prevLogs ) => [
122+ ...prevLogs ,
123+ {
124+ timestamp : new Date ( record . timestamp ) ,
125+ message : decoder . decode ( record . body ) ,
126+ level,
127+ } ,
128+ ] ) ;
129+ } catch ( err ) {
130+ console . error ( "Failed to parse log record:" , err ) ;
131+ }
132+ }
133+ } catch ( error ) {
134+ if ( abortController . signal . aborted ) return ;
135+
136+ const isNotFoundError = error instanceof S2Error && error . code === "stream_not_found" ;
137+ if ( isNotFoundError ) return ;
138+
139+ console . error ( "Failed to stream logs:" , error ) ;
140+ setStreamError ( "Failed to stream logs" ) ;
141+ } finally {
142+ if ( ! abortController . signal . aborted ) {
143+ setIsStreaming ( false ) ;
144+ }
145+ }
146+ } ;
147+
148+ streamLogs ( ) ;
149+
150+ return ( ) => {
151+ abortController . abort ( ) ;
152+ } ;
153+ } , [ s2Logs . basin , s2Logs . stream ] ) ;
154+
70155 return (
71156 < div className = "grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright" >
72157 < div className = "mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed" >
@@ -158,6 +243,10 @@ export default function Page() {
158243 />
159244 </ Property . Value >
160245 </ Property . Item >
246+ < Property . Item >
247+ < Property . Label > Logs</ Property . Label >
248+ < LogsDisplay logs = { logs } isStreaming = { isStreaming } streamError = { streamError } />
249+ </ Property . Item >
161250 { deployment . canceledAt && (
162251 < Property . Item >
163252 < Property . Label > Canceled at</ Property . Label >
@@ -320,3 +409,143 @@ export default function Page() {
320409 </ div >
321410 ) ;
322411}
412+
413+ type LogsDisplayProps = {
414+ logs : LogEntry [ ] ;
415+ isStreaming : boolean ;
416+ streamError : string | null ;
417+ } ;
418+
419+ function LogsDisplay ( { logs, isStreaming, streamError } : LogsDisplayProps ) {
420+ const [ copied , setCopied ] = useState ( false ) ;
421+ const [ mouseOver , setMouseOver ] = useState ( false ) ;
422+ const logsContainerRef = useRef < HTMLDivElement > ( null ) ;
423+
424+ // auto-scroll log container to bottom when new logs arrive
425+ useEffect ( ( ) => {
426+ if ( logsContainerRef . current ) {
427+ logsContainerRef . current . scrollTop = logsContainerRef . current . scrollHeight ;
428+ }
429+ } , [ logs ] ) ;
430+
431+ const onCopyLogs = useCallback (
432+ ( event : React . MouseEvent < HTMLButtonElement > ) => {
433+ event . preventDefault ( ) ;
434+ event . stopPropagation ( ) ;
435+ const logsText = logs . map ( ( log ) => log . message ) . join ( "\n" ) ;
436+ navigator . clipboard . writeText ( logsText ) ;
437+ setCopied ( true ) ;
438+ setTimeout ( ( ) => {
439+ setCopied ( false ) ;
440+ } , 1500 ) ;
441+ } ,
442+ [ logs ]
443+ ) ;
444+
445+ const errorCount = logs . filter ( ( log ) => log . level === "error" ) . length ;
446+ const warningCount = logs . filter ( ( log ) => log . level === "warn" ) . length ;
447+
448+ return (
449+ < div className = "mt-1.5 overflow-hidden rounded-md border border-grid-bright" >
450+ < div className = "flex items-center justify-between border-b border-grid-dimmed px-3 py-2" >
451+ < div className = "flex items-center gap-4" >
452+ < div className = "flex items-center gap-1.5" >
453+ < div
454+ className = { cn (
455+ "h-2 w-2 rounded-full" ,
456+ errorCount > 0 ? "bg-error/80" : "bg-charcoal-600"
457+ ) }
458+ />
459+ < Paragraph variant = "extra-small/dimmed/mono" className = "w-[ch-10]" >
460+ { `${ errorCount } ${ errorCount === 1 ? "error" : "errors" } ` }
461+ </ Paragraph >
462+ </ div >
463+ < div className = "flex items-center gap-1.5" >
464+ < div
465+ className = { cn (
466+ "h-2 w-2 rounded-full" ,
467+ warningCount > 0 ? "bg-warning/80" : "bg-charcoal-600"
468+ ) }
469+ />
470+ < Paragraph variant = "extra-small/dimmed/mono" >
471+ { `${ warningCount } ${ warningCount === 1 ? "warning" : "warnings" } ` }
472+ </ Paragraph >
473+ </ div >
474+ </ div >
475+ { logs . length > 0 && (
476+ < TooltipProvider >
477+ < Tooltip open = { copied || mouseOver } disableHoverableContent >
478+ < TooltipTrigger
479+ onClick = { onCopyLogs }
480+ onMouseEnter = { ( ) => setMouseOver ( true ) }
481+ onMouseLeave = { ( ) => setMouseOver ( false ) }
482+ className = { cn (
483+ "transition-colors duration-100 focus-custom hover:cursor-pointer" ,
484+ copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
485+ ) }
486+ >
487+ { copied ? < ClipboardCheck className = "size-4" /> : < Clipboard className = "size-4" /> }
488+ </ TooltipTrigger >
489+ < TooltipContent side = "left" className = "text-xs" >
490+ { copied ? "Copied" : "Copy" }
491+ </ TooltipContent >
492+ </ Tooltip >
493+ </ TooltipProvider >
494+ ) }
495+ </ div >
496+
497+ < div
498+ ref = { logsContainerRef }
499+ className = "h-64 grow overflow-x-auto overflow-y-scroll font-mono text-xs scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
500+ >
501+ < div className = "flex w-fit min-w-full flex-col" >
502+ { logs . length === 0 && (
503+ < div className = "flex gap-x-2.5 border-l-2 border-transparent px-2.5 py-1" >
504+ { streamError ? (
505+ < span className = "text-error" > Failed fetching logs</ span >
506+ ) : (
507+ < span className = "text-text-dimmed" >
508+ { isStreaming ? "Waiting for logs..." : "No logs yet" }
509+ </ span >
510+ ) }
511+ </ div >
512+ ) }
513+ { logs . map ( ( log , index ) => {
514+ return (
515+ < div
516+ key = { index }
517+ className = { cn (
518+ "flex w-full gap-x-2.5 border-l-2 px-2.5 py-1" ,
519+ log . level === "error" && "border-error/60 bg-error/15 hover:bg-error/25" ,
520+ log . level === "warn" && "border-warning/60 bg-warning/20 hover:bg-warning/30" ,
521+ log . level === "info" && "border-transparent hover:bg-charcoal-750"
522+ ) }
523+ >
524+ < span
525+ className = { cn (
526+ "select-none whitespace-nowrap py-px" ,
527+ log . level === "error" && "text-error/80" ,
528+ log . level === "warn" && "text-warning/70" ,
529+ log . level === "info" && "text-text-dimmed"
530+ ) }
531+ >
532+ < DateTimeAccurate date = { log . timestamp } hideDate />
533+ </ span >
534+ < span
535+ className = { cn (
536+ "whitespace-nowrap" ,
537+ log . level === "error" && "text-error" ,
538+ log . level === "warn" && "text-warning" ,
539+ log . level === "info" && "text-text-bright"
540+ ) }
541+ >
542+ { log . message }
543+ </ span >
544+ </ div >
545+ ) ;
546+ } ) }
547+ </ div >
548+ </ div >
549+ </ div >
550+ ) ;
551+ }
0 commit comments