#346 Improve run dashboard perf when tasks have large outputs

Performance degradation came from the syntax highlighting of large code blocks and from doing that on the server and the client, so fixed this in a couple of ways:

1. Stream the task details data using defer and Suspense/Await
2. Skipped syntax highlighting code blocks with more than 1k lines
This commit is contained in:
Eric Allam
2023-09-01 22:05:06 +01:00
parent 8fd68e30e9
commit 9f076631ae
7 changed files with 199 additions and 133 deletions
+103 -87
View File
@@ -1,10 +1,9 @@
import { Clipboard, ClipboardCheck } from "lucide-react";
import type { Language, PrismTheme } from "prism-react-renderer";
import Highlight, { defaultProps } from "prism-react-renderer";
import { forwardRef, useCallback, useState } from "react";
import { cn } from "~/utils/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { ClipboardDocumentCheckIcon, ClipboardIcon } from "@heroicons/react/24/solid";
import { Clipboard, ClipboardCheck, ClipboardCheckIcon } from "lucide-react";
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
@@ -192,6 +191,9 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
Array.from({ length: end - start + 1 }, (_, i) => start + i)
);
// if there are more than 1000 lines, don't highlight
const shouldHighlight = lineCount <= 1000;
return (
<div
className={cn("relative overflow-hidden rounded-md border border-slate-800", className)}
@@ -229,99 +231,113 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
</TooltipProvider>
)}
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
{({
className: inheritedClassName,
style: inheritedStyle,
tokens,
getLineProps,
getTokenProps,
}) => (
<div
dir="ltr"
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
style={{
maxHeight,
}}
>
<pre
className={cn(
"relative mr-2 font-mono text-xs leading-relaxed",
inheritedClassName
)}
style={inheritedStyle}
{shouldHighlight ? (
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
{({
className: inheritedClassName,
style: inheritedStyle,
tokens,
getLineProps,
getTokenProps,
}) => (
<div
dir="ltr"
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
style={{
maxHeight,
}}
>
{tokens
.map((line, index) => {
if (
index === tokens.length - 1 &&
line.length === 1 &&
line[0].content === "\n"
) {
return null;
}
<pre
className={cn(
"relative mr-2 font-mono text-xs leading-relaxed",
inheritedClassName
)}
style={inheritedStyle}
dir="ltr"
>
{tokens
.map((line, index) => {
if (
index === tokens.length - 1 &&
line.length === 1 &&
line[0].content === "\n"
) {
return null;
}
const lineNumber = index + 1;
const lineProps = getLineProps({ line, key: index });
const lineNumber = index + 1;
const lineProps = getLineProps({ line, key: index });
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
let shouldDim = hasAnyHighlights;
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
shouldDim = false;
}
let shouldDim = hasAnyHighlights;
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
shouldDim = false;
}
return (
<div
key={lineProps.key}
{...lineProps}
className={cn(
"flex w-full justify-start transition-opacity duration-500",
lineProps.className
)}
style={{
opacity: shouldDim ? dimAmount : undefined,
...lineProps.style,
}}
>
{showLineNumbers && (
<div
className={
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
}
style={{
width: `calc(8 * ${maxLineWidth / 16}rem)`,
}}
>
{lineNumber}
return (
<div
key={lineProps.key}
{...lineProps}
className={cn(
"flex w-full justify-start transition-opacity duration-500",
lineProps.className
)}
style={{
opacity: shouldDim ? dimAmount : undefined,
...lineProps.style,
}}
>
{showLineNumbers && (
<div
className={
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
}
style={{
width: `calc(8 * ${maxLineWidth / 16}rem)`,
}}
>
{lineNumber}
</div>
)}
<div className="flex-1">
{line.map((token, key) => {
const tokenProps = getTokenProps({ token, key });
return (
<span
key={tokenProps.key}
{...tokenProps}
style={{
color: tokenProps?.style?.color as string,
...tokenProps.style,
}}
/>
);
})}
</div>
)}
<div className="flex-1">
{line.map((token, key) => {
const tokenProps = getTokenProps({ token, key });
return (
<span
key={tokenProps.key}
{...tokenProps}
style={{
color: tokenProps?.style?.color as string,
...tokenProps.style,
}}
/>
);
})}
<div className="w-4 flex-none" />
</div>
<div className="w-4 flex-none" />
</div>
);
})
.filter(Boolean)}
</pre>
</div>
)}
</Highlight>
);
})
.filter(Boolean)}
</pre>
</div>
)}
</Highlight>
) : (
<div
dir="ltr"
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
style={{
maxHeight,
}}
>
<pre className="relative mr-2 p-2 font-mono text-xs leading-relaxed" dir="ltr">
{code}
</pre>
</div>
)}
</div>
);
}
@@ -54,7 +54,7 @@ export function RunCompletedDetail({ run }: { run: MatchedRun }) {
<RunPanelDivider />
{run.error && <RunPanelError text={run.error.message} stackTrace={run.error.stack} />}
{run.output ? (
<CodeBlock language="json" code={run.output} />
<CodeBlock language="json" code={run.output} maxLines={8} />
) : (
run.output === null && <Paragraph variant="small">This run returned nothing</Paragraph>
)}
+11 -16
View File
@@ -1,4 +1,3 @@
import { DetailedTask } from "~/presenters/TaskDetailsPresenter.server";
import {
RunPanel,
RunPanelBody,
@@ -29,22 +28,16 @@ import {
} from "../primitives/Table";
import { TaskAttemptStatusLabel } from "./TaskAttemptStatus";
import { TaskStatusIcon } from "./TaskStatus";
import { ClientOnly } from "remix-utils";
import { Spinner } from "../primitives/Spinner";
import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route";
export function TaskDetail({ task }: { task: DetailedTask }) {
const {
name,
description,
icon,
startedAt,
completedAt,
status,
delayUntil,
params,
properties,
output,
style,
attempts,
} = task;
const { name, description, icon, status, params, properties, output, style, attempts } = task;
const startedAt = task.startedAt ? new Date(task.startedAt) : undefined;
const completedAt = task.completedAt ? new Date(task.completedAt) : undefined;
const delayUntil = task.delayUntil ? new Date(task.delayUntil) : undefined;
return (
<RunPanel selected={false}>
@@ -150,7 +143,9 @@ export function TaskDetail({ task }: { task: DetailedTask }) {
<div className="mt-4 flex flex-col gap-2">
<Header3>Output</Header3>
{output ? (
<CodeBlock code={JSON.stringify(output, null, 2)} />
<ClientOnly fallback={<Spinner />}>
{() => <CodeBlock code={output} maxLines={35} />}
</ClientOnly>
) : (
<Paragraph variant="small">No output</Paragraph>
)}
@@ -1,4 +1,4 @@
import { DisplayPropertiesSchema, StyleSchema } from "@trigger.dev/core";
import { StyleSchema } from "@trigger.dev/core";
import { PrismaClient, prisma } from "~/db.server";
import { mergeProperties } from "~/utils/mergeProperties.server";
@@ -7,8 +7,6 @@ type DetailsProps = {
userId: string;
};
export type DetailedTask = NonNullable<Awaited<ReturnType<TaskDetailsPresenter["call"]>>>;
export class TaskDetailsPresenter {
#prismaClient: PrismaClient;
@@ -87,6 +85,7 @@ export class TaskDetailsPresenter {
return {
...task,
output: task.output ? JSON.stringify(task.output, null, 2) : undefined,
connection: task.runConnection,
params: task.params as Record<string, any>,
properties: mergeProperties(task.properties, task.outputProperties),
@@ -1,5 +1,7 @@
import { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Await, useLoaderData } from "@remix-run/react";
import { LoaderArgs, SerializeFrom, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
import { requireUserId } from "~/services/session.server";
@@ -10,23 +12,26 @@ export const loader = async ({ request, params }: LoaderArgs) => {
const { taskParam } = TaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
const task = await presenter.call({
const taskPromise = presenter.call({
userId,
id: taskParam,
});
if (!task) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
task,
return defer({
taskPromise,
});
};
export type DetailedTask = NonNullable<Awaited<SerializeFrom<typeof loader>["taskPromise"]>>;
export default function Page() {
const { task } = useTypedLoaderData<typeof loader>();
return <TaskDetail task={task} />;
const { taskPromise } = useLoaderData<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
}
@@ -1,5 +1,7 @@
import { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Await, useLoaderData } from "@remix-run/react";
import { LoaderArgs, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
import { requireUserId } from "~/services/session.server";
@@ -10,23 +12,24 @@ export const loader = async ({ request, params }: LoaderArgs) => {
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
const task = await presenter.call({
const taskPromise = presenter.call({
userId,
id: taskParam,
});
if (!task) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
task,
return defer({
taskPromise,
});
};
export default function Page() {
const { task } = useTypedLoaderData<typeof loader>();
return <TaskDetail task={task} />;
const { taskPromise } = useLoaderData<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
}
+48
View File
@@ -38,4 +38,52 @@ client.defineJob({
},
});
client.defineJob({
id: "stress-test-2",
name: "Stress Test 2",
version: "1.0.0",
trigger: eventTrigger({
name: "stress.test.2",
}),
run: async (payload, io, ctx) => {
await io.runTask(`task-1`, { name: `Task 1` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/photos");
return await response.json();
});
await io.runTask(`task-2`, { name: `Task 2` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/comments");
return await response.json();
});
await io.runTask(`task-3`, { name: `Task 3` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/photos");
return await response.json();
});
await io.runTask(`task-4`, { name: `Task 4` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/comments");
return await response.json();
});
const response = await io.runTask(`task-5`, { name: `Task 5` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/photos");
return await response.json();
});
await io.runTask(`task-6`, { name: `Task 6` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
return await response.json();
});
return response;
},
});
createExpressServer(client);