Files
2026-07-13 13:32:57 +08:00

595 lines
20 KiB
TypeScript

import { BoltIcon, BoltSlashIcon } from "@heroicons/react/20/solid";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type SSEStreamPart, SSEStreamSubscription } from "@trigger.dev/core/v3";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Clipboard, ClipboardCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import simplur from "simplur";
import { ListBulletIcon } from "~/assets/icons/ListBulletIcon";
import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon";
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
import { SnakedArrowIcon } from "~/assets/icons/SnakedArrowIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3RunStreamParamsSchema } from "~/utils/pathBuilder";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
type ViewMode = "list" | "compact";
export type StreamChunk = {
id: string;
data: unknown;
timestamp: number;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, runParam, streamKey } =
v3RunStreamParamsSchema.parse(params);
const project = await $replica.project.findFirst({
where: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const run = await runStore.findRun(
{ friendlyId: runParam, projectId: project.id },
{
select: {
id: true,
friendlyId: true,
realtimeStreamsVersion: true,
streamBasinName: true,
runtimeEnvironmentId: true,
},
}
);
if (!run) {
throw new Response("Not Found", { status: 404 });
}
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(run.runtimeEnvironmentId);
if (!environment || environment.slug !== envParam) {
throw new Response("Not Found", { status: 404 });
}
// Get Last-Event-ID header for resuming from a specific position
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion, {
run: { streamBasinName: run.streamBasinName },
});
return realtimeStream.streamResponse(
request,
run.friendlyId,
streamKey,
getRequestAbortSignal(),
{
lastEventId,
}
);
};
export function RealtimeStreamViewer({
runId,
streamKey,
metadata,
displayName,
resourcePath: resourcePathOverride,
headerLabel,
headerLeft,
headerRight,
hideViewModeToggle = false,
headerClassName,
}: {
runId?: string;
streamKey?: string;
metadata?: Record<string, unknown> | undefined;
displayName?: string;
/** Pre-built resource path. When provided, `runId`/`streamKey` are unused. */
resourcePath?: string;
/** Override the "Stream:" / "Input stream:" prefix in the header. */
headerLabel?: string;
/**
* Replaces the default "Stream: <name>" content next to the connection
* icon. Use to inline tabs or other navigation in place of a static
* label.
*/
headerLeft?: React.ReactNode;
/**
* Extra content appended after the built-in chunks/view-mode/copy
* controls on the right side of the header. Use to inline a mode
* toggle or other actions alongside the stream controls.
*/
headerRight?: React.ReactNode;
/** Hide the "Flow as text" / "View as list" view-mode toggle button. */
hideViewModeToggle?: boolean;
/** Extra classes applied to the header bar (overrides default styling via tailwind-merge). */
headerClassName?: string;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const resourcePath =
resourcePathOverride ??
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`;
const startIndex = typeof metadata?.startIndex === "number" ? metadata.startIndex : undefined;
const { chunks, error, isConnected } = useRealtimeStream(resourcePath, startIndex);
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [mouseOver, setMouseOver] = useState(false);
const [copied, setCopied] = useState(false);
const getCompactText = useCallback(() => {
return chunks
.map((chunk) => {
if (typeof chunk.data === "string") {
return chunk.data;
}
return JSON.stringify(chunk.data);
})
.join("");
}, [chunks]);
const onCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(getCompactText());
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[getCompactText]
);
// Use IntersectionObserver to detect when the bottom element is visible
useEffect(() => {
const bottomElement = bottomRef.current;
const scrollElement = scrollRef.current;
if (!bottomElement || !scrollElement) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry) {
setIsAtBottom(entry.isIntersecting);
}
},
{
root: scrollElement,
threshold: 0.1,
rootMargin: "0px",
}
);
observer.observe(bottomElement);
// Also add a scroll listener as a backup to ensure state updates
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
const handleScroll = () => {
if (!scrollElement || !bottomElement) return;
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
// Debounce the state update to avoid interrupting smooth scroll
scrollTimeout = setTimeout(() => {
const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50;
setIsAtBottom(isNearBottom);
}, 100);
};
scrollElement.addEventListener("scroll", handleScroll);
// Check initial state
const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50;
setIsAtBottom(isNearBottom);
return () => {
observer.disconnect();
scrollElement.removeEventListener("scroll", handleScroll);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
};
}, [chunks.length, viewMode]);
// Auto-scroll to bottom when new chunks arrive, if we're at the bottom
useEffect(() => {
if (isAtBottom && scrollRef.current) {
// Preserve horizontal scroll position while scrolling to bottom vertically
const currentScrollLeft = scrollRef.current.scrollLeft;
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
scrollRef.current.scrollLeft = currentScrollLeft;
}
}, [chunks, isAtBottom]);
const firstLineNumber = startIndex ?? 0;
const lastLineNumber = firstLineNumber + chunks.length - 1;
const maxLineNumberWidth = (chunks.length > 0 ? lastLineNumber : firstLineNumber).toString()
.length;
// Virtual rendering for list view
const rowVirtualizer = useVirtualizer({
count: chunks.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 28,
overscan: 5,
});
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div
className={cn(
"border-b border-grid-bright bg-background-bright @container",
headerClassName
)}
>
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 @[300px]:flex-nowrap">
<div className="flex min-w-0 items-center gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
{isConnected ? (
<BoltIcon className={cn("size-3.5 animate-pulse text-success")} />
) : (
<BoltSlashIcon className={cn("size-3.5 text-text-dimmed")} />
)}
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{isConnected ? "Connected" : "Disconnected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{headerLeft ?? (
<Paragraph
variant="small/bright"
className="mb-0 flex min-w-0 items-center gap-1 truncate whitespace-nowrap"
>
<span>{headerLabel ?? (displayName ? "Input stream:" : "Stream:")}</span>
<span className="truncate font-mono text-text-dimmed">
{displayName ?? streamKey ?? ""}
</span>
</Paragraph>
)}
</div>
<div className="flex w-full flex-wrap items-center justify-between gap-3 @[300px]:w-auto @[300px]:flex-nowrap">
<Paragraph variant="small" className="mb-0 whitespace-nowrap">
{simplur`${chunks.length} chunk[|s]`}
</Paragraph>
<div className="flex items-center gap-3">
{!hideViewModeToggle && (
<TooltipProvider>
<Tooltip open={chunks.length === 0 ? false : undefined} disableHoverableContent>
<TooltipTrigger
disabled={chunks.length === 0}
onClick={() => setViewMode(viewMode === "list" ? "compact" : "list")}
className={cn(
"text-text-dimmed transition-colors focus-custom",
chunks.length === 0
? "cursor-not-allowed opacity-50"
: "hover:cursor-pointer hover:text-text-bright"
)}
>
{viewMode === "list" ? (
<SnakedArrowIcon className="size-4" />
) : (
<ListBulletIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{viewMode === "list" ? "Flow as text" : "View as list"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip
open={chunks.length === 0 ? false : copied || mouseOver || undefined}
disableHoverableContent
>
<TooltipTrigger
disabled={chunks.length === 0}
onClick={onCopied}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"transition-colors duration-100 focus-custom",
chunks.length === 0
? "cursor-not-allowed opacity-50"
: copied
? "text-success hover:cursor-pointer"
: "text-text-dimmed hover:cursor-pointer hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheck className="size-4" />
) : (
<Clipboard className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{copied ? "Copied" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip open={chunks.length === 0 ? false : undefined} disableHoverableContent>
<TooltipTrigger
disabled={chunks.length === 0}
onClick={() => {
if (isAtBottom) {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
} else {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}
}}
className={cn(
"text-text-dimmed transition-colors focus-custom",
chunks.length === 0
? "cursor-not-allowed opacity-50"
: "hover:cursor-pointer hover:text-text-bright"
)}
>
{isAtBottom ? (
<MoveToTopIcon className="size-4" />
) : (
<MoveToBottomIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{isAtBottom ? "Scroll to top" : "Scroll to bottom"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{headerRight}
</div>
</div>
</div>
{/* Content */}
<div
ref={scrollRef}
className="flex-1 overflow-x-auto overflow-y-auto bg-background-deep scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
{error && (
<div className="border-b border-error/20 bg-error/10 p-3">
<Paragraph variant="small" className="mb-0 text-error">
Error: {error.message}
</Paragraph>
</div>
)}
{chunks.length === 0 && !error && (
<div className="flex h-full items-center justify-center">
{isConnected ? (
<div className="flex items-center gap-2">
<Spinner />
<Paragraph variant="small" className="mb-0 text-text-dimmed">
Waiting for data
</Paragraph>
</div>
) : (
<Paragraph variant="small" className="mb-0 text-text-dimmed">
No data received
</Paragraph>
)}
</div>
)}
{chunks.length > 0 && viewMode === "list" && (
<div className="font-mono text-xs leading-tight">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
minWidth: "100%",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<StreamChunkLine
key={virtualItem.key}
chunk={chunks[virtualItem.index]}
lineNumber={firstLineNumber + virtualItem.index}
maxLineNumberWidth={maxLineNumberWidth}
size={virtualItem.size}
start={virtualItem.start}
/>
))}
{/* Sentinel element for IntersectionObserver */}
<div
ref={bottomRef}
className="h-px"
style={{
position: "absolute",
top: `${rowVirtualizer.getTotalSize()}px`,
}}
/>
</div>
</div>
)}
{chunks.length > 0 && viewMode === "compact" && (
<div className="p-3 font-mono text-xs leading-relaxed">
<CompactStreamView chunks={chunks} />
{/* Sentinel element for IntersectionObserver */}
<div ref={bottomRef} className="h-px" />
</div>
)}
</div>
</div>
);
}
function CompactStreamView({ chunks }: { chunks: StreamChunk[] }) {
const compactText = chunks
.map((chunk) => {
if (typeof chunk.data === "string") {
return chunk.data;
}
return JSON.stringify(chunk.data);
})
.join("");
return <div className="whitespace-pre-wrap break-all text-text-bright">{compactText}</div>;
}
function StreamChunkLine({
chunk,
lineNumber,
maxLineNumberWidth,
size,
start,
}: {
chunk: StreamChunk;
lineNumber: number;
maxLineNumberWidth: number;
size: number;
start: number;
}) {
const formattedData =
typeof chunk.data === "string" ? chunk.data : JSON.stringify(chunk.data, null, 2);
const date = new Date(chunk.timestamp);
const timeString = date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const milliseconds = date.getMilliseconds().toString().padStart(3, "0");
const timestamp = `${timeString}.${milliseconds}`;
return (
<div
className="group flex gap-3 py-1 hover:bg-background-bright"
style={{
position: "absolute",
top: 0,
left: 0,
height: `${size}px`,
transform: `translateY(${start}px)`,
}}
>
{/* Line number */}
<div
className="flex-none select-none pl-2 text-right text-text-faint"
style={{ width: `${Math.max(maxLineNumberWidth, 3)}ch` }}
>
{lineNumber}
</div>
{/* Timestamp */}
<div className="flex-none select-none pl-1 text-text-faint">{timestamp}</div>
{/* Content */}
<div className="whitespace-nowrap text-text-bright">{formattedData}</div>
</div>
);
}
export function useRealtimeStream(resourcePath: string, startIndex?: number) {
const [chunks, setChunks] = useState<StreamChunk[]>([]);
const [error, setError] = useState<Error | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
setChunks([]);
setError(null);
const abortController = new AbortController();
let reader: ReadableStreamDefaultReader<SSEStreamPart<unknown>> | null = null;
async function connectAndConsume() {
try {
const sseSubscription = new SSEStreamSubscription(resourcePath, {
signal: abortController.signal,
lastEventId: startIndex ? (startIndex - 1).toString() : undefined,
timeoutInSeconds: 30,
});
const stream = await sseSubscription.subscribe();
setIsConnected(true);
reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (value !== undefined) {
setChunks((prev) => [
...prev,
{
id: value.id,
data: value.chunk,
timestamp: value.timestamp,
},
]);
}
}
} catch (err) {
// Only set error if not aborted
if (!abortController.signal.aborted) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
setIsConnected(false);
}
}
connectAndConsume();
return () => {
abortController.abort();
reader?.cancel();
};
}, [resourcePath, startIndex]);
return { chunks, error, isConnected };
}