Skip to content

Commit c87adc4

Browse files
committed
Stream build-server logs in the deployment details page
1 parent 0a14b69 commit c87adc4

File tree

6 files changed

+432
-104
lines changed

6 files changed

+432
-104
lines changed

apps/webapp/app/components/primitives/DateTime.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type DateTimeProps = {
1313
includeTime?: boolean;
1414
showTimezone?: boolean;
1515
showTooltip?: boolean;
16+
hideDate?: boolean;
1617
previousDate?: Date | string | null; // Add optional previous date for comparison
1718
};
1819

@@ -184,7 +185,7 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): s
184185
// Format time only
185186
function formatTimeOnly(date: Date, timeZone: string, locales: string[]): string {
186187
return new Intl.DateTimeFormat(locales, {
187-
hour: "numeric",
188+
hour: "2-digit",
188189
minute: "numeric",
189190
second: "numeric",
190191
timeZone,
@@ -198,6 +199,7 @@ export const DateTimeAccurate = ({
198199
timeZone = "UTC",
199200
previousDate = null,
200201
showTooltip = true,
202+
hideDate = false,
201203
}: DateTimeProps) => {
202204
const locales = useLocales();
203205
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
@@ -214,7 +216,9 @@ export const DateTimeAccurate = ({
214216
}, []);
215217

216218
// Smart formatting based on whether date changed
217-
const formattedDateTime = realPrevDate
219+
const formattedDateTime = hideDate
220+
? formatTimeOnly(realDate, localTimeZone, locales)
221+
: realPrevDate
218222
? isSameDay(realDate, realPrevDate)
219223
? formatTimeOnly(realDate, localTimeZone, locales)
220224
: formatDateTimeAccurate(realDate, localTimeZone, locales)

apps/webapp/app/components/primitives/Paragraph.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ const paragraphVariants = {
1717
text: "font-sans text-sm font-normal text-text-bright",
1818
spacing: "mb-2",
1919
},
20+
"small/dimmed": {
21+
text: "font-sans text-sm font-normal text-text-dimmed",
22+
spacing: "mb-2",
23+
},
2024
"extra-small": {
2125
text: "font-sans text-xs font-normal text-text-dimmed",
2226
spacing: "mb-1.5",
@@ -25,6 +29,14 @@ const paragraphVariants = {
2529
text: "font-sans text-xs font-normal text-text-bright",
2630
spacing: "mb-1.5",
2731
},
32+
"extra-small/dimmed": {
33+
text: "font-sans text-xs font-normal text-text-dimmed",
34+
spacing: "mb-1.5",
35+
},
36+
"extra-small/dimmed/mono": {
37+
text: "font-mono text-xs font-normal text-text-dimmed",
38+
spacing: "mb-1.5",
39+
},
2840
"extra-small/mono": {
2941
text: "font-mono text-xs font-normal text-text-dimmed",
3042
spacing: "mb-1.5",

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx

Lines changed: 234 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Link, useLocation } from "@remix-run/react";
22
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
33
import { 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";
47
import { ExitIcon } from "~/assets/icons/ExitIcon";
58
import { GitMetadata } from "~/components/GitMetadata";
69
import { RuntimeIcon } from "~/components/RuntimeIcon";
@@ -22,10 +25,15 @@ import {
2225
} from "~/components/primitives/Table";
2326
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
2427
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
28+
import {
29+
Tooltip,
30+
TooltipContent,
31+
TooltipProvider,
32+
TooltipTrigger,
33+
} from "~/components/primitives/Tooltip";
2534
import { useEnvironment } from "~/hooks/useEnvironment";
2635
import { useOrganization } from "~/hooks/useOrganizations";
2736
import { useProject } from "~/hooks/useProject";
28-
import { useUser } from "~/hooks/useUser";
2937
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
3038
import { requireUserId } from "~/services/session.server";
3139
import { 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+
6175
export 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+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ export default function Page() {
372372
{deploymentParam && (
373373
<>
374374
<ResizableHandle id="deployments-handle" />
375-
<ResizablePanel id="deployments-inspector" min="400px" max="700px">
375+
<ResizablePanel id="deployments-inspector" min="500px" max="800px">
376376
<Outlet />
377377
</ResizablePanel>
378378
</>

apps/webapp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"@remix-run/serve": "2.1.0",
106106
"@remix-run/server-runtime": "2.1.0",
107107
"@remix-run/v1-meta": "^0.1.3",
108+
"@s2-dev/streamstore": "^0.17.2",
108109
"@sentry/remix": "9.46.0",
109110
"@slack/web-api": "7.9.1",
110111
"@socket.io/redis-adapter": "^8.3.0",

0 commit comments

Comments
 (0)