v3 live run reloading and minor style improvements (#925)

* Initial work creating the Redis publishing and subscribing

* Live view is working

* Show the live reloading status on the page

* Fixed some style issues on the run page with James

* Fix for invalid JSX attributes in the ExitIcon svg

* Some sexy shit

* The end line is now correctly coloured

* millisecondsToNanoseconds exported from core/v3 and imported correctly

* Fix for e2e tests, we changed the h1 to an h2

* Tidied imports

* Removed unused hook
This commit is contained in:
Matt Aitken
2024-03-06 09:48:00 +00:00
committed by GitHub
parent 2a1273b683
commit 44d443a048
17 changed files with 459 additions and 253 deletions
+4 -4
View File
@@ -1,13 +1,13 @@
export function ExitIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="3.5" y1="8" x2="11.5" y2="8" stroke="currentColor" stroke-linecap="round" />
<line x1="15.5" y1="1.5" x2="15.5" y2="14.5" stroke="currentColor" stroke-linecap="round" />
<line x1="3.5" y1="8" x2="11.5" y2="8" stroke="currentColor" strokeLinecap="round" />
<line x1="15.5" y1="1.5" x2="15.5" y2="14.5" stroke="currentColor" strokeLinecap="round" />
<path
d="M8.5 4.5L12 8L8.5 11.5"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
@@ -1,31 +0,0 @@
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import { cn } from "~/utils/cn";
import { environmentTextClassName, environmentTitle } from "./environments/EnvironmentLabel";
type Environment = Pick<RuntimeEnvironment, "type">;
type VersionLabelProps = {
environment: Environment;
userName?: string;
version: string;
};
export function VersionLabel({ environment, userName, version }: VersionLabelProps) {
return (
<div
className={cn(
"border-midnight-700 inline-flex items-center justify-stretch justify-items-stretch rounded-sm border text-xxs"
)}
>
<div className="px-1 text-xs tabular-nums text-text-dimmed">v{version}</div>
<div
className={cn(
"border-midnight-700 inline-flex items-center justify-center rounded-r-sm border-l px-1 text-xxs font-medium uppercase tracking-wider",
environmentTextClassName(environment)
)}
>
{environmentTitle(environment, userName)}
</div>
</div>
);
}
@@ -1,7 +1,6 @@
import React, { FunctionComponent, ReactElement, createElement } from "react";
import { IconNamesOrString, NamedIcon } from "./NamedIcon";
import React, { FunctionComponent, createElement } from "react";
import { cn } from "~/utils/cn";
import { render } from "react-dom";
import { IconNamesOrString, NamedIcon } from "./NamedIcon";
export type RenderIcon =
| IconNamesOrString
@@ -9,6 +9,7 @@ import {
useRef,
useState,
} from "react";
import { inverseLerp, lerp } from "~/utils/lerp";
interface MousePosition {
x: number;
@@ -225,19 +226,3 @@ export function FollowCursor({ children }: FollowCursorProps) {
function calculatePixelWidth(minWidth: number, maxWidth: number, scale: number) {
return lerp(minWidth, maxWidth, scale);
}
/** Linearly interpolates between the min/max values, using t.
* It can't go outside the range */
function lerp(min: number, max: number, t: number) {
return min + (max - min) * clamp(t, 0, 1);
}
/** Inverse lerp */
function inverseLerp(min: number, max: number, value: number) {
return (value - min) / (max - min);
}
/** Clamps a value between a min and max */
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -39,3 +39,34 @@ export function LiveTimer({
</Paragraph>
);
}
export function LiveCountUp({
lastUpdated,
updateInterval = 250,
className,
}: {
lastUpdated: Date;
updateInterval?: number;
className?: string;
}) {
const [now, setNow] = useState<Date>();
useEffect(() => {
const interval = setInterval(() => {
const date = new Date();
setNow(date);
}, updateInterval);
return () => clearInterval(interval);
}, [lastUpdated]);
return (
<>
{formatDuration(lastUpdated, now, {
style: "short",
maxDecimalPoints: 0,
units: ["m", "s"],
})}
</>
);
}
@@ -9,6 +9,7 @@ type SpanTitleProps = {
isError: boolean;
style: TaskEventStyle;
level: TaskEventLevel;
isPartial: boolean;
size: "small" | "large";
};
@@ -90,10 +91,6 @@ export function SpanCodePathAccessory({
}
function eventTextClassName(event: Pick<SpanTitleProps, "isError" | "style" | "level">) {
if (event.isError) {
return "text-rose-500";
}
switch (event.level) {
case "TRACE": {
return textClassNameForVariant(event.style.variant);
@@ -107,7 +104,7 @@ function eventTextClassName(event: Pick<SpanTitleProps, "isError" | "style" | "l
return "text-amber-400";
}
case "ERROR": {
return "text-rose-500";
return "text-error";
}
default: {
return textClassNameForVariant(event.style.variant);
@@ -116,29 +113,29 @@ function eventTextClassName(event: Pick<SpanTitleProps, "isError" | "style" | "l
}
export function eventBackgroundClassName(
event: Pick<SpanTitleProps, "isError" | "style" | "level">
event: Pick<SpanTitleProps, "isError" | "style" | "level" | "isPartial">
) {
if (event.isError) {
return "bg-rose-500";
return "bg-error";
}
switch (event.level) {
case "TRACE": {
return backgroundClassNameForVariant(event.style.variant);
return backgroundClassNameForVariant(event.style.variant, event.isPartial);
}
case "LOG":
case "INFO":
case "DEBUG": {
return backgroundClassNameForVariant(event.style.variant);
return backgroundClassNameForVariant(event.style.variant, event.isPartial);
}
case "WARN": {
return "bg-amber-400";
}
case "ERROR": {
return "bg-rose-500";
return "bg-error";
}
default: {
return backgroundClassNameForVariant(event.style.variant);
return backgroundClassNameForVariant(event.style.variant, event.isPartial);
}
}
}
@@ -154,10 +151,13 @@ function textClassNameForVariant(variant: TaskEventStyle["variant"]) {
}
}
function backgroundClassNameForVariant(variant: TaskEventStyle["variant"]) {
function backgroundClassNameForVariant(variant: TaskEventStyle["variant"], isPartial: boolean) {
switch (variant) {
case "primary": {
return "bg-blue-500";
if (isPartial) {
return "bg-blue-500";
}
return "bg-success";
}
default: {
return "bg-charcoal-500";
@@ -74,15 +74,15 @@ export function runStatusClassNameColor(status: ExtendedTaskAttemptStatus | null
case "PENDING":
return "text-charcoal-500";
case "EXECUTING":
return "text-blue-500";
return "text-pending";
case "PAUSED":
return "text-amber-300";
case "FAILED":
return "text-rose-500";
return "text-error";
case "CANCELED":
return "text-charcoal-500";
case "COMPLETED":
return "text-green-500";
return "text-success";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
@@ -0,0 +1,13 @@
import { useEffect, useLayoutEffect, useState } from "react";
export function useInitialDimensions(ref: React.RefObject<HTMLElement>) {
const [dimensions, setDimensions] = useState<DOMRectReadOnly | null>(null);
useEffect(() => {
if (ref.current) {
setDimensions(ref.current.getBoundingClientRect());
}
}, [ref]);
return dimensions;
}
@@ -1,11 +0,0 @@
import { UIMatch, useMatches } from "@remix-run/react";
export function useIsOrgChildPage(matches?: UIMatch[]) {
if (!matches) {
matches = useMatches();
}
return matches.some((matchData) => {
return matchData.id.startsWith("routes/_app.orgs.$organizationSlug");
});
}
@@ -1,4 +1,4 @@
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3/utils/durations";
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
import { PrismaClient, prisma } from "~/db.server";
import { getUsername } from "~/utils/username";
@@ -98,6 +98,15 @@ export class RunPresenter {
}
}
let rootSpanStatus: "executing" | "completed" | "failed" = "executing";
if (events[0]) {
if (events[0].data.isError) {
rootSpanStatus = "failed";
} else if (!events[0].data.isPartial) {
rootSpanStatus = "completed";
}
}
return {
run: {
number: run.number,
@@ -109,6 +118,7 @@ export class RunPresenter {
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
},
},
rootSpanStatus,
events: events,
parentRunFriendlyId:
tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId,
@@ -1,7 +1,8 @@
import { JobRun, TaskRun, TaskRunAttempt } from "@trigger.dev/database";
import { TaskRun, TaskRunAttempt } from "@trigger.dev/database";
import { eventStream } from "remix-utils/sse/server";
import { PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { sse } from "~/utils/sse.server";
import { eventRepository } from "~/v3/eventRepository.server";
type RunWithAttempts = {
updatedAt: Date;
@@ -11,6 +12,8 @@ type RunWithAttempts = {
}[];
};
const pingInterval = 1000;
export class RunStreamPresenter {
#prismaClient: PrismaClient;
@@ -25,94 +28,89 @@ export class RunStreamPresenter {
request: Request;
runFriendlyId: TaskRun["friendlyId"];
}) {
const run = await this.#runForUpdates(runFriendlyId);
const run = await this.#prismaClient.taskRun.findUnique({
where: {
friendlyId: runFriendlyId,
},
select: {
traceId: true,
},
});
if (!run) {
return new Response("Not found", { status: 404 });
}
let lastUpdatedAt = this.#getLatestUpdatedAt(run);
logger.info("RunStreamPresenter.call", {
runFriendlyId,
lastUpdatedAt,
traceId: run.traceId,
});
return sse({
request,
run: async (send, stop) => {
const result = await this.#runForUpdates(runFriendlyId);
if (!result) {
return stop();
let pinger: NodeJS.Timer | undefined = undefined;
const { unsubscribe, eventEmitter } = await eventRepository.subscribeToTrace(run.traceId);
return eventStream(request.signal, (send, close) => {
const safeSend = (args: { event?: string; data: string }) => {
try {
send(args);
} catch (error) {
if (error instanceof Error) {
if (error.name !== "TypeError") {
logger.debug("Error sending SSE, aborting", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
args,
});
}
} else {
logger.debug("Unknown error sending SSE, aborting", {
error,
args,
});
}
close();
}
};
eventEmitter.addListener("message", (event) => {
safeSend({ data: event });
});
pinger = setInterval(() => {
if (request.signal.aborted) {
return close();
}
if (this.#isRunCompleted(result)) {
logger.info("RunStreamPresenter.call completed", {
runFriendlyId,
lastUpdatedAt,
completed: true,
});
send({ data: new Date().toISOString() });
return stop();
}
safeSend({ event: "ping", data: new Date().toISOString() });
}, pingInterval);
const newUpdatedAt = this.#getLatestUpdatedAt(result);
if (lastUpdatedAt !== newUpdatedAt) {
logger.info("RunStreamPresenter.call updated", {
runFriendlyId,
lastUpdatedAt,
newUpdatedAt,
});
send({ data: result.updatedAt.toISOString() });
}
logger.info("RunStreamPresenter.call waiting", {
return function clear() {
logger.info("RunStreamPresenter.abort", {
runFriendlyId,
lastUpdatedAt,
newUpdatedAt,
traceId: run.traceId,
});
lastUpdatedAt = newUpdatedAt;
},
clearInterval(pinger);
eventEmitter.removeAllListeners();
unsubscribe().catch((error) => {
logger.error("RunStreamPresenter.abort.unsubscribe", {
runFriendlyId,
traceId: run.traceId,
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
});
};
});
}
#runForUpdates(friendlyId: string) {
return this.#prismaClient.taskRun.findUnique({
where: {
friendlyId,
},
select: {
updatedAt: true,
attempts: {
select: {
status: true,
updatedAt: true,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
}
#getLatestUpdatedAt(run: RunWithAttempts) {
const lastAttempt = run.attempts[0];
if (lastAttempt) {
return lastAttempt.updatedAt.getTime();
}
return run.updatedAt.getTime();
}
#isRunCompleted(run: RunWithAttempts) {
return run.attempts.some(
(attempt) =>
attempt.status === "FAILED" ||
attempt.status === "CANCELED" ||
attempt.status === "COMPLETED"
);
}
}
@@ -1,29 +1,27 @@
import { useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
import { ReactNode } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { VersionLabel } from "~/components/VersionLabel";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { InlineCode } from "~/components/code/InlineCode";
import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
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 { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
import { TaskPath } from "~/components/runs/v3/TaskPath";
import { TaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3RunPath, v3SpanParamsSchema } from "~/utils/pathBuilder";
import { TaskPath } from "~/components/runs/v3/TaskPath";
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
import { LinkButton } from "~/components/primitives/Buttons";
import { AlignRightIcon } from "lucide-react";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useParams } from "@remix-run/react";
import { ExitIcon } from "~/assets/icons/ExitIcon";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -49,7 +47,7 @@ export default function Page() {
const { runParam } = useParams();
return (
<div className="grid max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon name={event.style?.icon} className="h-4 min-h-4 w-4 min-w-4" />
@@ -66,7 +64,7 @@ export default function Page() {
/>
)}
</div>
<div className="overflow-y-auto px-2 pt-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<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>
{event.level === "TRACE" ? (
@@ -85,6 +83,22 @@ export default function Page() {
</Paragraph>
</Property>
)}
{event.style.variant === "primary" && (
<Property label="Status">
<TaskRunStatus
status={
event.isCancelled
? "CANCELED"
: event.isError
? "FAILED"
: event.isPartial
? "EXECUTING"
: "COMPLETED"
}
className="text-sm"
/>
</Property>
)}
<Property label="Message">{event.message}</Property>
<Property label="Task ID">{event.taskSlug}</Property>
{event.taskPath && event.taskExportName && (
@@ -100,10 +114,10 @@ export default function Page() {
{event.queueName && <Property label="Queue name">{event.queueName}</Property>}
{event.workerVersion && (
<Property label="Version">
<VersionLabel
version={event.workerVersion}
environment={{ type: event.environmentType }}
/>
<div className="flex items-center gap-2 text-sm text-text-bright">
<span>{event.workerVersion}</span>
<EnvironmentLabel environment={{ type: event.environmentType }} />
</div>
</Property>
)}
</PropertyTable>
@@ -206,7 +220,7 @@ function TimelineBar({
) : state === "complete" ? (
<div className="flex flex-1 items-center">
<div className={cn("h-0.75 flex-1", classNameForState(state))} />
<Paragraph variant="small" className="px-1 text-green-500">
<Paragraph variant="small" className="px-1 text-success">
{formatDurationNanoseconds(duration, { style: "short" })}
</Paragraph>
<div className={cn("h-0.75 flex-1", classNameForState(state))} />
@@ -231,10 +245,10 @@ function VerticalBar({ state }: { state: TimelineState }) {
function DottedLine() {
return (
<div className="flex h-0.75 flex-1 items-center justify-evenly">
<div className="h-0.75 w-0.75 bg-blue-500" />
<div className="h-0.75 w-0.75 bg-blue-500" />
<div className="h-0.75 w-0.75 bg-blue-500" />
<div className="h-0.75 w-0.75 bg-blue-500" />
<div className="h-0.75 w-0.75 bg-pending" />
<div className="h-0.75 w-0.75 bg-pending" />
<div className="h-0.75 w-0.75 bg-pending" />
<div className="h-0.75 w-0.75 bg-pending" />
</div>
);
}
@@ -242,13 +256,13 @@ function DottedLine() {
function classNameForState(state: TimelineState) {
switch (state) {
case "pending": {
return "bg-blue-500";
return "bg-pending";
}
case "complete": {
return "bg-green-500";
return "bg-success";
}
case "error": {
return "bg-rose-500";
return "bg-error";
}
}
}
@@ -3,6 +3,7 @@ import {
ChevronRightIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
NoSymbolIcon,
} from "@heroicons/react/20/solid";
import { Link, Outlet, useNavigate, useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
@@ -26,10 +27,13 @@ import { Slider } from "~/components/primitives/Slider";
import { Switch } from "~/components/primitives/Switch";
import * as Timeline from "~/components/primitives/Timeline";
import { TreeView, useTree } from "~/components/primitives/TreeView/TreeView";
import { LiveCountUp, LiveTimer } from "~/components/runs/v3/LiveTimer";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { SpanTitle, eventBackgroundClassName } from "~/components/runs/v3/SpanTitle";
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
import { useDebounce } from "~/hooks/useDebounce";
import { useEventSource } from "~/hooks/useEventSource";
import { useInitialDimensions } from "~/hooks/useInitialDimensions";
import { useOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { useProject } from "~/hooks/useProject";
@@ -38,6 +42,7 @@ import { RunEvent, RunPresenter } from "~/presenters/v3/RunPresenter.server";
import { getResizableRunSettings, setResizableRunSettings } from "~/services/resizablePanel";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { lerp } from "~/utils/lerp";
import {
v3RunParamsSchema,
v3RunPath,
@@ -51,7 +56,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { projectParam, organizationSlug, runParam } = v3RunParamsSchema.parse(params);
const presenter = new RunPresenter();
const { run, events, parentRunFriendlyId, duration } = await presenter.call({
const { run, events, parentRunFriendlyId, duration, rootSpanStatus } = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
@@ -67,6 +72,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
parentRunFriendlyId,
resizeSettings,
duration,
rootSpanStatus,
});
};
@@ -77,7 +83,7 @@ function getSpanId(path: string): string | undefined {
}
export default function Page() {
const { run, events, parentRunFriendlyId, resizeSettings, duration } =
const { run, events, parentRunFriendlyId, resizeSettings, duration, rootSpanStatus } =
useTypedLoaderData<typeof loader>();
const navigate = useNavigate();
const organization = useOrganization();
@@ -136,6 +142,7 @@ export default function Page() {
changeToSpan(selectedSpan);
}}
totalDuration={duration}
rootSpanStatus={rootSpanStatus}
/>
) : (
<ResizablePanelGroup
@@ -162,6 +169,7 @@ export default function Page() {
changeToSpan(selectedSpan);
}}
totalDuration={duration}
rootSpanStatus={rootSpanStatus}
/>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -184,20 +192,24 @@ function TasksTreeView({
parentRunFriendlyId,
onSelectedIdChanged,
totalDuration,
rootSpanStatus,
}: {
events: RunEvent[];
selectedId?: string;
parentRunFriendlyId?: string;
onSelectedIdChanged: (selectedId: string | undefined) => void;
totalDuration: number;
rootSpanStatus: "executing" | "completed" | "failed";
}) {
const [filterText, setFilterText] = useState("");
const [errorsOnly, setErrorsOnly] = useState(false);
const [showDurations, setShowDurations] = useState(false);
const [scale, setScale] = useState(0.25);
const [scale, setScale] = useState(0);
const parentRef = useRef<HTMLDivElement>(null);
const treeScrollRef = useRef<HTMLDivElement>(null);
const timelineScrollRef = useRef<HTMLDivElement>(null);
const timelineContainerRef = useRef<HTMLDivElement>(null);
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
const {
nodes,
@@ -239,6 +251,7 @@ function TasksTreeView({
onChange={(e) => setFilterText(e.target.value)}
/>
<div className="flex items-center gap-2">
<LiveReloadingStatus rootSpanCompleted={rootSpanStatus !== "executing"} />
<Switch
variant="small"
label="Errors only"
@@ -274,8 +287,14 @@ function TasksTreeView({
{/* Tree list */}
<ResizablePanel order={1} minSize={20} defaultSize={50} className="pl-3">
<div className="grid h-full grid-rows-[2rem_1fr] overflow-hidden">
<div>
{parentRunFriendlyId && <ShowParentLink runFriendlyId={parentRunFriendlyId} />}
<div className="flex items-center">
{parentRunFriendlyId ? (
<ShowParentLink runFriendlyId={parentRunFriendlyId} />
) : (
<Paragraph variant="small" className="text-charcoal-500">
This is the root task
</Paragraph>
)}
</div>
<TreeView
parentRef={parentRef}
@@ -286,11 +305,10 @@ function TasksTreeView({
nodes={nodes}
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
parentClassName="pt-2"
renderNode={({ node, state }) => (
<div
className={cn(
"flex h-8 cursor-pointer items-center rounded-l-sm pr-3",
"flex h-8 cursor-pointer items-center rounded-l-sm pr-2",
state.selected
? "bg-grid-dimmed hover:bg-grid-bright"
: "bg-transparent hover:bg-grid-dimmed"
@@ -332,18 +350,14 @@ function TasksTreeView({
</div>
</div>
<div className="flex w-full items-center justify-between gap-2 px-1">
<div className="flex w-full items-center justify-between gap-2 pl-1">
<div className="flex items-center gap-2 overflow-x-hidden">
<RunIcon name={node.data.style?.icon} className="h-4 min-h-4 w-4 min-w-4" />
<NodeText node={node} />
{node.data.isRoot && <Badge variant="outline-rounded">Root</Badge>}
</div>
<div className="flex items-center gap-2">
{node.data.isCancelled ? (
<Paragraph variant="extra-small" className="text-amber-500">
Cancelled
</Paragraph>
) : null}
<div className="flex items-center gap-1">
<NodeStatusIcon node={node} />
</div>
</div>
</div>
@@ -363,66 +377,76 @@ function TasksTreeView({
<ResizableHandle withHandle />
{/* Timeline */}
<ResizablePanel order={2} minSize={20} defaultSize={50}>
<div className="h-full overflow-x-auto overflow-y-hidden pr-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div
className="h-full overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
ref={timelineContainerRef}
>
<Timeline.Root
durationMs={nanosecondsToMilliseconds(totalDuration)}
durationMs={nanosecondsToMilliseconds(totalDuration * 1.05)}
scale={scale}
className="h-full pt-2"
minWidth={300}
className="h-full overflow-hidden"
minWidth={initialTimelineDimensions?.width ?? 300}
maxWidth={2000}
>
{/* Follows the cursor */}
<Timeline.FollowCursor>
{(ms) => (
<div className="relative z-50 flex h-full flex-col">
<div className="relative flex h-8 items-end">
<div className="absolute left-1/2 w-fit -translate-x-1/2 rounded-sm border border-charcoal-600 bg-charcoal-750 px-1 py-0.5 text-xxs tabular-nums text-text-bright">
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
</div>
</div>
<div className="w-px grow border-r border-charcoal-600" />
</div>
)}
</Timeline.FollowCursor>
<CurrentTimeIndicator totalDuration={totalDuration} />
<Timeline.Row className="grid h-full grid-rows-[2rem_1fr]">
{/* The duration labels */}
<Timeline.Row>
<Timeline.Row className="h-5">
<Timeline.EquallyDistribute count={tickCount}>
{(ms: number, index: number) => (
<Timeline.Point
ms={ms}
className={"relative bottom-0 text-xxs text-text-dimmed"}
>
{(ms) => (
<div
className={cn(
"whitespace-nowrap",
index === 0
? "ml-0.5"
: index === tickCount - 1
? "-translate-x-full"
: "-translate-x-1/2"
)}
>
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
</div>
)}
</Timeline.Point>
)}
</Timeline.EquallyDistribute>
</Timeline.Row>
<Timeline.Row className="h-3">
<Timeline.Row className="h-6">
<Timeline.EquallyDistribute count={tickCount}>
{(ms: number, index: number) => {
if (index === 0) return null;
if (index === tickCount - 1) return null;
return (
<Timeline.Point
ms={ms}
className={"relative bottom-[2px] text-xxs text-text-dimmed"}
>
{(ms) => (
<div
className={cn(
"whitespace-nowrap",
index === 0
? "ml-1"
: index === tickCount - 1
? "-ml-1 -translate-x-full"
: "-translate-x-1/2"
)}
>
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
</div>
)}
</Timeline.Point>
);
}}
</Timeline.EquallyDistribute>
{rootSpanStatus !== "executing" && (
<Timeline.Point
ms={nanosecondsToMilliseconds(totalDuration)}
className={cn(
"relative bottom-[2px] text-xxs",
rootSpanStatus === "completed" ? "text-success" : "text-error"
)}
>
{(ms) => (
<div className={cn("-translate-x-1/2 whitespace-nowrap")}>
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
</div>
)}
</Timeline.Point>
)}
</Timeline.Row>
<Timeline.Row className="h-2">
<Timeline.EquallyDistribute count={tickCount}>
{(ms: number, index: number) => {
if (index === 0 || index === tickCount - 1) return null;
return (
<Timeline.Point
ms={ms}
@@ -431,6 +455,13 @@ function TasksTreeView({
);
}}
</Timeline.EquallyDistribute>
<Timeline.Point
ms={nanosecondsToMilliseconds(totalDuration)}
className={cn(
"h-full border-r",
rootSpanStatus === "completed" ? "border-success/30" : "border-error/30"
)}
/>
</Timeline.Row>
</Timeline.Row>
{/* Main timeline body */}
@@ -444,6 +475,16 @@ function TasksTreeView({
);
}}
</Timeline.EquallyDistribute>
{/* The completed line */}
{rootSpanStatus !== "executing" && (
<Timeline.Point
ms={nanosecondsToMilliseconds(totalDuration)}
className={cn(
"h-full border-r",
rootSpanStatus === "completed" ? "border-success/30" : "border-error/30"
)}
/>
)}
<TreeView
parentRef={parentRef}
scrollRef={timelineScrollRef}
@@ -479,7 +520,7 @@ function TasksTreeView({
<Timeline.Point
ms={nanosecondsToMilliseconds(node.data.offset)}
className={cn(
"-ml-1.5 h-3 w-3 rounded-full border-2 border-background-bright",
"-ml-1 h-3 w-3 rounded-full border-2 border-background-bright",
eventBackgroundClassName(node.data)
)}
/>
@@ -512,6 +553,31 @@ function NodeText({ node }: { node: RunEvent }) {
</Paragraph>
);
}
function NodeStatusIcon({ node }: { node: RunEvent }) {
if (node.data.level !== "TRACE") return null;
if (node.data.style.variant !== "primary") return null;
if (node.data.isCancelled) {
return (
<>
<Paragraph variant="extra-small" className={runStatusClassNameColor("CANCELED")}>
Canceled
</Paragraph>
<TaskRunStatusIcon status="CANCELED" className={cn("size-4")} />
</>
);
}
if (node.data.isError) {
return <TaskRunStatusIcon status="FAILED" className={cn("size-4")} />;
}
if (node.data.isPartial) {
return <TaskRunStatusIcon status={"EXECUTING"} className={cn("size-4")} />;
}
return <TaskRunStatusIcon status="COMPLETED" className={cn("size-4")} />;
}
function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boolean }) {
return (
@@ -550,6 +616,30 @@ function ShowParentLink({ runFriendlyId }: { runFriendlyId: string }) {
);
}
function LiveReloadingStatus({ rootSpanCompleted }: { rootSpanCompleted: boolean }) {
if (rootSpanCompleted) return null;
return (
<div className="flex items-center gap-1">
<PulsingDot />
<Paragraph variant="extra-small" className="whitespace-nowrap text-blue-500">
Live reloading
</Paragraph>
</div>
);
}
function PulsingDot() {
return (
<span className="relative flex h-2 w-2">
<span
className={`absolute h-full w-full animate-ping rounded-full border border-blue-500 opacity-100 duration-1000`}
/>
<span className={`h-2 w-2 rounded-full bg-blue-500`} />
</span>
);
}
function SpanWithDuration({
showDuration,
node,
@@ -559,7 +649,7 @@ function SpanWithDuration({
<Timeline.Span {...props}>
<div
className={cn(
"relative flex h-4 w-full min-w-px items-center rounded-sm",
"relative flex h-4 w-full min-w-[2px] items-center rounded-sm",
eventBackgroundClassName(node.data)
)}
>
@@ -586,3 +676,41 @@ function SpanWithDuration({
</Timeline.Span>
);
}
const edgeBoundary = 0.05;
function CurrentTimeIndicator({ totalDuration }: { totalDuration: number }) {
return (
<Timeline.FollowCursor>
{(ms) => {
const ratio = ms / nanosecondsToMilliseconds(totalDuration);
let offset = 0.5;
if (ratio < edgeBoundary) {
offset = lerp(0, 0.5, ratio / edgeBoundary);
} else if (ratio > 1 - edgeBoundary) {
offset = lerp(0.5, 1, (ratio - (1 - edgeBoundary)) / edgeBoundary);
}
return (
<div className="relative z-50 flex h-full flex-col">
<div className="relative flex h-6 items-end">
<div
className="absolute w-fit whitespace-nowrap rounded-sm border border-charcoal-600 bg-charcoal-750 px-1 py-0.5 text-xxs tabular-nums text-text-bright"
style={{
left: `${offset * 100}%`,
transform: `translateX(-${offset * 100}%)`,
}}
>
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
</div>
</div>
<div className="w-px grow border-r border-charcoal-600" />
</div>
);
}}
</Timeline.FollowCursor>
);
}
+15
View File
@@ -0,0 +1,15 @@
/** Linearly interpolates between the min/max values, using t.
* It can't go outside the range */
export function lerp(min: number, max: number, t: number) {
return min + (max - min) * clamp(t, 0, 1);
}
/** Inverse lerp */
export function inverseLerp(min: number, max: number, value: number) {
return (value - min) / (max - min);
}
/** Clamps a value between a min and max */
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
+56 -2
View File
@@ -12,7 +12,6 @@ import {
flattenAndNormalizeAttributes,
flattenAttributes,
isExceptionSpanEvent,
logger,
omit,
unflattenAttributes,
} from "@trigger.dev/core/v3";
@@ -21,6 +20,10 @@ import { createHash } from "node:crypto";
import { PrismaClient, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { DynamicFlushScheduler } from "./dynamicFlushScheduler.server";
import Redis, { RedisOptions } from "ioredis";
import { env } from "~/env.server";
import { EventEmitter } from "node:stream";
import { logger } from "~/services/logger.server";
export type CreatableEvent = Omit<
Prisma.TaskEventCreateInput,
@@ -77,6 +80,7 @@ export type EventBuilder = {
export type EventRepoConfig = {
batchSize: number;
batchInterval: number;
redis: RedisOptions;
};
export type QueryOptions = Prisma.TaskEventWhereInput;
@@ -119,8 +123,8 @@ export type UpdateEventOptions = {
export class EventRepository {
private readonly _flushScheduler: DynamicFlushScheduler<CreatableEvent>;
private _randomIdGenerator = new RandomIdGenerator();
private _redisPublishClient: Redis;
constructor(private db: PrismaClient = prisma, private readonly _config: EventRepoConfig) {
this._flushScheduler = new DynamicFlushScheduler({
@@ -128,6 +132,8 @@ export class EventRepository {
flushInterval: _config.batchInterval,
callback: this.#flushBatch.bind(this),
});
this._redisPublishClient = new Redis(this._config.redis);
}
async insert(event: CreatableEvent) {
@@ -138,6 +144,8 @@ export class EventRepository {
await this.db.taskEvent.create({
data: event as Prisma.TaskEventCreateInput,
});
this.#publishToRedis([event]);
}
async insertMany(events: CreatableEvent[]) {
@@ -576,12 +584,50 @@ export class EventRepository {
return result;
}
async subscribeToTrace(traceId: string) {
const redis = new Redis(this._config.redis);
const channel = `events:${traceId}:*`;
// Subscribe to the channel.
await redis.psubscribe(channel);
const eventEmitter = new EventEmitter();
// Define the message handler.
redis.on("pmessage", (pattern, channelReceived, message) => {
if (channelReceived.startsWith(`events:${traceId}:`)) {
eventEmitter.emit("message", message);
}
});
// Return a function that can be used to unsubscribe.
const unsubscribe = async () => {
await redis.punsubscribe(channel);
};
return {
unsubscribe,
eventEmitter,
};
}
async #flushBatch(batch: CreatableEvent[]) {
const events = excludePartialEventsWithCorrespondingFullEvent(batch);
await this.db.taskEvent.createMany({
data: events as Prisma.TaskEventCreateManyInput[],
});
this.#publishToRedis(events);
}
async #publishToRedis(events: CreatableEvent[]) {
if (events.length === 0) return;
const uniqueTraceSpans = new Set(events.map((e) => `events:${e.traceId}:${e.spanId}`));
for (const id of uniqueTraceSpans) {
await this._redisPublishClient.publish(id, new Date().toISOString());
}
}
public generateTraceId() {
@@ -614,6 +660,14 @@ export class EventRepository {
export const eventRepository = new EventRepository(prisma, {
batchSize: 100,
batchInterval: 5000,
redis: {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
});
export function stripAttributePrefix(attributes: Attributes, prefix: string) {
+1
View File
@@ -16,6 +16,7 @@ export {
formatDurationNanoseconds,
formatDurationInDays,
nanosecondsToMilliseconds,
millisecondsToNanoseconds,
} from "./utils/durations";
export { getEnvVar } from "./utils/getEnv";
+2 -2
View File
@@ -31,13 +31,13 @@ test("Verify jobs from the test nextjs project", async ({ page }) => {
await page.locator("a").filter({ hasText: "Test Project" }).click();
await page.getByRole("link", { name: "Environments & API Keys" }).click();
await expect(page.locator("h1").filter({ hasText: "Environments & API Keys" })).toBeVisible();
await expect(page.locator("h2").filter({ hasText: "Environments & API Keys" })).toBeVisible();
await expect(
page.locator("h3").filter({ hasText: "nextjs-test" })
// Set the timeout high to allow the cli to register jobs
).toBeVisible({ timeout: 15000 });
await page.getByRole("link", { name: "Jobs" }).click();
await expect(page.locator("h1").filter({ hasText: /^Jobs$/ })).toBeVisible();
await expect(page.locator("h2").filter({ hasText: /^Jobs$/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Test Job One/ })).toBeVisible();
});