Test, output and canceling improvements (#952)

* The vertical lines in the run tree view are now brighter

* Blank state when you click a task with no runs

* Canceling from the runs table

* Fix for styles coming through incorrectly to properties

* Fix for the OTEL output column having a $output prefix if it’s an object
This commit is contained in:
Matt Aitken
2024-03-18 17:27:52 +00:00
committed by GitHub
parent ebec14093d
commit 9304c447be
11 changed files with 179 additions and 58 deletions
@@ -253,7 +253,7 @@ export const TableCellMenu = forwardRef<
type TableBlankRowProps = {
className?: string;
colSpan: number;
children: ReactNode;
children?: ReactNode;
};
export const TableBlankRow = forwardRef<HTMLTableRowElement, TableBlankRowProps>(
@@ -0,0 +1,43 @@
import { StopCircleIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
type CancelRunDialogProps = {
runFriendlyId: string;
redirectPath: string;
};
export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialogProps) {
const cancelFetcher = useFetcher();
return (
<DialogContent>
<DialogHeader>Cancel this run?</DialogHeader>
<DialogDescription>
Canceling a run will stop execution. If you want to run this later you will have to replay
the entire run with the original payload.
</DialogDescription>
<DialogFooter>
<cancelFetcher.Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/small"
LeadingIcon={cancelFetcher.state === "idle" ? StopCircleIcon : "spinner-white"}
disabled={cancelFetcher.state !== "idle"}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{cancelFetcher.state === "idle" ? "Cancel run" : "Canceling..."}
</Button>
</cancelFetcher.Form>
</DialogFooter>
</DialogContent>
);
}
@@ -1,10 +1,10 @@
import { StopIcon } from "@heroicons/react/24/outline";
import { CheckIcon } from "@heroicons/react/24/solid";
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
import { User } from "@trigger.dev/database";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { RunListItem } from "~/presenters/v3/RunListPresenter.server";
import { v3RunPath } from "~/utils/pathBuilder";
import { RunListAppliedFilters, RunListItem } from "~/presenters/v3/RunListPresenter.server";
import { docsPath, v3RunPath, v3TestPath } from "~/utils/pathBuilder";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { DateTime } from "../../primitives/DateTime";
import { Paragraph } from "../../primitives/Paragraph";
@@ -15,16 +15,24 @@ import {
TableBody,
TableCell,
TableCellChevron,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "../../primitives/Table";
import { formatDuration } from "@trigger.dev/core/v3";
import { TaskRunStatusCombo } from "./TaskRunStatus";
import { useEnvironments } from "~/hooks/useEnvironments";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { StopCircleIcon } from "@heroicons/react/20/solid";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { CancelRunDialog } from "./CancelRunDialog";
import { useLocation } from "@remix-run/react";
type RunsTableProps = {
total: number;
hasFilters: boolean;
filters: RunListAppliedFilters;
showJob?: boolean;
runs: RunListItem[];
isLoading?: boolean;
@@ -34,12 +42,14 @@ type RunsTableProps = {
export function TaskRunsTable({
total,
hasFilters,
filters,
runs,
isLoading = false,
currentUser,
}: RunsTableProps) {
const organization = useOrganization();
const project = useProject();
const location = useLocation();
return (
<Table>
@@ -65,9 +75,7 @@ export function TaskRunsTable({
{!isLoading && <NoRuns title="No runs found" />}
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={9}>
{!isLoading && <NoRuns title="No runs match your filters" />}
</TableBlankRow>
<BlankState isLoading={isLoading} filters={filters} />
) : (
runs.map((run) => {
const path = v3RunPath(organization, project, run);
@@ -102,7 +110,23 @@ export function TaskRunsTable({
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : ""}
</TableCell>
<TableCellChevron to={path} isSticky />
{run.isCancellable ? (
<TableCellMenu isSticky>
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
</TableCellMenu>
) : (
<TableCell to={path}>{""}</TableCell>
)}
</TableRow>
);
})
@@ -127,3 +151,62 @@ function NoRuns({ title }: { title: string }) {
</div>
);
}
function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "filters">) {
const organization = useOrganization();
const project = useProject();
const envs = useEnvironments();
if (isLoading) return <TableBlankRow colSpan={9}></TableBlankRow>;
const { environments, tasks, from, to, ...otherFilters } = filters;
if (
filters.environments.length === 1 &&
filters.tasks.length === 1 &&
filters.from === undefined &&
filters.to === undefined &&
Object.values(otherFilters).every((filterArray) => filterArray.length === 0)
) {
const environment = envs?.find((env) => env.id === filters.environments[0]);
return (
<TableBlankRow colSpan={9}>
<div className="py-14">
<Paragraph className="w-auto" variant="base/bright" spacing>
There are no runs for {filters.tasks[0]}
{environment ? (
<>
{" "}
in <EnvironmentLabel environment={environment} size="large" />
</>
) : null}
</Paragraph>
<div className="flex items-center justify-center gap-2">
<LinkButton
to={v3TestPath(organization, project)}
variant="primary/small"
LeadingIcon={BeakerIcon}
className="inline-flex"
>
Create a test run
</LinkButton>
<Paragraph variant="small">or</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="primary/small"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Triggering a task docs
</LinkButton>
</div>
</div>
</TableBlankRow>
);
}
return (
<TableBlankRow colSpan={9}>
<NoRuns title="No runs match your filters" />
</TableBlankRow>
);
}
@@ -2,6 +2,7 @@ import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/databa
import { Direction } from "~/components/runs/RunStatuses";
import { PrismaClient, prisma } from "~/db.server";
import { getUsername } from "~/utils/username";
import { CANCELLABLE_STATUSES } from "~/v3/services/cancelTaskRun.server";
type RunListOptions = {
userId: string;
@@ -23,6 +24,7 @@ const DEFAULT_PAGE_SIZE = 20;
export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
export type RunListItem = RunList["runs"][0];
export type RunListAppliedFilters = RunList["filters"];
export class RunListPresenter {
#prismaClient: PrismaClient;
@@ -220,6 +222,7 @@ export class RunListPresenter {
version: run.version,
taskIdentifier: run.taskIdentifier,
attempts: Number(run.attempts),
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
environment: {
type: environment.type,
slug: environment.slug,
@@ -233,6 +236,14 @@ export class RunListPresenter {
previous,
},
possibleTasks: possibleTasks.map((task) => task.slug),
filters: {
tasks: tasks || [],
versions: versions || [],
statuses: statuses || [],
environments: environments || [],
from,
to,
},
hasFilters,
};
}
@@ -19,6 +19,7 @@ import {
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
@@ -54,7 +55,6 @@ export default function Page() {
const organization = useOrganization();
const project = useProject();
const { runParam } = useParams();
const cancelFetcher = useFetcher();
return (
<div
@@ -176,38 +176,15 @@ export default function Page() {
Cancel run
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Cancel this run?</DialogHeader>
<DialogDescription>
Canceling a run will stop execution. If you want to run this later you will have
to replay the entire run with the original payload.
</DialogDescription>
<DialogFooter>
<cancelFetcher.Form
action={`/resources/taskruns/${event.runId}/cancel`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={v3RunSpanPath(
organization,
project,
{ friendlyId: runParam },
{ spanId: event.spanId }
)}
variant="danger/small"
LeadingIcon={
cancelFetcher.state === "idle" ? StopCircleIcon : "spinner-white"
}
disabled={cancelFetcher.state !== "idle"}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{cancelFetcher.state === "idle" ? "Cancel run" : "Canceling..."}
</Button>
</cancelFetcher.Form>
</DialogFooter>
</DialogContent>
<CancelRunDialog
runFriendlyId={event.runId}
redirectPath={v3RunSpanPath(
organization,
project,
{ friendlyId: runParam },
{ spanId: event.spanId }
)}
/>
</Dialog>
)}
</div>
@@ -581,11 +581,7 @@ function NodeStatusIcon({ node }: { node: RunEvent }) {
}
function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boolean }) {
return (
<div
className={cn("h-8 w-2 border-r", isError ? "border-rose-500/10" : "border-charcoal-800")}
/>
);
return <div className={cn("h-8 w-2 border-r border-grid-bright")} />;
}
function ShowParentLink({ runFriendlyId }: { runFriendlyId: string }) {
@@ -84,6 +84,7 @@ export default function Page() {
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
currentUser={user}
+2 -6
View File
@@ -9,10 +9,10 @@ import {
SpanEvents,
TaskEventStyle,
correctErrorStackTrace,
flattenAndNormalizeAttributes,
flattenAttributes,
isExceptionSpanEvent,
omit,
primitiveValueOrflattenedAttributes,
unflattenAttributes,
} from "@trigger.dev/core/v3";
import { Prisma, TaskEvent, TaskEventStatus, type TaskEventKind } from "@trigger.dev/database";
@@ -178,10 +178,7 @@ export class EventRepository {
metadata: event.metadata as Attributes,
style: event.style as Attributes,
output: options?.attributes.output
? flattenAndNormalizeAttributes(
options.attributes.output,
SemanticInternalAttributes.OUTPUT
)
? primitiveValueOrflattenedAttributes(options.attributes.output, undefined)
: undefined,
});
}
@@ -564,7 +561,6 @@ export class EventRepository {
queueName: options.attributes.queueName,
batchId: options.attributes.batchId ?? undefined,
properties: {
...style,
...(flattenAttributes(metadata, SemanticInternalAttributes.METADATA) as Record<
string,
string
@@ -8,7 +8,7 @@ import { assertUnreachable } from "../utils/asserts.server";
import { CancelAttemptService } from "./cancelAttempt.server";
import { logger } from "~/services/logger.server";
const CANCELLABLE_STATUSES: Array<TaskRunStatus> = [
export const CANCELLABLE_STATUSES: Array<TaskRunStatus> = [
"PENDING",
"EXECUTING",
"PAUSED",
+1 -1
View File
@@ -42,7 +42,7 @@ export { ConsoleInterceptor } from "./consoleInterceptor";
export {
flattenAttributes,
unflattenAttributes,
flattenAndNormalizeAttributes,
primitiveValueOrflattenedAttributes,
} from "./utils/flattenAttributes";
export { defaultRetryOptions, calculateNextRetryDelay, calculateResetAt } from "./utils/retries";
export { accessoryAttributes } from "./utils/styleAttributes";
@@ -95,13 +95,27 @@ export function unflattenAttributes(obj: Attributes): Record<string, unknown> {
return result;
}
export function flattenAndNormalizeAttributes(
export function primitiveValueOrflattenedAttributes(
obj: Record<string, unknown> | Array<unknown> | string | boolean | number | undefined,
prefix: string
): Attributes {
prefix: string | undefined
): Attributes | string | number | boolean | undefined {
if (
typeof obj === "string" ||
typeof obj === "number" ||
typeof obj === "boolean" ||
obj === null ||
obj === undefined
) {
return obj;
}
const attributes = flattenAttributes(obj, prefix);
if (typeof attributes[prefix] !== "undefined" && attributes[prefix] !== null) {
if (
prefix !== undefined &&
typeof attributes[prefix] !== "undefined" &&
attributes[prefix] !== null
) {
return attributes[prefix] as unknown as Attributes;
}