Improved run page and replaying (#1240)

* PropertyTable component changed to use sub components

* Separate run span component

* Early WIP on tabs that use search query

* Shortcut key tabs for the span panel

* The span timeline is working

* When runs get expired, update the OTEL event with an error

* Improved the expired error message

* Reveal env vars when editing

* Tightened things up a bit

* Added detail tab properties

* Progress dashed line

* Move the env label next to the Run number title

* Top level cancel/replay buttons

* Replay with a different payload and environment

* Fix for non json payloads

* Hide the clear/copy buttons

* UI improvements with large payloads

* Close the panels when you replay/cancel

* Added the new timeline to spans

* Use the u-turn left icon for replay

* Added an index for spanId on TaskRun

* Remove replay/cancel buttons the span view

* Replay shortcut works inside the code editor

* Split the log/span inspector between Overview and Detail as well

* More improvements to the inspector

* Context and output improvements

* Focus on run working

* Added version to run.ctx

* Added some padding to the detail view

* Added context tab with shortcut

* Only load the replay data when the dialog is open

* Replaying uses the tags from the original run

* Links are now text links

* Removed version links for now because we don’t have dropdown filters for them yet

* Tabs are now outside of the scrollview

* The inspector is now 30% of the width by default

* Allow replaying and editing SuperJSON payloads

* Deleted unused CodeGroup file

* Increase the tags limit to 5, do the limiting on the server

* The admin tooltip now always shows basic org, project and user info

* Fix for schedule inspector disabled state layout

* Remove new unused span metadata and context

* Removed unused import
This commit is contained in:
Matt Aitken
2024-07-31 15:11:50 +01:00
committed by GitHub
parent c1d4c04e89
commit 09413a62a4
37 changed files with 1864 additions and 695 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Added version to ctx.run
@@ -1,3 +1,4 @@
import * as Property from "~/components/primitives/PropertyTable";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import {
Tooltip,
@@ -5,8 +6,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useIsImpersonating } from "~/hooks/useOrganizations";
import { useHasAdminAccess } from "~/hooks/useUser";
import {
useIsImpersonating,
useOptionalOrganization,
useOrganization,
} from "~/hooks/useOrganizations";
import { useOptionalProject, useProject } from "~/hooks/useProject";
import { useHasAdminAccess, useUser } from "~/hooks/useUser";
export function AdminDebugTooltip({ children }: { children: React.ReactNode }) {
const hasAdminAccess = useHasAdminAccess();
@@ -22,10 +28,46 @@ export function AdminDebugTooltip({ children }: { children: React.ReactNode }) {
<TooltipTrigger>
<ShieldCheckIcon className="size-5" />
</TooltipTrigger>
<TooltipContent className="flex max-h-[90vh] items-center gap-1 overflow-y-auto">
{children}
<TooltipContent className="max-h-[90vh] overflow-y-auto">
<Content>{children}</Content>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function Content({ children }: { children: React.ReactNode }) {
const organization = useOptionalOrganization();
const project = useOptionalProject();
const user = useUser();
return (
<div className="flex flex-col gap-2 divide-y divide-slate-700">
<Property.Table>
<Property.Item>
<Property.Label>User ID</Property.Label>
<Property.Value>{user.id}</Property.Value>
</Property.Item>
{organization && (
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{organization.id}</Property.Value>
</Property.Item>
)}
{project && (
<>
<Property.Item>
<Property.Label>Project ID</Property.Label>
<Property.Value>{project.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Project ref</Property.Label>
<Property.Value>{project.ref}</Property.Value>
</Property.Item>
</>
)}
</Property.Table>
<div className="pt-2">{children}</div>
</div>
);
}
@@ -241,16 +241,12 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"absolute right-3 z-50 transition-colors duration-100 hover:cursor-pointer",
showChrome ? "top-10" : "top-3",
"absolute right-3 z-50 transition-colors duration-100 hover:cursor-pointer",
showChrome ? "top-10" : "top-2.5",
copied ? "text-emerald-500" : "text-charcoal-500 hover:text-charcoal-300"
)}
>
{copied ? (
<ClipboardCheck className="h-5 w-5" />
) : (
<Clipboard className="h-5 w-5" />
)}
{copied ? <ClipboardCheck className="size-4" /> : <Clipboard className="size-4" />}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{copied ? "Copied" : "Copy"}
@@ -391,8 +387,8 @@ function Chrome({ title }: { title?: string }) {
export function TitleRow({ title }: { title: string }) {
return (
<div className="flex items-center justify-between px-4">
<Paragraph variant="base/bright" className="w-full border-b border-grid-dimmed py-2.5">
<div className="flex items-center justify-between px-3">
<Paragraph variant="small/bright" className="w-full border-b border-grid-dimmed py-2">
{title}
</Paragraph>
</div>
+44 -32
View File
@@ -107,39 +107,51 @@ export function JSONEditor(opts: JSONEditorProps) {
}, 1500);
}, [view]);
const showButtons = showClearButton || showCopyButton;
return (
<div className={cn(opts.className, "grid grid-rows-[2.5rem_1fr]")}>
<div className="mx-3 flex items-center justify-end gap-2 border-b border-grid-dimmed">
{showClearButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={TrashIcon}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clear();
}}
>
Clear
</Button>
)}
{showCopyButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
trailingIconClassName={copied ? "text-green-500 group-hover:text-green-500" : undefined}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
copy();
}}
>
Copy
</Button>
)}
</div>
<div
className={cn(
opts.className,
"grid",
showButtons ? "grid-rows-[2.5rem_1fr]" : "grid-rows-[1fr]"
)}
>
{showButtons && (
<div className="mx-3 flex items-center justify-end gap-2 border-b border-grid-dimmed">
{showClearButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={TrashIcon}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clear();
}}
>
Clear
</Button>
)}
{showCopyButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
trailingIconClassName={
copied ? "text-green-500 group-hover:text-green-500" : undefined
}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
copy();
}}
>
Copy
</Button>
)}
</div>
)}
<div
className="w-full overflow-auto"
ref={editor}
@@ -10,6 +10,7 @@ import { LoadingBarDivider } from "./LoadingBarDivider";
import { NamedIcon } from "./NamedIcon";
import { Paragraph } from "./Paragraph";
import { Tabs, TabsProps } from "./Tabs";
import { ReactNode } from "react";
type WithChildren = {
children: React.ReactNode;
@@ -34,7 +35,7 @@ export function NavBar({ children }: WithChildren) {
}
type PageTitleProps = {
title: string;
title: ReactNode;
backButton?: {
to: string;
text: string;
@@ -1,40 +1,31 @@
import { ReactNode } from "react";
import { type ReactNode } from "react";
import { Paragraph } from "./Paragraph";
import { cn } from "~/utils/cn";
export function PropertyTable({
children,
className,
}: {
type ChildrenClassName = {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("grid grid-cols-[auto,1fr] items-center gap-x-4 gap-y-2", className)}>
{children}
</div>
);
}
export type PropertyProps = {
label: ReactNode;
labelClassName?: string;
children: ReactNode;
};
export function Property({ label, labelClassName, children }: PropertyProps) {
return (
<>
<div className={labelClassName}>
{typeof label === "string" ? <Paragraph variant="small">{label}</Paragraph> : label}
</div>
<div>
{typeof children === "string" ? (
<Paragraph variant="small/bright">{children}</Paragraph>
) : (
children
)}
</div>
</>
);
function PropertyTable({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("flex flex-col gap-y-3", className)}>{children}</div>;
}
function PropertyItem({ children, className }: ChildrenClassName) {
return <div className={cn("flex flex-col gap-0 text-sm", className)}>{children}</div>;
}
function PropertyLabel({ children, className }: ChildrenClassName) {
return <div className={cn("font-medium text-text-bright", className)}>{children}</div>;
}
function PropertyValue({ children, className }: ChildrenClassName) {
return <div className={cn("text-text-dimmed", className)}>{children}</div>;
}
export {
PropertyTable as Table,
PropertyItem as Item,
PropertyLabel as Label,
PropertyValue as Value,
};
+104 -21
View File
@@ -1,6 +1,11 @@
import { NavLink, useLocation } from "@remix-run/react";
import { Link, NavLink, useLocation } from "@remix-run/react";
import { motion } from "framer-motion";
import { ReactNode, useRef } from "react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { projectPubSub } from "~/v3/services/projectPubSub.server";
import { ShortcutKey } from "./ShortcutKey";
export type TabsProps = {
tabs: {
@@ -13,28 +18,106 @@ export type TabsProps = {
export function Tabs({ tabs, className, layoutId }: TabsProps) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
<TabContainer className={className}>
{tabs.map((tab, index) => (
<NavLink key={index} to={tab.to} className="group flex flex-col items-center pt-1" end>
{({ isActive, isPending }) => (
<>
<span
className={cn(
"text-sm transition duration-200",
isActive || isPending ? "text-indigo-500" : "text-charcoal-200"
)}
>
{tab.label}
</span>
{isActive || isPending ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</>
)}
</NavLink>
<TabLink key={index} to={tab.to} layoutId={layoutId}>
{tab.label}
</TabLink>
))}
</TabContainer>
);
}
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
{children}
</div>
);
}
export function TabLink({
to,
children,
layoutId,
}: {
to: string;
children: ReactNode;
layoutId: string;
}) {
return (
<NavLink to={to} className="group flex flex-col items-center pt-1" end>
{({ isActive, isPending }) => {
return (
<>
<span
className={cn(
"text-sm transition duration-200",
isActive || isPending ? "text-text-bright" : "text-text-bright"
)}
>
{children}
</span>
{isActive || isPending ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</>
);
}}
</NavLink>
);
}
export function TabButton({
isActive,
layoutId,
shortcut,
...props
}: {
isActive: boolean;
shortcut?: ShortcutDefinition;
layoutId: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const ref = useRef<HTMLButtonElement>(null);
if (shortcut) {
useShortcutKeys({
shortcut: shortcut,
action: () => {
if (ref.current) {
ref.current.click();
}
},
disabled: props.disabled,
});
}
return (
<button
className={cn("group flex flex-col items-center pt-1", props.className)}
ref={ref}
{...props}
>
<>
<div className="flex items-center gap-1">
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-bright"
)}
>
{props.children}
</span>
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
</div>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</>
</button>
);
}
@@ -23,11 +23,11 @@ export function LiveTimer({
}, updateInterval);
return () => clearInterval(interval);
}, [startTime]);
}, [startTime, endTime]);
return (
<>
{formatDuration(startTime, now, {
{formatDuration(startTime, endTime ?? now, {
style: "short",
maxDecimalPoints: 0,
units: ["d", "h", "m", "s"],
@@ -1,12 +1,16 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher, useNavigation } from "@remix-run/react";
import { Form, useFetcher, useNavigation, useSubmit } from "@remix-run/react";
import { useCallback, useEffect, useRef } from "react";
import { UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson";
import { JSONEditor } from "~/components/code/JSONEditor";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
import { DialogContent, DialogDescription, DialogHeader } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Select, SelectItem } from "~/components/primitives/Select";
import { ButtonSpinner, Spinner } from "~/components/primitives/Spinner";
import { type loader } from "~/routes/resources.taskruns.$runParam.replay";
type ReplayRunDialogProps = {
runFriendlyId: string;
@@ -14,31 +18,141 @@ type ReplayRunDialogProps = {
};
export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/taskruns/${runFriendlyId}/replay`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="replay">
<DialogHeader>Replay this run?</DialogHeader>
<DialogDescription>
Replaying a run will create a new run with the same payload and environment as the original.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post">
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<Button
type="submit"
variant="primary/small"
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Replaying..." : "Replay run"}
</Button>
</Form>
</DialogFooter>
<DialogContent key={`replay`} className="md:max-w-3xl">
<ReplayContent runFriendlyId={runFriendlyId} failedRedirect={failedRedirect} />
</DialogContent>
);
}
function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
const fetcher = useTypedFetcher<typeof loader>();
const isLoading = fetcher.state !== "idle";
useEffect(() => {
fetcher.load(`/resources/taskruns/${runFriendlyId}/replay`);
}, [runFriendlyId]);
return (
<>
<DialogHeader>Replay this run</DialogHeader>
{isLoading ? (
<div className="grid place-items-center p-6">
<Spinner />
</div>
) : fetcher.data ? (
<ReplayForm
{...fetcher.data}
failedRedirect={failedRedirect}
runFriendlyId={runFriendlyId}
/>
) : (
<>Failed to get run data</>
)}
</>
);
}
function ReplayForm({
payload,
payloadType,
environment,
environments,
failedRedirect,
runFriendlyId,
}: UseDataFunctionReturn<typeof loader> & { failedRedirect: string; runFriendlyId: string }) {
const navigation = useNavigation();
const submit = useSubmit();
const currentJson = useRef<string>(payload);
const formAction = `/resources/taskruns/${runFriendlyId}/replay`;
const isSubmitting = navigation.formAction === formAction;
const editablePayload =
payloadType === "application/json" || payloadType === "application/super+json";
const submitForm = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
const formData = new FormData(e.currentTarget);
const data: Record<string, string> = {
environment: formData.get("environment") as string,
failedRedirect: formData.get("failedRedirect") as string,
};
if (editablePayload) {
data.payload = currentJson.current;
}
submit(data, {
action: formAction,
method: "post",
});
e.preventDefault();
},
[currentJson]
);
return (
<Form action={formAction} method="post" onSubmit={(e) => submitForm(e)} className="pt-2">
{editablePayload ? (
<>
<Header3 spacing>Payload</Header3>
<div className="mb-3 max-h-[70vh] overflow-y-auto rounded-sm border border-grid-dimmed bg-charcoal-900 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<JSONEditor
defaultValue={currentJson.current}
readOnly={false}
basicSetup
onChange={(v) => {
currentJson.current = v;
}}
showClearButton={false}
showCopyButton={false}
height="100%"
min-height="100%"
max-height="100%"
/>
</div>
</>
) : null}
<InputGroup>
<Label>Environment</Label>
<Select
id="environment"
name="environment"
placeholder="Select an environment"
defaultValue={environment.id}
items={environments}
dropdownIcon
variant="tertiary/medium"
className="w-fit pl-2"
text={(value) => {
const env = environments.find((env) => env.id === value)!;
return (
<div className="flex items-center pr-2">
<EnvironmentLabel environment={env} userName={env.userName} />
</div>
);
}}
>
{(matches) =>
matches.map((env) => (
<SelectItem key={env.id} value={env.id}>
<EnvironmentLabel environment={env} userName={env.userName} />
</SelectItem>
))
}
</Select>
</InputGroup>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<Button
type="submit"
variant="primary/medium"
LeadingIcon={isSubmitting ? ButtonSpinner : undefined}
disabled={isSubmitting}
shortcut={{ modifiers: ["meta"], key: "enter", enabledOnInputElements: true }}
className="mt-5"
>
{isSubmitting ? "Replaying..." : "Replay run"}
</Button>
</Form>
);
}
@@ -6,7 +6,7 @@ import {
import { CodeBlock } from "~/components/code/CodeBlock";
import { Callout } from "~/components/primitives/Callout";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
type SpanEventsProps = {
@@ -34,8 +34,8 @@ function SpanEventHeader({
}) {
return (
<div className="flex items-center justify-between">
<Header2 className={titleClassName}>{title}</Header2>
<Paragraph variant="small">
<Header3 className={titleClassName}>{title}</Header3>
<Paragraph variant="extra-small">
<DateTimeAccurate date={time} />
</Paragraph>
</div>
@@ -57,7 +57,7 @@ function SpanEvent({ spanEvent }: { spanEvent: OtelSpanEvent }) {
);
}
function SpanEventError({
export function SpanEventError({
spanEvent,
exception,
}: {
@@ -65,7 +65,7 @@ function SpanEventError({
exception: ExceptionEventProperties;
}) {
return (
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 p-3">
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
<SpanEventHeader
title={exception.type ?? "Error"}
time={spanEvent.time}
@@ -1,6 +1,8 @@
import { prisma } from "~/db.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
export const MAX_TAGS_PER_RUN = 5;
export async function createTag({ tag, projectId }: { tag: string; projectId: string }) {
if (tag.trim().length === 0) return;
return prisma.taskRunTag.upsert({
@@ -1,5 +1,6 @@
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { PrismaClient, prisma } from "~/db.server";
import { getUsername } from "~/utils/username";
import { eventRepository } from "~/v3/eventRepository.server";
@@ -33,6 +34,7 @@ export class RunPresenter {
traceId: true,
spanId: true,
friendlyId: true,
status: true,
runtimeEnvironment: {
select: {
id: true,
@@ -71,6 +73,9 @@ export class RunPresenter {
number: run.number,
friendlyId: run.friendlyId,
traceId: run.traceId,
spanId: run.spanId,
status: run.status,
isFinished: FINISHED_STATUSES.includes(run.status),
environment: {
id: run.runtimeEnvironment.id,
organizationId: run.runtimeEnvironment.organizationId,
@@ -127,6 +132,9 @@ export class RunPresenter {
number: run.number,
friendlyId: run.friendlyId,
traceId: run.traceId,
spanId: run.spanId,
status: run.status,
isFinished: FINISHED_STATUSES.includes(run.status),
environment: {
id: run.runtimeEnvironment.id,
organizationId: run.runtimeEnvironment.organizationId,
@@ -1,10 +1,13 @@
import { prettyPrintPacket } from "@trigger.dev/core/v3";
import { PrismaClient, prisma } from "~/db.server";
import { Context, MachinePresetName, prettyPrintPacket } from "@trigger.dev/core/v3";
import { FINISHED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { eventRepository } from "~/v3/eventRepository.server";
import { BasePresenter } from "./basePresenter.server";
import { machineDefinition } from "@trigger.dev/platform/v3";
import { machinePresetFromName } from "~/v3/machinePresets.server";
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
export type Span = NonNullable<Result>["event"];
export type Span = NonNullable<NonNullable<Result>["span"]>;
export type SpanRun = NonNullable<NonNullable<Result>["run"]>;
export class SpanPresenter extends BasePresenter {
public async call({
@@ -30,6 +33,223 @@ export class SpanPresenter extends BasePresenter {
throw new Error("Project not found");
}
const run = await this.getRun(spanId);
if (run) {
return {
type: "run" as const,
run,
};
}
//get the run
const span = await this.getSpan(runFriendlyId, spanId);
if (!span) {
throw new Error("Span not found");
}
return {
type: "span" as const,
span,
};
}
async getRun(spanId: string) {
const run = await this._replica.taskRun.findFirst({
select: {
traceId: true,
//metadata
number: true,
taskIdentifier: true,
friendlyId: true,
isTest: true,
tags: {
select: {
name: true,
},
},
machinePreset: true,
lockedToVersion: {
select: {
version: true,
sdkVersion: true,
},
},
//status + duration
status: true,
startedAt: true,
createdAt: true,
updatedAt: true,
queuedAt: true,
//idempotency
idempotencyKey: true,
//delayed
delayUntil: true,
//ttl
ttl: true,
expiredAt: true,
//queue
queue: true,
concurrencyKey: true,
//schedule
schedule: {
select: {
friendlyId: true,
generatorExpression: true,
timezone: true,
generatorDescription: true,
},
},
//usage
baseCostInCents: true,
costInCents: true,
usageDurationMs: true,
//env
runtimeEnvironment: {
select: { id: true, slug: true, type: true },
},
payload: true,
payloadType: true,
maxAttempts: true,
//finished attempt
attempts: {
select: {
output: true,
outputType: true,
error: true,
},
where: {
status: "COMPLETED",
},
},
project: {
include: {
organization: true,
},
},
lockedBy: {
select: {
filePath: true,
exportName: true,
},
},
},
where: {
spanId,
},
});
if (!run) {
return;
}
const finishedAttempt = run.attempts.at(0);
const output =
finishedAttempt === undefined
? undefined
: finishedAttempt.outputType === "application/store"
? `/resources/packets/${run.runtimeEnvironment.id}/${finishedAttempt.output}`
: typeof finishedAttempt.output !== "undefined" && finishedAttempt.output !== null
? await prettyPrintPacket(finishedAttempt.output, finishedAttempt.outputType ?? undefined)
: undefined;
const payload =
run.payloadType === "application/store"
? `/resources/packets/${run.runtimeEnvironment.id}/${run.payload}`
: typeof run.payload !== "undefined" && run.payload !== null
? await prettyPrintPacket(run.payload, run.payloadType ?? undefined)
: undefined;
const span = await eventRepository.getSpan(spanId, run.traceId);
const context = {
task: {
id: run.taskIdentifier,
filePath: run.lockedBy?.filePath,
exportName: run.lockedBy?.exportName,
},
run: {
id: run.friendlyId,
createdAt: run.createdAt,
tags: run.tags.map((tag) => tag.name),
isTest: run.isTest,
idempotencyKey: run.idempotencyKey ?? undefined,
startedAt: run.startedAt ?? run.createdAt,
durationMs: run.usageDurationMs,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
maxAttempts: run.maxAttempts ?? undefined,
version: run.lockedToVersion?.version,
},
queue: {
name: run.queue,
},
environment: {
id: run.runtimeEnvironment.id,
slug: run.runtimeEnvironment.slug,
type: run.runtimeEnvironment.type,
},
organization: {
id: run.project.organization.id,
slug: run.project.organization.slug,
name: run.project.organization.title,
},
project: {
id: run.project.id,
ref: run.project.externalRef,
slug: run.project.slug,
name: run.project.name,
},
machine: run.machinePreset
? machinePresetFromName(run.machinePreset as MachinePresetName)
: undefined,
};
return {
friendlyId: run.friendlyId,
status: run.status,
createdAt: run.createdAt,
startedAt: run.startedAt,
updatedAt: run.updatedAt,
delayUntil: run.delayUntil,
expiredAt: run.expiredAt,
ttl: run.ttl,
taskIdentifier: run.taskIdentifier,
version: run.lockedToVersion?.version,
sdkVersion: run.lockedToVersion?.sdkVersion,
isTest: run.isTest,
environmentId: run.runtimeEnvironment.id,
schedule: run.schedule
? {
friendlyId: run.schedule.friendlyId,
generatorExpression: run.schedule.generatorExpression,
description: run.schedule.generatorDescription,
timezone: run.schedule.timezone,
}
: undefined,
queue: {
name: run.queue,
isCustomQueue: !run.queue.startsWith("task/"),
concurrencyKey: run.concurrencyKey,
},
tags: run.tags.map((tag) => tag.name),
baseCostInCents: run.baseCostInCents,
costInCents: run.costInCents,
totalCostInCents: run.costInCents + run.baseCostInCents,
usageDurationMs: run.usageDurationMs,
isFinished: FINISHED_STATUSES.includes(run.status),
isRunning: RUNNING_STATUSES.includes(run.status),
payload,
payloadType: run.payloadType,
output,
outputType: finishedAttempt?.outputType ?? "application/json",
links: span?.links,
events: span?.events,
context: JSON.stringify(context, null, 2),
};
}
async getSpan(runFriendlyId: string, spanId: string) {
const run = await this._prisma.taskRun.findFirst({
select: {
traceId: true,
@@ -49,31 +269,11 @@ export class SpanPresenter extends BasePresenter {
return;
}
const output =
span.outputType === "application/store"
? `/resources/packets/${span.environmentId}/${span.output}`
: typeof span.output !== "undefined"
? await prettyPrintPacket(span.output, span.outputType ?? undefined)
: undefined;
const payload =
span.payloadType === "application/store"
? `/resources/packets/${span.environmentId}/${span.payload}`
: typeof span.payload !== "undefined" && span.payload !== null
? await prettyPrintPacket(span.payload, span.payloadType ?? undefined)
: undefined;
return {
event: {
...span,
events: span.events,
output,
outputType: span.outputType ?? "application/json",
payload,
payloadType: span.payloadType ?? "application/json",
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
showActionBar: span.show?.actions === true,
},
...span,
events: span.events,
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
showActionBar: span.show?.actions === true,
};
}
}
@@ -20,7 +20,7 @@ import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { StepNumber } from "~/components/primitives/StepNumber";
import {
@@ -129,21 +129,20 @@ export default function Page() {
<PageTitle title="Tasks" />
<PageAccessories>
<AdminDebugTooltip>
<PropertyTable>
<Property.Table>
{tasks.map((task) => (
<Property label={task.exportName} key={task.slug}>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">
{task.environments
.map((e) =>
e.userName ? `${e.userName}/${e.id}` : `${e.type.slice(0, 3)}/${e.id}`
)
.join(", ")}
</Paragraph>
</div>
</Property>
<Property.Item key={task.slug}>
<Property.Label>{task.exportName}</Property.Label>
<Property.Value>
{task.environments
.map((e) =>
e.userName ? `${e.userName}/${e.id}` : `${e.type.slice(0, 3)}/${e.id}`
)
.join(", ")}
</Property.Value>
</Property.Item>
))}
</PropertyTable>
</Property.Table>
</AdminDebugTooltip>
</PageAccessories>
</NavBar>
@@ -11,7 +11,7 @@ import { DateTime } from "~/components/primitives/DateTime";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBody,
@@ -61,15 +61,14 @@ export default function Page() {
<PageTitle title="API keys" />
<PageAccessories>
<AdminDebugTooltip>
<PropertyTable>
<Property.Table>
{environments.map((environment) => (
<Property label={environment.slug} key={environment.id}>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{environment.id}</Paragraph>
</div>
</Property>
<Property.Item key={environment.id}>
<Property.Label>{environment.slug}</Property.Label>
<Property.Value>{environment.id}</Property.Value>
</Property.Item>
))}
</PropertyTable>
</Property.Table>
</AdminDebugTooltip>
<LinkButton
@@ -11,7 +11,7 @@ import { LinkButton } from "~/components/primitives/Buttons";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBody,
@@ -71,44 +71,39 @@ export default function Page() {
<Header2 className={cn("whitespace-nowrap")}>Deploy: {deployment.shortCode}</Header2>
<AdminDebugTooltip>
<PropertyTable>
<Property label="ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{deployment.id}</Paragraph>
</div>
</Property>
<Property label="Project ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{deployment.projectId}</Paragraph>
</div>
</Property>
<Property label="Org ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{deployment.organizationId}</Paragraph>
</div>
</Property>
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>{deployment.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Project ID</Property.Label>
<Property.Value>{deployment.projectId}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{deployment.organizationId}</Property.Value>
</Property.Item>
{deployment.imageReference && (
<Property label="Image">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">
{deployment.imageReference}
</Paragraph>
</div>
</Property>
<Property.Item>
<Property.Label>Image</Property.Label>
<Property.Value>{deployment.imageReference}</Property.Value>
</Property.Item>
)}
{deployment.externalBuildData && (
<Property label="Build Server">
<div className="flex items-center gap-2">
<Property.Item>
<Property.Label>Build Server</Property.Label>
<Property.Value>
<Link
to={`/resources/${deployment.projectId}/deployments/${deployment.id}/logs`}
className="extra-small/bright/mono underline"
>
{deployment.externalBuildData.buildId}
</Link>
</div>
</Property>
</Property.Value>
</Property.Item>
)}
</PropertyTable>
</Property.Table>
</AdminDebugTooltip>
<LinkButton
@@ -120,35 +115,51 @@ export default function Page() {
</div>
<div className="overflow-y-auto px-3 pt-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="flex flex-col gap-4">
<PropertyTable>
<Property label="Deploy">
<div className="flex items-center gap-2">
<Paragraph variant="small/bright">{deployment.shortCode}</Paragraph>
<Property.Table>
<Property.Item>
<Property.Label>Deploy</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{deployment.shortCode}</span>
{deployment.label && <Badge variant="outline-rounded">{deployment.label}</Badge>}
</div>
</Property>
<Property label="Environment">
<EnvironmentLabel environment={deployment.environment} userName={usernameForEnv} />
</Property>
<Property label="Version">{deployment.version}</Property>
<Property label="Status">
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
className="text-sm"
/>
</Property>
<Property label="Tasks">{deployment.tasks ? deployment.tasks.length : ""}</Property>
<Property label="SDK Version">
{deployment.sdkVersion ? deployment.sdkVersion : ""}
</Property>
<Property label="Started at">
<Paragraph variant="small/bright">
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentLabel environment={deployment.environment} userName={usernameForEnv} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>{deployment.version}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
className="text-sm"
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Tasks</Property.Label>
<Property.Value>{deployment.tasks ? deployment.tasks.length : ""}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>SDK Version</Property.Label>
<Property.Value>{deployment.sdkVersion ? deployment.sdkVersion : ""}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Started at</Property.Label>
<Property.Value>
<DateTimeAccurate date={deployment.createdAt} /> UTC
</Paragraph>
</Property>
<Property label="Built at">
<Paragraph variant="small/bright">
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Built at</Property.Label>
<Property.Value>
{deployment.builtAt ? (
<>
<DateTimeAccurate date={deployment.builtAt} /> UTC
@@ -156,10 +167,11 @@ export default function Page() {
) : (
""
)}
</Paragraph>
</Property>
<Property label="Deployed at">
<Paragraph variant="small/bright">
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Deployed at</Property.Label>
<Property.Value>
{deployment.deployedAt ? (
<>
<DateTimeAccurate date={deployment.deployedAt} /> UTC
@@ -167,25 +179,28 @@ export default function Page() {
) : (
""
)}
</Paragraph>
</Property>
<Property label="Deployed by">
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName}
className="h-4 w-4"
/>
<Paragraph variant="small">
{deployment.deployedBy.name ?? deployment.deployedBy.displayName}
</Paragraph>
</div>
) : (
""
)}
</Property>
</PropertyTable>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Deployed by</Property.Label>
<Property.Value>
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName}
className="h-4 w-4"
/>
<Paragraph variant="small">
{deployment.deployedBy.name ?? deployment.deployedBy.displayName}
</Paragraph>
</div>
) : (
""
)}
</Property.Value>
</Property.Item>
</Property.Table>
{deployment.tasks ? (
<div className="divide-y divide-charcoal-800 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
@@ -248,6 +248,7 @@ export default function Page() {
<EditEnvironmentVariablePanel
environments={environments}
variable={variable}
revealAll={revealAll}
/>
<DeleteEnvironmentVariableButton variable={variable} />
</TableCellMenu>
@@ -293,15 +294,21 @@ export default function Page() {
function EditEnvironmentVariablePanel({
variable,
environments,
revealAll,
}: {
variable: EnvironmentVariableWithSetValues;
environments: Pick<RuntimeEnvironment, "id" | "type">[];
revealAll: boolean;
}) {
const [reveal, setReveal] = useState(revealAll);
const [isOpen, setIsOpen] = useState(false);
const lastSubmission = useActionData();
const navigation = useNavigation();
const hiddenValues = Object.values(variable.values).filter((value) => !environments.map(e => e.id).includes(value.environment.id));
const hiddenValues = Object.values(variable.values).filter(
(value) => !environments.map((e) => e.id).includes(value.environment.id)
);
const isLoading =
navigation.state !== "idle" &&
@@ -340,7 +347,11 @@ function EditEnvironmentVariablePanel({
<input type="hidden" name="key" value={variable.key} />
{hiddenValues.map((value, index) => (
<Fragment key={index}>
<input type="hidden" name={`values[${index}].environmentId`} value={value.environment.id} />
<input
type="hidden"
name={`values[${index}].environmentId`}
value={value.environment.id}
/>
<input type="hidden" name={`values[${index}].value`} value={value.value} />
</Fragment>
))}
@@ -355,7 +366,15 @@ function EditEnvironmentVariablePanel({
</Fieldset>
<Fieldset>
<InputGroup fullWidth>
<Label>Values</Label>
<div className="flex justify-between gap-1">
<Label>Values</Label>
<Switch
variant="small"
label="Reveal"
checked={reveal}
onCheckedChange={(e) => setReveal(e.valueOf())}
/>
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2">
{environments.map((environment, index) => {
const value = variable.values[environment.id]?.value;
@@ -377,7 +396,7 @@ function EditEnvironmentVariablePanel({
name={`values[${index}].value`}
placeholder="Not set"
defaultValue={value}
type="password"
type={reveal ? "text" : "password"}
/>
</Fragment>
);
@@ -1,9 +1,12 @@
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
BoltSlashIcon,
ChevronDownIcon,
ChevronRightIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
import type { Location } from "@remix-run/react";
import { useLoaderData, useParams, useRevalidator } from "@remix-run/react";
@@ -26,14 +29,14 @@ import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
@@ -71,6 +74,9 @@ import {
import { SpanView } from "../resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { env } from "~/env.server";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
@@ -122,41 +128,38 @@ export default function Page() {
to: v3RunsPath(organization, project),
text: "Runs",
}}
title={`Run #${run.number}`}
title={
<div className="flex items-center gap-3">
<span>Run #{run.number}</span>
<EnvironmentLabel
size="large"
environment={run.environment}
userName={usernameForEnv}
/>
</div>
}
/>
<PageAccessories>
<AdminDebugTooltip>
<PropertyTable>
<Property label="ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{run.id}</Paragraph>
</div>
</Property>
<Property label="Trace ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{run.traceId}</Paragraph>
</div>
</Property>
<Property label="Env ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{run.environment.id}</Paragraph>
</div>
</Property>
<Property label="Org ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">
{run.environment.organizationId}
</Paragraph>
</div>
</Property>
</PropertyTable>
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>{run.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Trace ID</Property.Label>
<Property.Value>{run.traceId}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env ID</Property.Label>
<Property.Value>{run.environment.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{run.environment.organizationId}</Property.Value>
</Property.Item>
</Property.Table>
</AdminDebugTooltip>
<EnvironmentLabel
size="large"
environment={run.environment}
userName={usernameForEnv}
/>
</PageAccessories>
</NavBar>
<PageBody>
@@ -200,37 +203,76 @@ export default function Page() {
to: v3RunsPath(organization, project),
text: "Runs",
}}
title={`Run #${run.number}`}
title={
<div className="flex items-center gap-3">
<span>Run #{run.number}</span>
<EnvironmentLabel
size="large"
environment={run.environment}
userName={usernameForEnv}
/>
</div>
}
/>
<PageAccessories>
<AdminDebugTooltip>
<PropertyTable>
<Property label="ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{run.id}</Paragraph>
</div>
</Property>
<Property label="Trace ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{run.traceId}</Paragraph>
</div>
</Property>
<Property label="Env ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{run.environment.id}</Paragraph>
</div>
</Property>
<Property label="Org ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">
{run.environment.organizationId}
</Paragraph>
</div>
</Property>
</PropertyTable>
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>{run.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Trace ID</Property.Label>
<Property.Value>{run.traceId}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env ID</Property.Label>
<Property.Value>{run.environment.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{run.environment.organizationId}</Property.Value>
</Property.Item>
</Property.Table>
</AdminDebugTooltip>
<EnvironmentLabel size="large" environment={run.environment} userName={usernameForEnv} />
<Dialog key={`replay-${run.friendlyId}`}>
<DialogTrigger asChild>
<Button
variant="tertiary/small"
LeadingIcon={ArrowUturnLeftIcon}
shortcut={{ key: "R" }}
>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={v3RunSpanPath(
organization,
project,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
/>
</Dialog>
{run.isFinished ? null : (
<Dialog key={`cancel-${run.friendlyId}`}>
<DialogTrigger asChild>
<Button variant="danger/small" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={v3RunSpanPath(
organization,
project,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
/>
</Dialog>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
@@ -244,7 +286,7 @@ export default function Page() {
setResizableRunSettings(document, layout);
}}
>
<ResizablePanel order={1} minSize={30} defaultSize={resizeSettings.layout?.[0]}>
<ResizablePanel order={1} minSize={30} defaultSize={resizeSettings.layout?.[0] ?? 70}>
<TasksTreeView
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
@@ -269,7 +311,7 @@ export default function Page() {
</ResizablePanel>
<ResizableHandle withHandle />
{selectedSpanId && (
<ResizablePanel order={2} minSize={30} defaultSize={resizeSettings.layout?.[1]}>
<ResizablePanel order={2} minSize={25} defaultSize={resizeSettings.layout?.[1] ?? 30}>
<SpanView
runParam={run.friendlyId}
spanId={selectedSpanId}
@@ -311,7 +353,7 @@ function TasksTreeView({
}: TasksTreeViewProps) {
const [filterText, setFilterText] = useState("");
const [errorsOnly, setErrorsOnly] = useState(false);
const [showDurations, setShowDurations] = useState(false);
const [showDurations, setShowDurations] = useState(true);
const [scale, setScale] = useState(0);
const parentRef = useRef<HTMLDivElement>(null);
const treeScrollRef = useRef<HTMLDivElement>(null);
@@ -1028,16 +1070,11 @@ function KeyboardShortcuts({
title="Expand all"
/>
<ShortcutWithAction
shortcut={{ key: "c" }}
shortcut={{ key: "w" }}
action={() => collapseAllBelowDepth(1)}
title="Collapse all"
/>
<NumberShortcuts toggleLevel={(number) => toggleExpandLevel(number)} />
<ShortcutWithAction
shortcut={{ key: "d" }}
action={() => setShowDurations((d) => !d)}
title="Toggle durations"
/>
</>
);
}
@@ -15,7 +15,6 @@ import { ExitIcon } from "~/assets/icons/ExitIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
@@ -27,7 +26,7 @@ import {
import { Header2, Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBlankRow,
@@ -225,36 +224,63 @@ export default function Page() {
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="p-3">
<div className="space-y-3">
<PropertyTable>
<Property label="Schedule ID">{schedule.friendlyId}</Property>
<Property label="Task ID">{schedule.taskIdentifier}</Property>
<Property label="Type">
<ScheduleTypeCombo type={schedule.type} className="text-sm" />
</Property>
<Property label="CRON (UTC)" labelClassName="self-start">
<div className="space-y-2">
<InlineCode variant="extra-small">{schedule.cron}</InlineCode>
<Paragraph variant="small">{schedule.cronDescription}</Paragraph>
</div>
</Property>
<Property label="Timezone">{schedule.timezone}</Property>
<Property label="Environments">
<EnvironmentLabels size="small" environments={schedule.environments} />
</Property>
<Property.Table>
<Property.Item>
<Property.Label>Schedule ID</Property.Label>
<Property.Value>{schedule.friendlyId}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task ID</Property.Label>
<Property.Value>{schedule.taskIdentifier}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Type</Property.Label>
<Property.Value>
<ScheduleTypeCombo type={schedule.type} className="text-sm" />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>CRON</Property.Label>
<Property.Value>
<div className="space-y-2">
<InlineCode variant="extra-small">{schedule.cron}</InlineCode>
<Paragraph variant="small">{schedule.cronDescription}</Paragraph>
</div>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Timezone</Property.Label>
<Property.Value>{schedule.timezone}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environments</Property.Label>
<Property.Value>
<EnvironmentLabels size="small" environments={schedule.environments} />
</Property.Value>
</Property.Item>
{isImperative && (
<>
<Property label="External ID">
{schedule.externalId ? schedule.externalId : ""}
</Property>
<Property label="Deduplication key">
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</Property>
<Property label="Status">
<EnabledStatus enabled={schedule.active} />
</Property>
<Property.Item>
<Property.Label>External ID</Property.Label>
<Property.Value>
{schedule.externalId ? schedule.externalId : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Deduplication key</Property.Label>
<Property.Value>
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<EnabledStatus enabled={schedule.active} />
</Property.Value>
</Property.Item>
</>
)}
</PropertyTable>
</Property.Table>
<div className="flex flex-col gap-1">
<Header3>Last 5 runs</Header3>
<TaskRunsTable
@@ -297,12 +323,12 @@ export default function Page() {
</TableRow>
))
) : (
<TableBlankRow colSpan={1}>
<TableBlankRow colSpan={isUtc ? 1 : 2}>
<PlaceholderText title="You found a bug" />
</TableBlankRow>
)
) : (
<TableBlankRow colSpan={1}>
<TableBlankRow colSpan={isUtc ? 1 : 2}>
<PlaceholderText title="Schedule disabled" />
</TableBlankRow>
)}
@@ -28,7 +28,7 @@ import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
@@ -122,15 +122,14 @@ export default function Page() {
<PageTitle title="Schedules" />
<PageAccessories>
<AdminDebugTooltip>
<PropertyTable>
<Property.Table>
{schedules.map((schedule) => (
<Property label={schedule.friendlyId} key={schedule.id}>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{schedule.id}</Paragraph>
</div>
</Property>
<Property.Item key={schedule.id}>
<Property.Label>{schedule.friendlyId}</Property.Label>
<Property.Value>{schedule.id}</Property.Value>
</Property.Item>
))}
</PropertyTable>
</Property.Table>
</AdminDebugTooltip>
{limits.used >= limits.limit ? (
@@ -17,7 +17,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import * as Property from "~/components/primitives/PropertyTable";
import { prisma } from "~/db.server";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -116,18 +116,19 @@ export default function Page() {
<PageAccessories>
<AdminDebugTooltip>
<PropertyTable>
<Property label="ID">
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>{project.id}</Property.Value>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{project.id}</Paragraph>
</div>
</Property>
<Property label="Org ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{project.organizationId}</Paragraph>
</div>
</Property>
</PropertyTable>
</Property.Item>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{project.organizationId}</Property.Value>
</Property.Item>
</Property.Table>
</AdminDebugTooltip>
</PageAccessories>
</NavBar>
@@ -27,7 +27,7 @@ import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import * as Property from "~/components/primitives/PropertyTable";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { $replica } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -128,23 +128,25 @@ export default function Page() {
<PageAccessories>
<AdminDebugTooltip>
<PropertyTable>
<Property label="Org ID">
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{organization.id}</Paragraph>
</div>
</Property>
<Property.Table>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{organization.id}</Property.Value>
</Property.Item>
{members.map((member) => (
<Property label={member.user.name} key={member.id}>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">
{member.user.email} - {member.user.id}
</Paragraph>
</div>
</Property>
<Property.Item key={member.id}>
<Property.Label>{member.user.name}</Property.Label>
<Property.Value>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">
{member.user.email} - {member.user.id}
</Paragraph>
</div>
</Property.Value>
</Property.Item>
))}
</PropertyTable>
</Property.Table>
</AdminDebugTooltip>
</PageAccessories>
</NavBar>
@@ -2,7 +2,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { AddTagsRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { createTag, getTagsForRunId } from "~/models/taskRunTag.server";
import { createTag, getTagsForRunId, MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
@@ -51,12 +51,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
return !existingTags.map((t) => t.name).includes(tag);
});
if (existingTags.length + newTags.length > 3) {
if (existingTags.length + newTags.length > MAX_TAGS_PER_RUN) {
return json(
{
error: `Runs can only have 3 tags, you're trying to set ${
error: `Runs can only have ${MAX_TAGS_PER_RUN} tags, you're trying to set ${
existingTags.length + newTags.length
}.`,
}. These tags have not been set: ${newTags.map((t) => `'${t}'`).join(", ")}.`,
},
{ status: 422 }
);
@@ -1,21 +1,93 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/node";
import { ActionFunction, json, LoaderFunctionArgs } from "@remix-run/node";
import { prettyPrintPacket } from "@trigger.dev/core/v3";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { prisma } from "~/db.server";
import { $replica, prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
const FormSchema = z.object({
failedRedirect: z.string(),
});
const ParamSchema = z.object({
runParam: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { runParam } = ParamSchema.parse(params);
const run = await $replica.taskRun.findFirst({
select: {
payload: true,
payloadType: true,
runtimeEnvironmentId: true,
project: {
select: {
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: true,
},
},
},
where: {
OR: [
{
type: {
in: ["PREVIEW", "STAGING", "PRODUCTION"],
},
},
{
type: "DEVELOPMENT",
orgMember: {
userId,
},
},
],
},
},
},
},
},
where: { friendlyId: runParam, project: { organization: { members: { some: { userId } } } } },
});
if (!run) {
throw new Response("Not Found", { status: 404 });
}
const environment = run.project.environments.find((env) => env.id === run.runtimeEnvironmentId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
return typedjson({
payload: await prettyPrintPacket(run.payload, run.payloadType),
payloadType: run.payloadType,
environment: displayableEnvironment(environment, userId),
environments: sortEnvironments(
run.project.environments.map((environment) => displayableEnvironment(environment, userId))
),
});
}
const FormSchema = z.object({
environment: z.string().optional(),
payload: z.string().optional(),
failedRedirect: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { runParam } = ParamSchema.parse(params);
const formData = await request.formData();
@@ -44,7 +116,10 @@ export const action: ActionFunction = async ({ request, params }) => {
}
const replayRunService = new ReplayTaskRunService();
const newRun = await replayRunService.call(taskRun);
const newRun = await replayRunService.call(taskRun, {
environmentId: submission.value.environment,
payload: submission.value.payload,
});
if (!newRun) {
return redirectWithErrorMessage(
+1 -1
View File
@@ -24,7 +24,7 @@ export function machinePresetFromConfig(config: unknown): MachinePreset {
return machinePresetFromName("small-1x");
}
function machinePresetFromName(name: MachinePresetName): MachinePreset {
export function machinePresetFromName(name: MachinePresetName): MachinePreset {
return {
name,
...machines[name],
@@ -190,6 +190,7 @@ export class CreateTaskRunAttemptService extends BaseService {
costInCents: taskRun.costInCents,
baseCostInCents: taskRun.baseCostInCents,
maxAttempts: taskRun.maxAttempts ?? undefined,
version: taskRun.lockedBy.worker.version,
},
queue: {
id: queue.friendlyId,
@@ -1,6 +1,7 @@
import { logger } from "~/services/logger.server";
import { marqs } from "~/v3/marqs/index.server";
import { BaseService } from "./baseService.server";
import { eventRepository } from "../eventRepository.server";
export class ExpireEnqueuedRunService extends BaseService {
public async call(runId: string) {
@@ -48,6 +49,24 @@ export class ExpireEnqueuedRunService extends BaseService {
},
});
await eventRepository.completeEvent(run.spanId, {
endTime: new Date(),
attributes: {
isError: true,
},
events: [
{
name: "exception",
time: new Date(),
properties: {
exception: {
message: `Run expired because the TTL (${run.ttl}) was reached`,
},
},
},
],
});
await marqs?.acknowledgeMessage(run.id);
}
}
@@ -1,15 +1,27 @@
import { conditionallyImportPacket, parsePacket, RunTags } from "@trigger.dev/core/v3";
import {
conditionallyImportPacket,
IOPacket,
parsePacket,
RunTags,
stringifyIO,
} from "@trigger.dev/core/v3";
import { replaceSuperJsonPayload } from "@trigger.dev/core/v3/utils/ioSerialization";
import { TaskRun } from "@trigger.dev/database";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { getTagsForRunId } from "~/models/taskRunTag.server";
import { logger } from "~/services/logger.server";
import { BaseService } from "./baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server";
import { getTagsForRunId } from "~/models/taskRunTag.server";
type OverrideOptions = {
environmentId?: string;
payload?: string;
};
export class ReplayTaskRunService extends BaseService {
public async call(existingTaskRun: TaskRun) {
public async call(existingTaskRun: TaskRun, overrideOptions?: OverrideOptions) {
const authenticatedEnvironment = await findEnvironmentById(
existingTaskRun.runtimeEnvironmentId
overrideOptions?.environmentId ?? existingTaskRun.runtimeEnvironmentId
);
if (!authenticatedEnvironment) {
return;
@@ -20,10 +32,27 @@ export class ReplayTaskRunService extends BaseService {
taskRunFriendlyId: existingTaskRun.friendlyId,
});
const payloadPacket = await conditionallyImportPacket({
data: existingTaskRun.payload,
dataType: existingTaskRun.payloadType,
});
let payloadPacket: IOPacket;
if (overrideOptions?.payload) {
if (existingTaskRun.payloadType === "application/super+json") {
const newPayload = await replaceSuperJsonPayload(
existingTaskRun.payload,
overrideOptions.payload
);
payloadPacket = await stringifyIO(newPayload);
} else {
payloadPacket = await conditionallyImportPacket({
data: overrideOptions.payload,
dataType: existingTaskRun.payloadType,
});
}
} else {
payloadPacket = await conditionallyImportPacket({
data: existingTaskRun.payload,
dataType: existingTaskRun.payloadType,
});
}
const parsedPayload =
payloadPacket.dataType === "application/json"
@@ -38,7 +67,7 @@ export class ReplayTaskRunService extends BaseService {
try {
const tags = await getTagsForRunId({
friendlyId: existingTaskRun.id,
friendlyId: existingTaskRun.friendlyId,
environmentId: authenticatedEnvironment.id,
});
@@ -17,7 +17,7 @@ import { getEntitlement } from "~/services/platform.v3.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { createTag } from "~/models/taskRunTag.server";
import { createTag, MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server";
export type TriggerTaskServiceOptions = {
idempotencyKey?: string;
@@ -78,6 +78,16 @@ export class TriggerTaskService extends BaseService {
}
}
if (
body.options?.tags &&
typeof body.options.tags !== "string" &&
body.options.tags.length > MAX_TAGS_PER_RUN
) {
throw new ServiceValidationError(
`Runs can only have ${MAX_TAGS_PER_RUN} tags, you're trying to set ${body.options.tags.length}.`
);
}
const runFriendlyId = generateFriendlyId("run");
const payloadPacket = await this.#handlePayloadPacket(
+1 -4
View File
@@ -57,10 +57,7 @@ export type CreateBackgroundWorkerResponse = z.infer<typeof CreateBackgroundWork
//an array of 1, 2, or 3 strings
const RunTag = z.string().max(64, "Tags must be less than 64 characters");
export const RunTags = z.union([
RunTag,
RunTag.array().max(3, "You can only set a maximum of 3 tags on a run."),
]);
export const RunTags = z.union([RunTag, RunTag.array()]);
export type RunTags = z.infer<typeof RunTags>;
+1
View File
@@ -137,6 +137,7 @@ export const TaskRun = z.object({
durationMs: z.number().default(0),
costInCents: z.number().default(0),
baseCostInCents: z.number().default(0),
version: z.string().optional(),
});
export type TaskRun = z.infer<typeof TaskRun>;
@@ -351,3 +351,16 @@ function safeJsonParse(value: string): any {
return;
}
}
export async function replaceSuperJsonPayload(original: string, newPayload: string) {
const superjson = await loadSuperJSON();
const originalObject = superjson.parse(original);
const { meta } = superjson.serialize(originalObject);
const newSuperJson = {
json: JSON.parse(newPayload) as any,
meta,
};
return superjson.deserialize(newSuperJson);
}
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "TaskRun_spanId_idx" ON "TaskRun"("spanId");
+2
View File
@@ -1691,6 +1691,8 @@ model TaskRun {
@@index([projectId, taskIdentifier, status])
//Schedules
@@index([scheduleId])
// Run page inspector
@@index([spanId])
}
enum TaskRunStatus {
@@ -31,6 +31,10 @@ export const simpleChildTask = task({
await tags.add("product:1");
await wait.for({ seconds: 10 });
return {
foo: "bar",
};
},
});
@@ -9,6 +9,8 @@ type Payload = {
export const triggerRunsWithTags = task({
id: "trigger-runs-with-tags",
run: async (payload: Payload, { ctx }) => {
logger.info(`${ctx.run.version}`);
const { id } = await simpleChildTask.trigger(
{ message: "trigger from triggerRunsWithTags" },
{ tags: payload.tags }