The Run page now uses a component so it can be shared

This commit is contained in:
Matt Aitken
2023-07-10 15:20:01 +01:00
parent 1b65b1d596
commit eae0b8a95e
4 changed files with 425 additions and 409 deletions
@@ -0,0 +1,402 @@
import { conform } from "@conform-to/react";
import { BoltIcon, ForwardIcon } from "@heroicons/react/24/solid";
import { Form, Outlet, useNavigate } from "@remix-run/react";
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
import { useMemo } from "react";
import { usePathName } from "~/hooks/usePathName";
import { Run } from "~/presenters/RunPresenter.server";
import { formatDuration } from "~/utils";
import { cn } from "~/utils/cn";
import {
runCompletedPath,
runTaskPath,
runTriggerPath,
} from "~/utils/pathBuilder";
import { CodeBlock } from "../code/CodeBlock";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { PageBody, PageContainer } from "../layout/AppLayout";
import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { DateTime } from "../primitives/DateTime";
import { Header2 } from "../primitives/Headers";
import { NamedIcon } from "../primitives/NamedIcon";
import {
PageButtons,
PageHeader,
PageInfoGroup,
PageInfoProperty,
PageInfoRow,
PageTitle,
PageTitleRow,
} from "../primitives/PageHeader";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import {
RunBasicStatus,
RunStatusIcon,
RunStatusLabel,
runBasicStatus,
runStatusTitle,
} from "../runs/RunStatuses";
import {
RunPanel,
RunPanelBody,
RunPanelDivider,
RunPanelError,
RunPanelHeader,
RunPanelIconProperty,
RunPanelIconSection,
RunPanelIconTitle,
RunPanelProperties,
} from "./RunCard";
import { TaskCard } from "./TaskCard";
import { TaskCardSkeleton } from "./TaskCardSkeleton";
type RunOverviewProps = {
run: Run;
trigger: {
icon: string;
title: string;
};
showRerun: boolean;
paths: {
back: string;
run: string;
};
};
const taskPattern = /\/tasks\/(.*)/;
export function RunOverview({
run,
trigger,
showRerun,
paths,
}: RunOverviewProps) {
const navigate = useNavigate();
const pathName = usePathName();
const selectedId = useMemo(() => {
if (pathName.endsWith("/completed")) {
return "completed";
}
if (pathName.endsWith("/trigger")) {
return "trigger";
}
const taskMatch = pathName.match(taskPattern);
const taskId = taskMatch ? taskMatch[1] : undefined;
if (taskId) {
return taskId;
}
}, [pathName]);
const basicStatus = runBasicStatus(run.status);
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle
backButton={{
to: paths.back,
text: "Runs",
}}
title={`Run #${run.number}`}
/>
<PageButtons>
{run.isTest && (
<span className="flex items-center gap-1 text-xs uppercase text-slate-600">
<NamedIcon name="beaker" className="h-4 w-4 text-slate-600" />
Test run
</span>
)}
{showRerun && (
<RerunPopover
environmentType={run.environment.type}
status={basicStatus}
/>
)}
</PageButtons>
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup>
<PageInfoProperty
icon={<RunStatusIcon status={run.status} className="h-4 w-4" />}
label={"Status"}
value={runStatusTitle(run.status)}
/>
<PageInfoProperty
icon={"calendar"}
label={"Started"}
value={
run.startedAt ? (
<DateTime date={run.startedAt} />
) : (
"Not started yet"
)
}
/>
<PageInfoProperty
icon={"property"}
label={"Version"}
value={`v${run.version}`}
/>
<PageInfoProperty
label={"Env"}
value={<EnvironmentLabel environment={run.environment} />}
/>
<PageInfoProperty
icon={"clock"}
label={"Duration"}
value={formatDuration(run.startedAt, run.completedAt)}
/>
</PageInfoGroup>
<PageInfoGroup alignment="right">
<Paragraph variant="extra-small" className="text-slate-600">
RUN ID: {run.id}
</Paragraph>
</PageInfoGroup>
</PageInfoRow>
</PageHeader>
<PageBody scrollable={false}>
<div className="grid h-full grid-cols-2 gap-2">
<div className="flex flex-col gap-6 overflow-y-auto py-4 pl-4 pr-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div>
<Header2 className="mb-2">Trigger</Header2>
<RunPanel
selected={selectedId === "trigger"}
onClick={() => navigate(runTriggerPath(paths.run))}
>
<RunPanelHeader
icon={<BoltIcon className="h-5 w-5 text-orange-500" />}
title={
<RunPanelIconTitle
icon={trigger.icon}
title={trigger.title}
/>
}
/>
<RunPanelBody>
<RunPanelProperties
properties={[
{ label: "Event name", text: run.event.name },
...run.properties,
]}
/>
</RunPanelBody>
</RunPanel>
</div>
<div>
<Header2 className="mb-2">Tasks</Header2>
{run.tasks.length > 0 ? (
run.tasks.map((task, index) => {
const isLast = index === run.tasks.length - 1;
return (
<TaskCard
key={task.id}
selectedId={selectedId}
selectedTask={(taskId) => {
navigate(runTaskPath(paths.run, taskId));
}}
isLast={isLast}
depth={0}
{...task}
/>
);
})
) : (
<BlankTasks status={run.status} basicStatus={basicStatus} />
)}
</div>
{(basicStatus === "COMPLETED" || basicStatus === "FAILED") && (
<div>
<Header2 className={cn("mb-2")}>Run Summary</Header2>
<RunPanel
selected={selectedId === "completed"}
onClick={() => navigate(runCompletedPath(paths.run))}
>
<RunPanelHeader
icon={
<RunStatusIcon
status={run.status}
className={"h-5 w-5"}
/>
}
title={
<Paragraph variant="small/bright">
<RunStatusLabel status={run.status} />
</Paragraph>
}
/>
<RunPanelBody>
<RunPanelIconSection>
{run.startedAt && (
<RunPanelIconProperty
icon="calendar"
label="Started at"
value={<DateTime date={run.startedAt} />}
/>
)}
{run.completedAt && (
<RunPanelIconProperty
icon="flag"
label="Finished at"
value={<DateTime date={run.completedAt} />}
/>
)}
{run.startedAt && run.completedAt && (
<RunPanelIconProperty
icon="clock"
label="Total duration"
value={formatDuration(
run.startedAt,
run.completedAt,
{
style: "long",
}
)}
/>
)}
</RunPanelIconSection>
<RunPanelDivider />
{run.error && (
<RunPanelError
text={run.error.message}
stackTrace={run.error.stack}
/>
)}
{run.output ? (
<CodeBlock
language="json"
code={run.output}
maxLines={10}
/>
) : (
run.output === null && (
<Paragraph variant="small">
This Run returned nothing.
</Paragraph>
)
)}
</RunPanelBody>
</RunPanel>
</div>
)}
</div>
{/* Detail view */}
<div className="overflow-y-auto py-4 pr-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<Header2 className="mb-2">Detail</Header2>
{selectedId ? (
<Outlet />
) : (
<Callout variant="info">Select a task or trigger</Callout>
)}
</div>
</div>
</PageBody>
</PageContainer>
);
}
function BlankTasks({
status,
basicStatus,
}: {
status: JobRunStatus;
basicStatus: RunBasicStatus;
}) {
switch (basicStatus) {
case "COMPLETED":
return (
<Paragraph variant="small">There were no tasks for this run.</Paragraph>
);
case "FAILED":
return <Paragraph variant="small">No tasks were run.</Paragraph>;
case "WAITING":
case "PENDING":
case "RUNNING":
return (
<div>
<Paragraph variant="small" className="mb-4">
Waiting for tasks
</Paragraph>
<TaskCardSkeleton />
</div>
);
default:
return (
<Paragraph variant="small">There were no tasks for this run.</Paragraph>
);
}
}
function RerunPopover({
environmentType,
status,
}: {
environmentType: RuntimeEnvironmentType;
status: RunBasicStatus;
}) {
return (
<Popover>
<PopoverTrigger asChild={true}>
<Button variant="primary/small" shortcut={{ key: "R" }}>
Rerun Job
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-80 flex-col gap-2 p-4" align="end">
<Form method="post">
{environmentType === "PRODUCTION" && (
<Callout variant="warning">
This will rerun this Job in your Production environment.
</Callout>
)}
<div className="flex flex-col items-start gap-4 divide-y divide-slate-600">
<div>
<Button
variant="primary/small"
type="submit"
name={conform.INTENT}
value="start"
fullWidth
LeadingIcon={BoltIcon}
>
Run again
</Button>
<Paragraph variant="extra-small" className="mt-2">
Start a brand new job run with the same Trigger data as this
one. This will re-do every task.
</Paragraph>
</div>
{status === "FAILED" && (
<div className="pt-4">
<Button
variant="primary/small"
type="submit"
name={conform.INTENT}
value="continue"
fullWidth
LeadingIcon={ForwardIcon}
>
Retry job run
</Button>
<Paragraph variant="extra-small" className="mt-2">
Continue running this job run from where it left off. This
will skip any task that has already been completed.
</Paragraph>
</div>
)}
</div>
</Form>
</PopoverContent>
</Popover>
);
}
@@ -12,6 +12,7 @@ type RunOptions = {
userId: string;
};
export type Run = NonNullable<Awaited<ReturnType<RunPresenter["call"]>>>;
export type Task = NonNullable<
Awaited<ReturnType<RunPresenter["call"]>>
>["tasks"][number];
@@ -1,48 +1,15 @@
import { conform } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { BoltIcon, ForwardIcon } from "@heroicons/react/24/solid";
import { Form, Outlet, useNavigate, useRevalidator } from "@remix-run/react";
import { useRevalidator } from "@remix-run/react";
import { ActionFunction, LoaderArgs, json } from "@remix-run/server-runtime";
import type { RuntimeEnvironmentType } from "@trigger.dev/internal";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils";
import { z } from "zod";
import { CodeBlock } from "~/components/code/CodeBlock";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import {
PageButtons,
PageHeader,
PageInfoGroup,
PageInfoProperty,
PageInfoRow,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import {
RunBasicStatus,
RunStatusIcon,
RunStatusLabel,
runBasicStatus,
runStatusTitle,
} from "~/components/runs/RunStatuses";
import { RunOverview } from "~/components/run/RunOverview";
import { runBasicStatus } from "~/components/runs/RunStatuses";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { useProject } from "~/hooks/useProject";
import { JobRunStatus } from "~/models/job.server";
import {
redirectBackWithErrorMessage,
redirectWithSuccessMessage,
@@ -51,31 +18,14 @@ import { RunPresenter } from "~/presenters/RunPresenter.server";
import { ContinueRunService } from "~/services/runs/continueRun.server";
import { ReRunService } from "~/services/runs/reRun.server";
import { requireUserId } from "~/services/session.server";
import { formatDuration } from "~/utils";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import {
RunParamsSchema,
jobPath,
runCompletedPath,
jobRunDashboardPath,
runPath,
runStreamingPath,
runTaskPath,
runTriggerPath,
} from "~/utils/pathBuilder";
import {
RunPanel,
RunPanelBody,
RunPanelDivider,
RunPanelError,
RunPanelHeader,
RunPanelIconProperty,
RunPanelIconSection,
RunPanelIconTitle,
RunPanelProperties,
} from "../../components/run/RunCard";
import { TaskCard } from "~/components/run/TaskCard";
import { TaskCardSkeleton } from "~/components/run/TaskCardSkeleton";
export const loader = async ({ request, params }: LoaderArgs) => {
const userId = await requireUserId(request);
@@ -156,34 +106,11 @@ export const handle: Handle = {
},
};
const taskPattern = /\/tasks\/(.*)/;
export default function Page() {
const { run } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const job = useJob();
const navigate = useNavigate();
const pathName = usePathName();
const selectedId = useMemo(() => {
if (pathName.endsWith("/completed")) {
return "completed";
}
if (pathName.endsWith("/trigger")) {
return "trigger";
}
const taskMatch = pathName.match(taskPattern);
const taskId = taskMatch ? taskMatch[1] : undefined;
if (taskId) {
return taskId;
}
}, [pathName]);
const basicStatus = runBasicStatus(run.status);
const revalidator = useRevalidator();
const events = useEventSource(
@@ -200,312 +127,14 @@ export default function Page() {
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle
backButton={{
to: jobPath(organization, project, job),
text: "Runs",
}}
title={`Run #${run.number}`}
/>
<PageButtons>
{run.isTest && (
<span className="flex items-center gap-1 text-xs uppercase text-slate-600">
<NamedIcon name="beaker" className="h-4 w-4 text-slate-600" />
Test run
</span>
)}
<RerunPopover
environmentType={run.environment.type}
status={basicStatus}
/>
</PageButtons>
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup>
<PageInfoProperty
icon={<RunStatusIcon status={run.status} className="h-4 w-4" />}
label={"Status"}
value={runStatusTitle(run.status)}
/>
<PageInfoProperty
icon={"calendar"}
label={"Started"}
value={
run.startedAt ? (
<DateTime date={run.startedAt} />
) : (
"Not started yet"
)
}
/>
<PageInfoProperty
icon={"property"}
label={"Version"}
value={`v${run.version}`}
/>
<PageInfoProperty
label={"Env"}
value={<EnvironmentLabel environment={run.environment} />}
/>
<PageInfoProperty
icon={"clock"}
label={"Duration"}
value={formatDuration(run.startedAt, run.completedAt)}
/>
</PageInfoGroup>
<PageInfoGroup alignment="right">
<Paragraph variant="extra-small" className="text-slate-600">
RUN ID: {run.id}
</Paragraph>
</PageInfoGroup>
</PageInfoRow>
</PageHeader>
<PageBody scrollable={false}>
<div className="grid h-full grid-cols-2 gap-2">
<div className="flex flex-col gap-6 overflow-y-auto py-4 pl-4 pr-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div>
<Header2 className="mb-2">Trigger</Header2>
<RunPanel
selected={selectedId === "trigger"}
onClick={() =>
navigate(runTriggerPath(organization, project, job, run))
}
>
<RunPanelHeader
icon={<BoltIcon className="h-5 w-5 text-orange-500" />}
title={
<RunPanelIconTitle
icon={job.event.icon}
title={job.event.title}
/>
}
/>
<RunPanelBody>
<RunPanelProperties
properties={[
{ label: "Event name", text: run.event.name },
...run.properties,
]}
/>
</RunPanelBody>
</RunPanel>
</div>
<div>
<Header2 className="mb-2">Tasks</Header2>
{run.tasks.length > 0 ? (
run.tasks.map((task, index) => {
const isLast = index === run.tasks.length - 1;
return (
<TaskCard
key={task.id}
selectedId={selectedId}
selectedTask={(taskId) => {
navigate(
runTaskPath(organization, project, job, run, taskId)
);
}}
isLast={isLast}
depth={0}
{...task}
/>
);
})
) : (
<BlankTasks status={run.status} basicStatus={basicStatus} />
)}
</div>
{(basicStatus === "COMPLETED" || basicStatus === "FAILED") && (
<div>
<Header2 className={cn("mb-2")}>Run Summary</Header2>
<RunPanel
selected={selectedId === "completed"}
onClick={() =>
navigate(runCompletedPath(organization, project, job, run))
}
>
<RunPanelHeader
icon={
<RunStatusIcon
status={run.status}
className={"h-5 w-5"}
/>
}
title={
<Paragraph variant="small/bright">
<RunStatusLabel status={run.status} />
</Paragraph>
}
/>
<RunPanelBody>
<RunPanelIconSection>
{run.startedAt && (
<RunPanelIconProperty
icon="calendar"
label="Started at"
value={<DateTime date={run.startedAt} />}
/>
)}
{run.completedAt && (
<RunPanelIconProperty
icon="flag"
label="Finished at"
value={<DateTime date={run.completedAt} />}
/>
)}
{run.startedAt && run.completedAt && (
<RunPanelIconProperty
icon="clock"
label="Total duration"
value={formatDuration(
run.startedAt,
run.completedAt,
{
style: "long",
}
)}
/>
)}
</RunPanelIconSection>
<RunPanelDivider />
{run.error && (
<RunPanelError
text={run.error.message}
stackTrace={run.error.stack}
/>
)}
{run.output ? (
<CodeBlock
language="json"
code={run.output}
maxLines={10}
/>
) : (
run.output === null && (
<Paragraph variant="small">
This Run returned nothing.
</Paragraph>
)
)}
</RunPanelBody>
</RunPanel>
</div>
)}
</div>
{/* Detail view */}
<div className="overflow-y-auto py-4 pr-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<Header2 className="mb-2">Detail</Header2>
{selectedId ? (
<Outlet />
) : (
<Callout variant="info">Select a task or trigger</Callout>
)}
</div>
</div>
</PageBody>
</PageContainer>
);
}
function BlankTasks({
status,
basicStatus,
}: {
status: JobRunStatus;
basicStatus: RunBasicStatus;
}) {
switch (basicStatus) {
case "COMPLETED":
return (
<Paragraph variant="small">There were no tasks for this run.</Paragraph>
);
case "FAILED":
return <Paragraph variant="small">No tasks were run.</Paragraph>;
case "WAITING":
case "PENDING":
case "RUNNING":
return (
<div>
<Paragraph variant="small" className="mb-4">
Waiting for tasks
</Paragraph>
<TaskCardSkeleton />
</div>
);
default:
return (
<Paragraph variant="small">There were no tasks for this run.</Paragraph>
);
}
}
function RerunPopover({
environmentType,
status,
}: {
environmentType: RuntimeEnvironmentType;
status: RunBasicStatus;
}) {
return (
<Popover>
<PopoverTrigger asChild={true}>
<Button variant="primary/small" shortcut={{ key: "R" }}>
Rerun Job
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-80 flex-col gap-2 p-4" align="end">
<Form method="post">
{environmentType === "PRODUCTION" && (
<Callout variant="warning">
This will rerun this Job in your Production environment.
</Callout>
)}
<div className="flex flex-col items-start gap-4 divide-y divide-slate-600">
<div>
<Button
variant="primary/small"
type="submit"
name={conform.INTENT}
value="start"
fullWidth
LeadingIcon={BoltIcon}
>
Run again
</Button>
<Paragraph variant="extra-small" className="mt-2">
Start a brand new job run with the same Trigger data as this
one. This will re-do every task.
</Paragraph>
</div>
{status === "FAILED" && (
<div className="pt-4">
<Button
variant="primary/small"
type="submit"
name={conform.INTENT}
value="continue"
fullWidth
LeadingIcon={ForwardIcon}
>
Retry job run
</Button>
<Paragraph variant="extra-small" className="mt-2">
Continue running this job run from where it left off. This
will skip any task that has already been completed.
</Paragraph>
</div>
)}
</div>
</Form>
</PopoverContent>
</Popover>
<RunOverview
run={run}
trigger={job.event}
showRerun={true}
paths={{
back: jobPath(organization, project, job),
run: runPath(organization, project, job, run),
}}
/>
);
}
+8 -24
View File
@@ -244,7 +244,7 @@ export function jobRunsParentPath(
return `${jobPath(organization, project, job)}/runs`;
}
function runPath(
export function runPath(
organization: OrgForPath,
project: ProjectForPath,
job: JobForPath,
@@ -259,7 +259,7 @@ export function jobRunDashboardPath(
job: JobForPath,
run: RunForPath
) {
return runTriggerPath(organization, project, job, run);
return runTriggerPath(runPath(organization, project, job, run));
}
export function runStreamingPath(
@@ -276,34 +276,18 @@ export function runParam(run: RunForPath) {
}
// Task
export function runTaskPath(
organization: OrgForPath,
project: ProjectForPath,
job: JobForPath,
run: RunForPath,
taskId: string
) {
return `${runPath(organization, project, job, run)}/tasks/${taskId}`;
export function runTaskPath(runPath: string, taskId: string) {
return `${runPath}/tasks/${taskId}`;
}
// Event
export function runTriggerPath(
organization: OrgForPath,
project: ProjectForPath,
job: JobForPath,
run: RunForPath
) {
return `${runPath(organization, project, job, run)}/trigger`;
export function runTriggerPath(runPath: string) {
return `${runPath}/trigger`;
}
// Event
export function runCompletedPath(
organization: OrgForPath,
project: ProjectForPath,
job: JobForPath,
run: RunForPath
) {
return `${runPath(organization, project, job, run)}/completed`;
export function runCompletedPath(runPath: string) {
return `${runPath}/completed`;
}
// Docs