Skip to content

Commit 7ff8f0e

Browse files
authored
Task and run page improvements (#1076)
* TaskListPresenter: if there are no tasks then don’t do stats queries * RunListPresenter, use BasePresenter and the read replica * Added populate script * Simplified the Runs list query, added live timer * Added TaskRun indexes for the RunList * Status can’t be null now we’re using the TaskRun status * Use defer so the page loads and shows a spinner * Improved the loading style * Get rid of latest run info from the tasks table super slow * Fix for the activity graph tooltip getting clipped * Added a code comment crediting the GitHub issue with the portal fix * Add search to the tasks list * Padding * Fix for the schedules columns not being UTC * Remove unused function
1 parent 68455c7 commit 7ff8f0e

File tree

20 files changed

+461
-185
lines changed

20 files changed

+461
-185
lines changed

apps/webapp/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ build-storybook.log
1717
.storybook-out
1818
storybook-static
1919

20-
/prisma/seed.js
20+
/prisma/seed.js
21+
/prisma/populate.js
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { VirtualElement as IVirtualElement } from "@popperjs/core";
2+
import { ReactNode, useEffect, useState } from "react";
3+
import { createPortal } from "react-dom";
4+
import { usePopper } from "react-popper";
5+
import { useEvent } from "react-use";
6+
import useLazyRef from "~/hooks/useLazyRef";
7+
8+
// Recharts 3.x will have portal support, but until then we're using this:
9+
//https://github.com/recharts/recharts/issues/2458#issuecomment-1063463873
10+
11+
export interface PopperPortalProps {
12+
active?: boolean;
13+
children: ReactNode;
14+
}
15+
16+
export default function TooltipPortal({ active = true, children }: PopperPortalProps) {
17+
const [portalElement, setPortalElement] = useState<HTMLDivElement>();
18+
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>();
19+
const virtualElementRef = useLazyRef(() => new VirtualElement());
20+
21+
const { styles, attributes, update } = usePopper(
22+
virtualElementRef.current,
23+
popperElement,
24+
POPPER_OPTIONS
25+
);
26+
27+
useEffect(() => {
28+
const el = document.createElement("div");
29+
document.body.appendChild(el);
30+
setPortalElement(el);
31+
return () => el.remove();
32+
}, []);
33+
34+
useEvent("mousemove", ({ clientX: x, clientY: y }) => {
35+
virtualElementRef.current?.update(x, y);
36+
if (!active) return;
37+
update?.();
38+
});
39+
40+
useEffect(() => {
41+
if (!active) return;
42+
update?.();
43+
}, [active, update]);
44+
45+
if (!portalElement) return null;
46+
47+
return createPortal(
48+
<div
49+
ref={setPopperElement}
50+
{...attributes.popper}
51+
style={{
52+
...styles.popper,
53+
zIndex: 1000,
54+
display: active ? "block" : "none",
55+
}}
56+
>
57+
{children}
58+
</div>,
59+
portalElement
60+
);
61+
}
62+
63+
class VirtualElement implements IVirtualElement {
64+
private rect = {
65+
width: 0,
66+
height: 0,
67+
top: 0,
68+
right: 0,
69+
bottom: 0,
70+
left: 0,
71+
x: 0,
72+
y: 0,
73+
toJSON() {
74+
return this;
75+
},
76+
};
77+
78+
update(x: number, y: number) {
79+
this.rect.y = y;
80+
this.rect.top = y;
81+
this.rect.bottom = y;
82+
83+
this.rect.x = x;
84+
this.rect.left = x;
85+
this.rect.right = x;
86+
}
87+
88+
getBoundingClientRect(): DOMRect {
89+
return this.rect;
90+
}
91+
}
92+
93+
const POPPER_OPTIONS: Parameters<typeof usePopper>[2] = {
94+
placement: "right-start",
95+
modifiers: [
96+
{
97+
name: "offset",
98+
options: {
99+
offset: [8, 8],
100+
},
101+
},
102+
],
103+
};

apps/webapp/app/components/runs/v3/LiveTimer.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import { formatDuration } from "@trigger.dev/core/v3";
2-
import { useState, useEffect } from "react";
3-
import { Paragraph } from "~/components/primitives/Paragraph";
4-
import { cn } from "~/utils/cn";
2+
import { useEffect, useState } from "react";
53

64
export function LiveTimer({
75
startTime,
86
endTime,
97
updateInterval = 250,
10-
className,
118
}: {
129
startTime: Date;
1310
endTime?: Date;
1411
updateInterval?: number;
15-
className?: string;
1612
}) {
1713
const [now, setNow] = useState<Date>();
1814

@@ -30,13 +26,13 @@ export function LiveTimer({
3026
}, [startTime]);
3127

3228
return (
33-
<Paragraph variant="extra-small" className={cn("whitespace-nowrap tabular-nums", className)}>
29+
<>
3430
{formatDuration(startTime, now, {
3531
style: "short",
3632
maxDecimalPoints: 0,
3733
units: ["d", "h", "m", "s"],
3834
})}
39-
</Paragraph>
35+
</>
4036
);
4137
}
4238

apps/webapp/app/components/runs/v3/TaskRunStatus.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ export const RUNNING_STATUSES: TaskRunStatus[] = [
3838
"WAITING_TO_RESUME",
3939
];
4040

41+
export const FINISHED_STATUSES: TaskRunStatus[] = [
42+
"COMPLETED_SUCCESSFULLY",
43+
"CANCELED",
44+
"COMPLETED_WITH_ERRORS",
45+
"INTERRUPTED",
46+
"SYSTEM_FAILURE",
47+
"CRASHED",
48+
];
49+
4150
export function descriptionForTaskRunStatus(status: TaskRunStatus): string {
4251
return taskRunStatusDescriptions[status];
4352
}

apps/webapp/app/components/runs/v3/TaskRunsTable.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { StopIcon } from "@heroicons/react/24/outline";
33
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
44
import { useLocation } from "@remix-run/react";
55
import { formatDuration } from "@trigger.dev/core/v3";
6-
import { User } from "@trigger.dev/database";
76
import { Button, LinkButton } from "~/components/primitives/Buttons";
87
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
98
import { useEnvironments } from "~/hooks/useEnvironments";
@@ -28,6 +27,7 @@ import {
2827
import { CancelRunDialog } from "./CancelRunDialog";
2928
import { ReplayRunDialog } from "./ReplayRunDialog";
3029
import { TaskRunStatusCombo } from "./TaskRunStatus";
30+
import { LiveTimer } from "./LiveTimer";
3131

3232
type RunsTableProps = {
3333
total: number;
@@ -94,9 +94,15 @@ export function TaskRunsTable({
9494
{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}
9595
</TableCell>
9696
<TableCell to={path}>
97-
{formatDuration(run.startedAt, run.completedAt, {
98-
style: "short",
99-
})}
97+
{run.startedAt && run.finishedAt ? (
98+
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
99+
style: "short",
100+
})
101+
) : run.startedAt ? (
102+
<LiveTimer startTime={new Date(run.startedAt)} />
103+
) : (
104+
"–"
105+
)}
100106
</TableCell>
101107
<TableCell to={path}>
102108
{run.isTest ? (

apps/webapp/app/hooks/useLazyRef.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { useRef, MutableRefObject } from "react";
2+
3+
const useLazyRef = <T>(initialValFunc: () => T) => {
4+
const ref: MutableRefObject<T | null> = useRef(null);
5+
if (ref.current === null) {
6+
ref.current = initialValFunc();
7+
}
8+
return ref;
9+
};
10+
11+
export default useLazyRef;

apps/webapp/app/presenters/v3/RunListPresenter.server.ts

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
22
import { Direction } from "~/components/runs/RunStatuses";
3-
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
3+
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
4+
import { sqlDatabaseSchema } from "~/db.server";
45
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
5-
import { getUsername } from "~/utils/username";
66
import { CANCELLABLE_STATUSES } from "~/v3/services/cancelTaskRun.server";
7+
import { BasePresenter } from "./basePresenter.server";
78

89
type RunListOptions = {
910
userId?: string;
@@ -28,13 +29,7 @@ export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
2829
export type RunListItem = RunList["runs"][0];
2930
export type RunListAppliedFilters = RunList["filters"];
3031

31-
export class RunListPresenter {
32-
#prismaClient: PrismaClient;
33-
34-
constructor(prismaClient: PrismaClient = prisma) {
35-
this.#prismaClient = prismaClient;
36-
}
37-
32+
export class RunListPresenter extends BasePresenter {
3833
public async call({
3934
userId,
4035
projectSlug,
@@ -60,7 +55,7 @@ export class RunListPresenter {
6055
to !== undefined;
6156

6257
// Find the project scoped to the organization
63-
const project = await this.#prismaClient.project.findFirstOrThrow({
58+
const project = await this._replica.project.findFirstOrThrow({
6459
select: {
6560
id: true,
6661
environments: {
@@ -88,15 +83,15 @@ export class RunListPresenter {
8883
});
8984

9085
//get all possible tasks
91-
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
86+
const possibleTasks = await this._replica.backgroundWorkerTask.findMany({
9287
distinct: ["slug"],
9388
where: {
9489
projectId: project.id,
9590
},
9691
});
9792

9893
//get the runs
99-
let runs = await this.#prismaClient.$queryRaw<
94+
let runs = await this._replica.$queryRaw<
10095
{
10196
id: string;
10297
number: BigInt;
@@ -107,10 +102,9 @@ export class RunListPresenter {
107102
status: TaskRunStatus;
108103
createdAt: Date;
109104
lockedAt: Date | null;
110-
completedAt: Date | null;
105+
updatedAt: Date;
111106
isTest: boolean;
112107
spanId: string;
113-
attempts: BigInt;
114108
}[]
115109
>`
116110
SELECT
@@ -123,20 +117,13 @@ export class RunListPresenter {
123117
tr.status AS status,
124118
tr."createdAt" AS "createdAt",
125119
tr."lockedAt" AS "lockedAt",
126-
tra."completedAt" AS "completedAt",
120+
tr."updatedAt" AS "updatedAt",
127121
tr."isTest" AS "isTest",
128-
tr."spanId" AS "spanId",
129-
COUNT(tra.id) AS attempts
122+
tr."spanId" AS "spanId"
130123
FROM
131124
${sqlDatabaseSchema}."TaskRun" tr
132125
LEFT JOIN
133-
(
134-
SELECT *,
135-
ROW_NUMBER() OVER (PARTITION BY "taskRunId" ORDER BY "createdAt" DESC) rn
136-
FROM ${sqlDatabaseSchema}."TaskRunAttempt"
137-
) tra ON tr.id = tra."taskRunId" AND tra.rn = 1
138-
LEFT JOIN
139-
${sqlDatabaseSchema}."BackgroundWorker" bw ON tra."backgroundWorkerId" = bw.id
126+
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
140127
WHERE
141128
-- project
142129
tr."projectId" = ${project.id}
@@ -154,15 +141,11 @@ export class RunListPresenter {
154141
? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})`
155142
: Prisma.empty
156143
}
157-
${hasStatusFilters ? Prisma.sql`AND (` : Prisma.empty}
158144
${
159145
statuses && statuses.length > 0
160-
? Prisma.sql`tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
146+
? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
161147
: Prisma.empty
162148
}
163-
${statuses && statuses.length > 0 && hasStatusFilters ? Prisma.sql` OR ` : Prisma.empty}
164-
${hasStatusFilters ? Prisma.sql`tr.status IS NULL` : Prisma.empty}
165-
${hasStatusFilters ? Prisma.sql`) ` : Prisma.empty}
166149
${
167150
environments && environments.length > 0
168151
? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})`
@@ -179,8 +162,6 @@ export class RunListPresenter {
179162
? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp`
180163
: Prisma.empty
181164
}
182-
GROUP BY
183-
tr."friendlyId", tr."taskIdentifier", tr."runtimeEnvironmentId", tr.id, bw.version, tra.status, tr."createdAt", tra."startedAt", tra."completedAt"
184165
ORDER BY
185166
${direction === "forward" ? Prisma.sql`tr.id DESC` : Prisma.sql`tr.id ASC`}
186167
LIMIT ${pageSize + 1}`;
@@ -219,19 +200,21 @@ export class RunListPresenter {
219200
throw new Error(`Environment not found for TaskRun ${run.id}`);
220201
}
221202

203+
const hasFinished = FINISHED_STATUSES.includes(run.status);
204+
222205
return {
223206
id: run.id,
224207
friendlyId: run.runFriendlyId,
225208
number: Number(run.number),
226-
createdAt: run.createdAt,
227-
startedAt: run.lockedAt,
228-
completedAt: run.completedAt,
209+
createdAt: run.createdAt.toISOString(),
210+
startedAt: run.lockedAt ? run.lockedAt.toISOString() : undefined,
211+
hasFinished,
212+
finishedAt: hasFinished ? run.updatedAt.toISOString() : undefined,
229213
isTest: run.isTest,
230214
status: run.status,
231215
version: run.version,
232216
taskIdentifier: run.taskIdentifier,
233217
spanId: run.spanId,
234-
attempts: Number(run.attempts),
235218
isReplayable: true,
236219
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
237220
environment: displayableEnvironments(environment, userId),

0 commit comments

Comments
 (0)