Merge branch 'main' into v3/fix-consecutive-waits
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped

This commit is contained in:
nicktrn
2024-04-30 16:48:55 +01:00
22 changed files with 474 additions and 201 deletions
+2 -1
View File
@@ -17,4 +17,5 @@ build-storybook.log
.storybook-out
storybook-static
/prisma/seed.js
/prisma/seed.js
/prisma/populate.js
@@ -0,0 +1,103 @@
import type { VirtualElement as IVirtualElement } from "@popperjs/core";
import { ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { useEvent } from "react-use";
import useLazyRef from "~/hooks/useLazyRef";
// Recharts 3.x will have portal support, but until then we're using this:
//https://github.com/recharts/recharts/issues/2458#issuecomment-1063463873
export interface PopperPortalProps {
active?: boolean;
children: ReactNode;
}
export default function TooltipPortal({ active = true, children }: PopperPortalProps) {
const [portalElement, setPortalElement] = useState<HTMLDivElement>();
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>();
const virtualElementRef = useLazyRef(() => new VirtualElement());
const { styles, attributes, update } = usePopper(
virtualElementRef.current,
popperElement,
POPPER_OPTIONS
);
useEffect(() => {
const el = document.createElement("div");
document.body.appendChild(el);
setPortalElement(el);
return () => el.remove();
}, []);
useEvent("mousemove", ({ clientX: x, clientY: y }) => {
virtualElementRef.current?.update(x, y);
if (!active) return;
update?.();
});
useEffect(() => {
if (!active) return;
update?.();
}, [active, update]);
if (!portalElement) return null;
return createPortal(
<div
ref={setPopperElement}
{...attributes.popper}
style={{
...styles.popper,
zIndex: 1000,
display: active ? "block" : "none",
}}
>
{children}
</div>,
portalElement
);
}
class VirtualElement implements IVirtualElement {
private rect = {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON() {
return this;
},
};
update(x: number, y: number) {
this.rect.y = y;
this.rect.top = y;
this.rect.bottom = y;
this.rect.x = x;
this.rect.left = x;
this.rect.right = x;
}
getBoundingClientRect(): DOMRect {
return this.rect;
}
}
const POPPER_OPTIONS: Parameters<typeof usePopper>[2] = {
placement: "right-start",
modifiers: [
{
name: "offset",
options: {
offset: [8, 8],
},
},
],
};
@@ -1,18 +1,14 @@
import { formatDuration } from "@trigger.dev/core/v3";
import { useState, useEffect } from "react";
import { Paragraph } from "~/components/primitives/Paragraph";
import { cn } from "~/utils/cn";
import { useEffect, useState } from "react";
export function LiveTimer({
startTime,
endTime,
updateInterval = 250,
className,
}: {
startTime: Date;
endTime?: Date;
updateInterval?: number;
className?: string;
}) {
const [now, setNow] = useState<Date>();
@@ -30,13 +26,13 @@ export function LiveTimer({
}, [startTime]);
return (
<Paragraph variant="extra-small" className={cn("whitespace-nowrap tabular-nums", className)}>
<>
{formatDuration(startTime, now, {
style: "short",
maxDecimalPoints: 0,
units: ["d", "h", "m", "s"],
})}
</Paragraph>
</>
);
}
@@ -38,6 +38,15 @@ export const RUNNING_STATUSES: TaskRunStatus[] = [
"WAITING_TO_RESUME",
];
export const FINISHED_STATUSES: TaskRunStatus[] = [
"COMPLETED_SUCCESSFULLY",
"CANCELED",
"COMPLETED_WITH_ERRORS",
"INTERRUPTED",
"SYSTEM_FAILURE",
"CRASHED",
];
export function descriptionForTaskRunStatus(status: TaskRunStatus): string {
return taskRunStatusDescriptions[status];
}
@@ -3,7 +3,6 @@ import { StopIcon } from "@heroicons/react/24/outline";
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
import { useLocation } from "@remix-run/react";
import { formatDuration } from "@trigger.dev/core/v3";
import { User } from "@trigger.dev/database";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { useEnvironments } from "~/hooks/useEnvironments";
@@ -28,6 +27,7 @@ import {
import { CancelRunDialog } from "./CancelRunDialog";
import { ReplayRunDialog } from "./ReplayRunDialog";
import { TaskRunStatusCombo } from "./TaskRunStatus";
import { LiveTimer } from "./LiveTimer";
type RunsTableProps = {
total: number;
@@ -94,9 +94,15 @@ export function TaskRunsTable({
{run.startedAt ? <DateTime date={run.startedAt} /> : ""}
</TableCell>
<TableCell to={path}>
{formatDuration(run.startedAt, run.completedAt, {
style: "short",
})}
{run.startedAt && run.finishedAt ? (
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
style: "short",
})
) : run.startedAt ? (
<LiveTimer startTime={new Date(run.startedAt)} />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{run.isTest ? (
+11
View File
@@ -0,0 +1,11 @@
import { useRef, MutableRefObject } from "react";
const useLazyRef = <T>(initialValFunc: () => T) => {
const ref: MutableRefObject<T | null> = useRef(null);
if (ref.current === null) {
ref.current = initialValFunc();
}
return ref;
};
export default useLazyRef;
@@ -1,9 +1,10 @@
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
import { Direction } from "~/components/runs/RunStatuses";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
import { getUsername } from "~/utils/username";
import { CANCELLABLE_STATUSES } from "~/v3/services/cancelTaskRun.server";
import { BasePresenter } from "./basePresenter.server";
type RunListOptions = {
userId?: string;
@@ -28,13 +29,7 @@ export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
export type RunListItem = RunList["runs"][0];
export type RunListAppliedFilters = RunList["filters"];
export class RunListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
export class RunListPresenter extends BasePresenter {
public async call({
userId,
projectSlug,
@@ -60,7 +55,7 @@ export class RunListPresenter {
to !== undefined;
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
const project = await this._replica.project.findFirstOrThrow({
select: {
id: true,
environments: {
@@ -88,7 +83,7 @@ export class RunListPresenter {
});
//get all possible tasks
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
const possibleTasks = await this._replica.backgroundWorkerTask.findMany({
distinct: ["slug"],
where: {
projectId: project.id,
@@ -96,7 +91,7 @@ export class RunListPresenter {
});
//get the runs
let runs = await this.#prismaClient.$queryRaw<
let runs = await this._replica.$queryRaw<
{
id: string;
number: BigInt;
@@ -107,10 +102,9 @@ export class RunListPresenter {
status: TaskRunStatus;
createdAt: Date;
lockedAt: Date | null;
completedAt: Date | null;
updatedAt: Date;
isTest: boolean;
spanId: string;
attempts: BigInt;
}[]
>`
SELECT
@@ -123,20 +117,13 @@ export class RunListPresenter {
tr.status AS status,
tr."createdAt" AS "createdAt",
tr."lockedAt" AS "lockedAt",
tra."completedAt" AS "completedAt",
tr."updatedAt" AS "updatedAt",
tr."isTest" AS "isTest",
tr."spanId" AS "spanId",
COUNT(tra.id) AS attempts
tr."spanId" AS "spanId"
FROM
${sqlDatabaseSchema}."TaskRun" tr
LEFT JOIN
(
SELECT *,
ROW_NUMBER() OVER (PARTITION BY "taskRunId" ORDER BY "createdAt" DESC) rn
FROM ${sqlDatabaseSchema}."TaskRunAttempt"
) tra ON tr.id = tra."taskRunId" AND tra.rn = 1
LEFT JOIN
${sqlDatabaseSchema}."BackgroundWorker" bw ON tra."backgroundWorkerId" = bw.id
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
WHERE
-- project
tr."projectId" = ${project.id}
@@ -154,15 +141,11 @@ export class RunListPresenter {
? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})`
: Prisma.empty
}
${hasStatusFilters ? Prisma.sql`AND (` : Prisma.empty}
${
statuses && statuses.length > 0
? Prisma.sql`tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
: Prisma.empty
}
${statuses && statuses.length > 0 && hasStatusFilters ? Prisma.sql` OR ` : Prisma.empty}
${hasStatusFilters ? Prisma.sql`tr.status IS NULL` : Prisma.empty}
${hasStatusFilters ? Prisma.sql`) ` : Prisma.empty}
${
environments && environments.length > 0
? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})`
@@ -179,8 +162,6 @@ export class RunListPresenter {
? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp`
: Prisma.empty
}
GROUP BY
tr."friendlyId", tr."taskIdentifier", tr."runtimeEnvironmentId", tr.id, bw.version, tra.status, tr."createdAt", tra."startedAt", tra."completedAt"
ORDER BY
${direction === "forward" ? Prisma.sql`tr.id DESC` : Prisma.sql`tr.id ASC`}
LIMIT ${pageSize + 1}`;
@@ -219,19 +200,21 @@ export class RunListPresenter {
throw new Error(`Environment not found for TaskRun ${run.id}`);
}
const hasFinished = FINISHED_STATUSES.includes(run.status);
return {
id: run.id,
friendlyId: run.runFriendlyId,
number: Number(run.number),
createdAt: run.createdAt,
startedAt: run.lockedAt,
completedAt: run.completedAt,
createdAt: run.createdAt.toISOString(),
startedAt: run.lockedAt ? run.lockedAt.toISOString() : undefined,
hasFinished,
finishedAt: hasFinished ? run.updatedAt.toISOString() : undefined,
isTest: run.isTest,
status: run.status,
version: run.version,
taskIdentifier: run.taskIdentifier,
spanId: run.spanId,
attempts: Number(run.attempts),
isReplayable: true,
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
environment: displayableEnvironments(environment, userId),
@@ -4,16 +4,15 @@ import {
TaskRunStatus,
TaskTriggerSource,
} from "@trigger.dev/database";
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { sqlDatabaseSchema } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { logger } from "~/services/logger.server";
import { getUsername } from "~/utils/username";
import { BasePresenter } from "./basePresenter.server";
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
export type Task = {
slug: string;
@@ -26,10 +25,6 @@ export type Task = {
type: RuntimeEnvironmentType;
userName?: string;
}[];
latestRun?: {
createdAt: Date;
status: TaskRunStatus;
};
};
type Return = Awaited<ReturnType<TaskListPresenter["call"]>>;
@@ -98,39 +93,8 @@ export class TaskListPresenter extends BasePresenter {
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" tasks ON tasks."workerId" = workers.id
ORDER BY slug ASC;`;
let latestRuns = [] as {
createdAt: Date;
status: TaskRunStatus;
taskIdentifier: string;
}[];
if (tasks.length > 0) {
const uniqueTaskSlugs = new Set(tasks.map((t) => t.slug));
latestRuns = await this._replica.$queryRaw<
{
createdAt: Date;
status: TaskRunStatus;
taskIdentifier: string;
}[]
>`
SELECT * FROM (
SELECT
"createdAt",
"status",
"taskIdentifier",
ROW_NUMBER() OVER (PARTITION BY "taskIdentifier" ORDER BY "updatedAt" DESC) AS rn
FROM
${sqlDatabaseSchema}."TaskRun"
WHERE
"taskIdentifier" IN(${Prisma.join(Array.from(uniqueTaskSlugs))})
AND "projectId" = ${project.id}
) t
WHERE rn = 1;`;
}
//group by the task identifier (task.slug). Add the latestRun and add all the environments.
const outputTasks = tasks.reduce((acc, task) => {
const latestRun = latestRuns.find((r) => r.taskIdentifier === task.slug);
const environment = project.environments.find((env) => env.id === task.runtimeEnvironmentId);
if (!environment) {
throw new Error(`Environment not found for TaskRun ${task.id}`);
@@ -151,13 +115,6 @@ export class TaskListPresenter extends BasePresenter {
//order the environments
existingTask.environments = sortEnvironments(existingTask.environments);
existingTask.latestRun = latestRun
? {
createdAt: latestRun.createdAt,
status: latestRun.status,
}
: undefined;
return acc;
}, [] as Task[]);
@@ -186,6 +143,10 @@ export class TaskListPresenter extends BasePresenter {
}
async #getActivity(tasks: string[], projectId: string) {
if (tasks.length === 0) {
return {};
}
const activity = await this._replica.$queryRaw<
{
taskIdentifier: string;
@@ -257,6 +218,10 @@ export class TaskListPresenter extends BasePresenter {
}
async #getRunningStats(tasks: string[], projectId: string) {
if (tasks.length === 0) {
return {};
}
const statuses = await this._replica.$queryRaw<
{
taskIdentifier: string;
@@ -305,6 +270,10 @@ export class TaskListPresenter extends BasePresenter {
}
async #getAverageDurations(tasks: string[], projectId: string) {
if (tasks.length === 0) {
return {};
}
const durations = await this._replica.$queryRaw<
{
taskIdentifier: string;
@@ -1,10 +1,10 @@
import { ChatBubbleLeftRightIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
import { useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { TaskRunStatus } from "@trigger.dev/database";
import { Fragment, Suspense, useEffect, useState } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { Feedback } from "~/components/Feedback";
import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/components/SetupCommands";
@@ -14,8 +14,9 @@ import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
import { formatDateTime } from "~/components/primitives/DateTime";
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
@@ -31,13 +32,9 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
import {
TaskRunStatusCombo,
TaskRunStatusIcon,
runStatusClassNameColor,
runStatusTitle,
} from "~/components/runs/v3/TaskRunStatus";
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
import {
TaskTriggerSourceIcon,
taskTriggerSourceDescription,
@@ -45,8 +42,8 @@ import {
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
import { useTextFilter } from "~/hooks/useTextFilter";
import { Task, TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3RunsPath, v3TasksStreamingPath } from "~/utils/pathBuilder";
@@ -84,6 +81,31 @@ export default function Page() {
const project = useProject();
const { tasks, userHasTasks, activity, runningStats, durations } =
useTypedLoaderData<typeof loader>();
const { filterText, setFilterText, filteredItems } = useTextFilter<Task>({
items: tasks,
filter: (task, text) => {
if (task.slug.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (
task.exportName.toLowerCase().includes(text.toLowerCase().replace("(", "").replace(")", ""))
) {
return true;
}
if (task.filePath.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) {
return true;
}
return false;
},
});
const hasTasks = tasks.length > 0;
//live reload the page when the tasks change
@@ -105,11 +127,22 @@ export default function Page() {
<PageTitle title="Tasks" />
</NavBar>
<PageBody>
<div className={cn("grid h-full grid-cols-1 gap-4")}>
<div className="h-full">
{hasTasks ? (
<div className="flex flex-col gap-4 pb-4">
{!userHasTasks && <UserHasNoTasks />}
<div className={cn("grid h-full grid-rows-1")}>
{hasTasks ? (
<div className="flex flex-col gap-4 pb-4">
{!userHasTasks && <UserHasNoTasks />}
<div className="pb-4">
<div className="h-8">
<Input
placeholder="Search tasks"
variant="tertiary"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
autoFocus
/>
</div>
<Table>
<TableHeader>
<TableRow>
@@ -120,13 +153,12 @@ export default function Page() {
<TableHeaderCell>Activity (7d)</TableHeaderCell>
<TableHeaderCell>Avg. duration</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell>Last run</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{tasks.length > 0 ? (
tasks.map((task) => {
{filteredItems.length > 0 ? (
filteredItems.map((task) => {
const path = v3RunsPath(organization, project, {
tasks: [task.slug],
});
@@ -218,30 +250,12 @@ export default function Page() {
))}
</div>
</TableCell>
<TableCell to={path}>
{task.latestRun ? (
<div
className={cn(
"flex items-center gap-1",
runStatusClassNameColor(task.latestRun.status)
)}
>
<TaskRunStatusIcon
status={task.latestRun.status}
className="h-4 w-4"
/>
<DateTime date={task.latestRun.createdAt} />
</div>
) : (
"Never run"
)}
</TableCell>
<TableCellChevron to={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={6}>
<TableBlankRow colSpan={8}>
<Paragraph variant="small" className="flex items-center justify-center">
No tasks match your filters
</Paragraph>
@@ -250,12 +264,12 @@ export default function Page() {
</TableBody>
</Table>
</div>
) : (
<MainCenteredContainer className="max-w-prose">
<CreateTaskInstructions />
</MainCenteredContainer>
)}
</div>
</div>
) : (
<MainCenteredContainer className="max-w-prose">
<CreateTaskInstructions />
</MainCenteredContainer>
)}
</div>
</PageBody>
</PageContainer>
@@ -362,7 +376,9 @@ function TaskActivityGraph({ activity }: { activity: TaskActivity }) {
content={<CustomTooltip />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
animationDuration={0}
/>
{/* The background */}
<Bar
dataKey="bg"
@@ -425,18 +441,21 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
}));
const title = payload[0].payload.day as string;
const formattedDate = formatDateTime(new Date(title), "UTC", [], false, false);
return (
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b-charcoal-650 border-b pb-2">{formattedDate}</Header3>
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 text-xs text-text-bright">
{items.map((item) => (
<Fragment key={item.status}>
<TaskRunStatusCombo status={item.status} />
<p>{item.value}</p>
</Fragment>
))}
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b-charcoal-650 border-b pb-2">{formattedDate}</Header3>
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 text-xs text-text-bright">
{items.map((item) => (
<Fragment key={item.status}>
<TaskRunStatusCombo status={item.status} />
<p>{item.value}</p>
</Fragment>
))}
</div>
</div>
</div>
</TooltipPortal>
);
}
@@ -38,6 +38,7 @@ import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3EnvironmentVariablesPath } from "~/utils/pathBuilder";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
import dotenv from "dotenv";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -183,7 +184,7 @@ export default function Page() {
}
}}
>
<DialogContent>
<DialogContent className="md:max-w-2xl lg:max-w-3xl">
<DialogHeader>New environment variables</DialogHeader>
<Form
method="post"
@@ -308,20 +309,12 @@ function VariableFields({
if (!clipboardData) return;
let text = clipboardData.getData("text");
//replace carriage returns
text = text.replace(/\r/g, "");
const lines = text.split("\n");
if (!text) return;
const keyValuePairs = lines.flatMap((line) => {
if (line.trim().startsWith("#")) return [];
const split = line.split("=");
if (split.length === 2) {
return [{ key: split[0], value: split[1] }];
}
return [];
});
const variables = dotenv.parse(text);
const keyValuePairs = Object.entries(variables).map(([key, value]) => ({ key, value }));
//do the default paste
if (keyValuePairs.length === 0) return;
//prevent default pasting
@@ -1,7 +1,7 @@
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { TypedAwait, typeddefer, typedjson, useTypedLoaderData } from "remix-typedjson";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { StepContentContainer } from "~/components/StepContentContainer";
@@ -22,6 +22,8 @@ import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3ProjectPath, v3TestPath } from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
import { TextLink } from "~/components/primitives/TextLink";
import { Spinner } from "~/components/primitives/Spinner";
import { Suspense } from "react";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -33,7 +35,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
TaskRunListSearchFilters.parse(s);
const presenter = new RunListPresenter();
const list = await presenter.call({
const list = presenter.call({
userId,
projectSlug: projectParam,
tasks,
@@ -46,13 +48,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
cursor: cursor,
});
return typedjson({
list,
return typeddefer({
data: list,
});
};
export default function Page() {
const { list } = useTypedLoaderData<typeof loader>();
const { data } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const project = useProject();
@@ -64,36 +66,53 @@ export default function Page() {
<PageTitle title="Runs" />
</NavBar>
<PageBody>
{list.runs.length === 0 && !list.hasFilters ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
) : (
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
<div>
<div className="mb-2 flex items-center justify-between gap-x-2">
<RunsFilters
possibleEnvironments={project.environments}
possibleTasks={list.possibleTasks}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
<Suspense
fallback={
<div className="flex items-center justify-center py-2">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading runs</Paragraph>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</div>
)}
}
>
<TypedAwait resolve={data}>
{(list) => (
<>
{list.runs.length === 0 && !list.hasFilters ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
) : (
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
<div>
<div className="mb-2 flex items-center justify-between gap-x-2">
<RunsFilters
possibleEnvironments={project.environments}
possibleTasks={list.possibleTasks}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</div>
)}
</>
)}
</TypedAwait>
</Suspense>
</PageBody>
</>
);
@@ -261,10 +261,10 @@ function SchedulesTable({
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
<DateTime date={schedule.nextRun} />
<DateTime date={schedule.nextRun} timeZone="utc" />
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.lastRun ? <DateTime date={schedule.lastRun} /> : ""}
{schedule.lastRun ? <DateTime date={schedule.lastRun} timeZone="utc" /> : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
<div className="flex gap-1">
@@ -342,7 +342,9 @@ function Timeline({ startTime, duration, inProgress, isError }: TimelineProps) {
<DateTimeAccurate date={startTime} />
</Paragraph>
{state === "pending" ? (
<LiveTimer startTime={startTime} className="" />
<Paragraph variant="extra-small" className={cn("whitespace-nowrap tabular-nums")}>
<LiveTimer startTime={startTime} />
</Paragraph>
) : (
<Paragraph variant="small">
<DateTimeAccurate
+6 -1
View File
@@ -16,6 +16,8 @@
"typecheck": "tsc -p ./tsconfig.check.json",
"db:seed": "node prisma/seed.js",
"db:seed:local": "ts-node prisma/seed.ts",
"build:db:populate": "esbuild --platform=node --bundle --minify --format=cjs ./prisma/populate.ts --outdir=prisma",
"db:populate": "node prisma/populate.js --",
"generate:sourcemaps": "remix build --sourcemap",
"clean:sourcemaps": "run-s clean:sourcemaps:*",
"clean:sourcemaps:public": "rimraf ./build/**/*.map",
@@ -58,6 +60,7 @@
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@opentelemetry/sdk-trace-node": "^1.22.0",
"@opentelemetry/semantic-conventions": "^1.22.0",
"@popperjs/core": "^2.11.8",
"@prisma/instrumentation": "^5.11.0",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.3",
@@ -105,6 +108,7 @@
"cronstrue": "^2.21.0",
"cross-env": "^7.0.3",
"cuid": "^2.1.8",
"dotenv": "^16.4.5",
"emails": "workspace:*",
"evt": "^2.4.13",
"express": "^4.18.1",
@@ -136,6 +140,7 @@
"react-collapse": "^5.1.1",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.1",
"react-popper": "^2.3.0",
"react-resizable-panels": "^2.0.9",
"react-stately": "^3.29.1",
"react-use": "^17.4.0",
@@ -227,4 +232,4 @@
"engines": {
"node": ">=16.0.0"
}
}
}
+101
View File
@@ -0,0 +1,101 @@
// Bulk adds data to the database for testing
// Call it like this
// 1. pnpm run build:db:populate
// 2. pnpm run db:populate -- --projectRef=proj_liazlkfgmfcusswwgohl --taskIdentifier=child-task --runCount=100000
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { prisma } from "../app/db.server";
async function populate() {
if (process.env.NODE_ENV !== "development") {
return;
}
const projectRef = getArg("projectRef");
if (!projectRef) {
throw new Error("projectRef is required");
}
const project = await prisma.project.findUnique({
include: {
environments: true,
},
where: {
externalRef: projectRef,
},
});
if (!project) {
throw new Error("Project not found");
}
const taskIdentifier = getArg("taskIdentifier");
if (!taskIdentifier) {
throw new Error("taskIdentifier is required");
}
const runCount = parseInt(getArg("runCount") || "100");
const task = await prisma.backgroundWorkerTask.findFirst({
where: {
projectId: project.id,
slug: taskIdentifier,
},
orderBy: {
createdAt: "desc",
},
});
if (!task) {
throw new Error("Task not found");
}
const runs = await prisma.taskRun.createMany({
data: Array(runCount)
.fill(0)
.map((_, index) => {
const friendlyId = generateFriendlyId("run");
return {
status: "CANCELED",
number: index + 1,
friendlyId,
runtimeEnvironmentId: project.environments[randomIndex(project.environments)].id,
projectId: project.id,
taskIdentifier,
payload: JSON.stringify({ foo: "bar" }),
traceId: "traceId",
spanId: "spanId",
queue: "task/${taskIdentifier}",
};
}),
skipDuplicates: true,
});
console.log(`Added ${runs.count} runs`);
}
function getArg(name: string) {
const args = process.argv.slice(2);
let value = "";
args.forEach((val) => {
if (val.startsWith(`--${name}=`)) {
value = val.split("=")[1];
}
});
return !value ? undefined : value;
}
function randomIndex<T>(array: T[]) {
return Math.floor(Math.random() * array.length);
}
populate()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+1
View File
@@ -21,6 +21,7 @@ module.exports = {
"random-words",
"superjson",
],
browserNodeBuiltinsPolyfill: { modules: { path: true, os: true, crypto: true } },
watchPaths: async () => {
return [
"../../packages/core/src/**/*",
+2 -1
View File
@@ -14,6 +14,7 @@
"db:migrate": "turbo run db:migrate:deploy generate",
"db:seed": "turbo run db:seed",
"db:studio": "turbo run db:studio",
"db:populate": "turbo run db:populate",
"dev": "turbo run dev --parallel",
"i:dev": "infisical run -- turbo run dev --parallel",
"format": "prettier . --write --config prettier.config.js",
@@ -72,4 +73,4 @@
"engine.io-parser@5.2.2": "patches/engine.io-parser@5.2.2.patch"
}
}
}
}
@@ -0,0 +1,8 @@
-- CreateIndex
CREATE INDEX "TaskRun_projectId_idx" ON "TaskRun"("projectId");
-- CreateIndex
CREATE INDEX "TaskRun_projectId_taskIdentifier_idx" ON "TaskRun"("projectId", "taskIdentifier");
-- CreateIndex
CREATE INDEX "TaskRun_projectId_status_idx" ON "TaskRun"("projectId", "status");
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "TaskRun_projectId_taskIdentifier_status_idx" ON "TaskRun"("projectId", "taskIdentifier", "status");
+5
View File
@@ -1638,6 +1638,11 @@ model TaskRun {
@@unique([runtimeEnvironmentId, idempotencyKey])
// Task activity graph
@@index([projectId, createdAt, taskIdentifier])
//Runs list
@@index([projectId])
@@index([projectId, taskIdentifier])
@@index([projectId, status])
@@index([projectId, taskIdentifier, status])
}
enum TaskRunStatus {
+39 -3
View File
@@ -291,6 +291,9 @@ importers:
'@opentelemetry/semantic-conventions':
specifier: ^1.22.0
version: 1.22.0
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
'@prisma/instrumentation':
specifier: ^5.11.0
version: 5.11.0
@@ -432,6 +435,9 @@ importers:
cuid:
specifier: ^2.1.8
version: 2.1.8
dotenv:
specifier: ^16.4.5
version: 16.4.5
emails:
specifier: workspace:*
version: link:../../packages/emails
@@ -525,6 +531,9 @@ importers:
react-hotkeys-hook:
specifier: ^4.4.1
version: 4.4.1(react-dom@18.2.0)(react@18.2.0)
react-popper:
specifier: ^2.3.0
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0)
react-resizable-panels:
specifier: ^2.0.9
version: 2.0.9(react-dom@18.2.0)(react@18.2.0)
@@ -9515,6 +9524,10 @@ packages:
resolution: {integrity: sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==}
dev: true
/@popperjs/core@2.11.8:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
/@prisma/client@5.4.1(prisma@5.4.1):
resolution: {integrity: sha512-xyD0DJ3gRNfLbPsC+YfMBBuLJtZKQfy1OD2qU/PZg+HKrr7SO+09174LMeTlWP0YF2wca9LxtVd4HnAiB5ketQ==}
engines: {node: '>=16.13'}
@@ -11891,7 +11904,7 @@ packages:
cacache: 15.3.0
chalk: 4.1.2
chokidar: 3.5.3
dotenv: 16.4.4
dotenv: 16.4.5
esbuild: 0.17.6
esbuild-plugins-node-modules-polyfill: 1.3.0(esbuild@0.17.6)
execa: 5.1.1
@@ -11975,7 +11988,7 @@ packages:
cacache: 17.1.4
chalk: 4.1.2
chokidar: 3.5.3
dotenv: 16.4.4
dotenv: 16.4.5
esbuild: 0.17.6
esbuild-plugins-node-modules-polyfill: 1.6.1(esbuild@0.17.6)
execa: 5.1.1
@@ -18372,7 +18385,6 @@ packages:
/dotenv@16.4.5:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
engines: {node: '>=12'}
dev: false
/dotenv@8.6.0:
resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==}
@@ -28336,6 +28348,10 @@ packages:
react: 18.2.0
dev: false
/react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
dev: false
/react-hotkeys-hook@4.4.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-sClBMBioFEgFGYLTWWRKvhxcCx1DRznd+wkFHwQZspnRBkHTgruKIHptlK/U/2DPX8BhHoRGzpMVWUXMmdZlmw==}
peerDependencies:
@@ -28356,6 +28372,20 @@ packages:
/react-is@18.1.0:
resolution: {integrity: sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==}
/react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==}
peerDependencies:
'@popperjs/core': ^2.0.0
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
dependencies:
'@popperjs/core': 2.11.8
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-fast-compare: 3.2.2
warning: 4.0.3
dev: false
/react-query@3.39.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==}
peerDependencies:
@@ -33426,6 +33456,12 @@ packages:
makeerror: 1.0.12
dev: true
/warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
dependencies:
loose-envify: 1.4.0
dev: false
/watchpack@2.4.0:
resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==}
engines: {node: '>=10.13.0'}
+3
View File
@@ -29,6 +29,9 @@
"db:studio": {
"cache": false
},
"db:populate": {
"cache": false
},
"dev": {
"cache": false
},