diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore index 074c30a48..8b81451ea 100644 --- a/apps/webapp/.gitignore +++ b/apps/webapp/.gitignore @@ -17,4 +17,5 @@ build-storybook.log .storybook-out storybook-static -/prisma/seed.js \ No newline at end of file +/prisma/seed.js +/prisma/populate.js \ No newline at end of file diff --git a/apps/webapp/app/components/primitives/TooltipPortal.tsx b/apps/webapp/app/components/primitives/TooltipPortal.tsx new file mode 100644 index 000000000..a17247bd9 --- /dev/null +++ b/apps/webapp/app/components/primitives/TooltipPortal.tsx @@ -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(); + const [popperElement, setPopperElement] = useState(); + 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( +
+ {children} +
, + 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[2] = { + placement: "right-start", + modifiers: [ + { + name: "offset", + options: { + offset: [8, 8], + }, + }, + ], +}; diff --git a/apps/webapp/app/components/runs/v3/LiveTimer.tsx b/apps/webapp/app/components/runs/v3/LiveTimer.tsx index 02926fff3..e48873752 100644 --- a/apps/webapp/app/components/runs/v3/LiveTimer.tsx +++ b/apps/webapp/app/components/runs/v3/LiveTimer.tsx @@ -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(); @@ -30,13 +26,13 @@ export function LiveTimer({ }, [startTime]); return ( - + <> {formatDuration(startTime, now, { style: "short", maxDecimalPoints: 0, units: ["d", "h", "m", "s"], })} - + ); } diff --git a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx index e785ccc4a..17b4a317b 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx @@ -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]; } diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 8b799c97e..3e30b6325 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -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 ? : "–"} - {formatDuration(run.startedAt, run.completedAt, { - style: "short", - })} + {run.startedAt && run.finishedAt ? ( + formatDuration(new Date(run.startedAt), new Date(run.finishedAt), { + style: "short", + }) + ) : run.startedAt ? ( + + ) : ( + "–" + )} {run.isTest ? ( diff --git a/apps/webapp/app/hooks/useLazyRef.ts b/apps/webapp/app/hooks/useLazyRef.ts new file mode 100644 index 000000000..31a8f3650 --- /dev/null +++ b/apps/webapp/app/hooks/useLazyRef.ts @@ -0,0 +1,11 @@ +import { useRef, MutableRefObject } from "react"; + +const useLazyRef = (initialValFunc: () => T) => { + const ref: MutableRefObject = useRef(null); + if (ref.current === null) { + ref.current = initialValFunc(); + } + return ref; +}; + +export default useLazyRef; diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index d203d7f28..019f48f0c 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -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>; 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), diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index bf57f1a26..e7ac50573 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -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>; @@ -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; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam._index/route.tsx index 7a0753de3..3c9dedcf6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam._index/route.tsx @@ -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(); + const { filterText, setFilterText, filteredItems } = useTextFilter({ + 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() { -
-
- {hasTasks ? ( -
- {!userHasTasks && } +
+ {hasTasks ? ( +
+ {!userHasTasks && } +
+
+ setFilterText(e.target.value)} + autoFocus + /> +
@@ -120,13 +153,12 @@ export default function Page() { Activity (7d) Avg. duration Environments - Last run Go to page - {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() { ))} - - {task.latestRun ? ( -
- - -
- ) : ( - "Never run" - )} -
); }) ) : ( - + No tasks match your filters @@ -250,12 +264,12 @@ export default function Page() {
- ) : ( - - - - )} -
+
+ ) : ( + + + + )}
@@ -362,7 +376,9 @@ function TaskActivityGraph({ activity }: { activity: TaskActivity }) { content={} allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 1000 }} + animationDuration={0} /> + {/* The background */} ) })); const title = payload[0].payload.day as string; const formattedDate = formatDateTime(new Date(title), "UTC", [], false, false); + return ( -
- {formattedDate} -
- {items.map((item) => ( - - -

{item.value}

-
- ))} + +
+ {formattedDate} +
+ {items.map((item) => ( + + +

{item.value}

+
+ ))} +
-
+ ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx index ed07d6843..418d82e16 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx @@ -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() { } }} > - + New environment variables
{ - 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 diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx index 976390e46..57107654c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx @@ -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(); + const { data } = useTypedLoaderData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; const project = useProject(); @@ -64,36 +66,53 @@ export default function Page() { - {list.runs.length === 0 && !list.hasFilters ? ( - list.possibleTasks.length === 0 ? ( - - ) : ( - - ) - ) : ( -
-
-
- -
- -
+ +
+ + Loading runs
- - -
-
- )} + } + > + + {(list) => ( + <> + {list.runs.length === 0 && !list.hasFilters ? ( + list.possibleTasks.length === 0 ? ( + + ) : ( + + ) + ) : ( +
+
+
+ +
+ +
+
+ + + +
+
+ )} + + )} +
+ ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx index 9f86db70e..aace86174 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx @@ -261,10 +261,10 @@ function SchedulesTable({ {schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"} - + - {schedule.lastRun ? : "–"} + {schedule.lastRun ? : "–"}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx index d28e5ee3b..4b280f3b5 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx @@ -342,7 +342,9 @@ function Timeline({ startTime, duration, inProgress, isError }: TimelineProps) { {state === "pending" ? ( - + + + ) : ( =16.0.0" } -} \ No newline at end of file +} diff --git a/apps/webapp/prisma/populate.ts b/apps/webapp/prisma/populate.ts new file mode 100644 index 000000000..30239479e --- /dev/null +++ b/apps/webapp/prisma/populate.ts @@ -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(array: T[]) { + return Math.floor(Math.random() * array.length); +} + +populate() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js index 9ca7e0830..7ddae5e0e 100644 --- a/apps/webapp/remix.config.js +++ b/apps/webapp/remix.config.js @@ -21,6 +21,7 @@ module.exports = { "random-words", "superjson", ], + browserNodeBuiltinsPolyfill: { modules: { path: true, os: true, crypto: true } }, watchPaths: async () => { return [ "../../packages/core/src/**/*", diff --git a/package.json b/package.json index c5159e1b0..63644057d 100644 --- a/package.json +++ b/package.json @@ -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" } } -} \ No newline at end of file +} diff --git a/packages/database/prisma/migrations/20240430110419_task_run_indexes_projectid_task_identifier_and_status/migration.sql b/packages/database/prisma/migrations/20240430110419_task_run_indexes_projectid_task_identifier_and_status/migration.sql new file mode 100644 index 000000000..e354381ee --- /dev/null +++ b/packages/database/prisma/migrations/20240430110419_task_run_indexes_projectid_task_identifier_and_status/migration.sql @@ -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"); diff --git a/packages/database/prisma/migrations/20240430110717_task_run_compound_index_projectid_task_identifier_and_status/migration.sql b/packages/database/prisma/migrations/20240430110717_task_run_compound_index_projectid_task_identifier_and_status/migration.sql new file mode 100644 index 000000000..2f2ccc604 --- /dev/null +++ b/packages/database/prisma/migrations/20240430110717_task_run_compound_index_projectid_task_identifier_and_status/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "TaskRun_projectId_taskIdentifier_status_idx" ON "TaskRun"("projectId", "taskIdentifier", "status"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 1afa7b1c3..d36c3061e 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 674b46553..be2dc4374 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/turbo.json b/turbo.json index a64a9d1f7..afcc536c4 100644 --- a/turbo.json +++ b/turbo.json @@ -29,6 +29,9 @@ "db:studio": { "cache": false }, + "db:populate": { + "cache": false + }, "dev": { "cache": false },