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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+41
-27
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+193
-65
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
formatDurationNanoseconds,
|
||||
formatDurationInDays,
|
||||
nanosecondsToMilliseconds,
|
||||
millisecondsToNanoseconds,
|
||||
} from "./utils/durations";
|
||||
export { getEnvVar } from "./utils/getEnv";
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user