ElectricSQL run page (hidden page for now) (#1297)

* WIP on using react-window-splitter

* WIP with new resizable panels and SSR

* Use the cookie package

* Resizable storybook page

* Increase indexing memory limit

* Fixed v2 usage meter displaying when on paid plan (#1255)

* Fixed v2 usage meter displaying when on paid plan

* Show the free usage panel only for v3 projects

* Concurrency page and more accurate tracking (#1252)

* Initial TaskRunConcurrencyTracker implementation

* MARQS calls a subscriber to events

* When enqueuing add the extra required metadata

* Track concurrency per environment for tasks too

* Admin page for global concurrency

* Use the new concurrency tracker on the tasks page

* Useful performance test task

* getAllTaskIdentifiers()

* New page for concurrency

* BackgroundWorkerTask index for quick lookup of task identifiers

* Added a way to get concurrency for environments

* Added upgrade/request more concurrency button

* Queued task column working

* Use defer and suspense

* Added queue column to the concurrency environments table

* Some comments added for clarity

* Fixed bad log message

* Sidemenu: move lower and rename to “Concurrency limits”

* Only show the environments, not tasks. Renamed to “Concurrency limits”

* v3: fix unfreezable state crashes for runs with multiple waits (#1253)

* support named capture groups

* write crash errors to attempt.error

* make restored pod names unique per checkpoint

* use last eight characters of checkpoint id instead

* add more chaos monkey env vars

* Ignore unfreezable states

* prevent excessive queue config parsing errors

* handle dependency resume edge case

* better entry point logging

* ignore checkpoint cancellation timeouts

* add missing idempotency keys to wait for dep replays

* remove checkpoints between attempts

* fix retry container names on kubernetes

* add changeset

* fix types

* bring back internal duration timers

* Added more logging to TaskRunConcurrencyTracker and some more try/catches

* Call subscriber.messageDequeued in dequeueMessageInSharedQueue

* Added messageReplaced to concurrency tracking (when freezing)

* Added depenenciesToBundle guide to bundle all packages

* Include the old message data when replacing, so we get the projectId etc.

* Fix restored container names

* Fix for schedule page not scrolling

* Added a description panel to the Concurrency admin page

* chore: Update version for release (beta) (#1256)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Release 3.0.0-beta.53

* Added a note to use batchTrigger() instead of trigger()

* The latest react-window-splitter fixes the ESM issues

* Set sensible defaults for the run page

* Deployments page

* Test page

* Schedules page

* Latest version of react-window-splitter (0.2.5)

* Updated to the latest version: react-window-splitter

* Callout if runs don’t start right away now has some top margin

* Small padding fix

* styled the handle focus state

* Added isStaticAtRest prop to resizable panel

* Updated resizable storybook

* Inline code blocks behave nicer when text wraps

* Added ElectricSQL to docker-compose, available on 3060

* Extracted some logic out of the eventRepository for getting a trace. This will be used on the frontend

* Use the new util

* More restructuring ready to use the trace summary from the frontend

* Using ElectricSQL for the run page data

* Min size for resizable panel on test page

* Don’t load the trace in the RunPresenter anymore

* Fix for the resizable panels on the run page

* Added overflow hidden to the panel group

* min size for the test page left hand panel

* Updated to latest window-splitter version

* Removed unused const

* One fix for client-server mismatch

* Slight improvement in the loading state

* Restructured the page so the loading is better

* Improvement to the loading states

* Improved the loading behaviour with the inspector

* WIP on auth, having problems with it

* Upgrade Remix to 2.9.1 (same as PR #1096)

* Switched structure around again so we only call the useTrace hook from the client

* Added auth to the sync

* Overscan more rows in the tree view

* Fix for TS error

* Remove duplicate import

* Revert "Upgrade Remix to 2.9.1 (same as PR #1096)"

This reverts commit e63ee9e924.

* save cookie only when id is used

* Deployment table now scrolls

* removed imports

* A lot of changes to make the inspector live too… WIP

* More major overhauls to get the synced version of the run page working…

* If a span is completed show that

* Set the debounce much lower for selecting the span view

* Load the details run inspector data on demand

* Delete the SpanPresenter

* Use the async payload because it deals with superjson

* Fixed weird merge conflict

* Share some inspector timeline components

* A couple of layout tweaks

* Improved the run inspector loading states

* Fix for paragaph errors

* Fix for focusing on a span

* Undefined typre for useSyncedShape

* ELECTRIC_ORIGIN env var doesn’t have a default, added to the examples

* Updated @electric-sql/react package to the latest

* Fix the timeline duration stretching

* Added some better error handling for the electric sync

* More logging

* Better error when there are bad responses

* Turn off resizable snapshots, there’s a bug

* Added getSpan back

* Added SpanPresenter back

* Updated to the new Electric hooks package

* Made a copy so we have the old run page and the new electric one

* Put the main eventRepository back for now

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: James Ritchie <james@trigger.dev>
Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Matt Aitken
2024-09-13 11:45:24 +01:00
committed by GitHub
parent a66db33dee
commit 9a7ad926aa
35 changed files with 5328 additions and 294 deletions
+1
View File
@@ -10,6 +10,7 @@ DIRECT_URL=${DATABASE_URL}
REMIX_APP_PORT=3030
APP_ENV=development
APP_ORIGIN=http://localhost:3030
ELECTRIC_ORIGIN=http://localhost:3060
NODE_ENV=development
V3_ENABLED=true
@@ -1,7 +1,7 @@
import { Clipboard, ClipboardCheck } from "lucide-react";
import type { Language, PrismTheme } from "prism-react-renderer";
import { Highlight, Prism } from "prism-react-renderer";
import { forwardRef, useCallback, useState } from "react";
import { forwardRef, ReactNode, useCallback, useState } from "react";
import { cn } from "~/utils/cn";
import { Paragraph } from "../primitives/Paragraph";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
@@ -385,7 +385,7 @@ function Chrome({ title }: { title?: string }) {
);
}
export function TitleRow({ title }: { title: string }) {
export function TitleRow({ title }: { title: ReactNode }) {
return (
<div className="flex items-center justify-between px-3">
<Paragraph variant="small/bright" className="w-full border-b border-grid-dimmed py-2">
@@ -1,7 +1,7 @@
import { cn } from "~/utils/cn";
const inlineCode =
"px-1 py-0.5 rounded border border-charcoal-700 bg-charcoal-800 text-charcoal-200 font-mono";
"px-1 py-0.5 rounded border border-charcoal-700 bg-charcoal-800 text-charcoal-200 font-mono text-wrap";
const variants = {
"extra-extra-small": "text-xxs",
@@ -1,32 +1,35 @@
"use client";
import * as ResizablePrimitive from "react-resizable-panels";
import React from "react";
import { PanelGroup, Panel, PanelResizer } from "react-window-splitter";
import { cn } from "~/utils/cn";
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn("flex w-full data-[panel-group-direction=vertical]:flex-col", className)}
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof PanelGroup>) => (
<PanelGroup
className={cn(
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
className
)}
autosaveStrategy={props.autosaveId ? "cookie" : undefined}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizablePanel = Panel;
const ResizableHandle = ({
withHandle,
withHandle = true,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof PanelResizer> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
<PanelResizer
className={cn(
"focus-visible:ring-ring group relative flex w-0.75 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"focus-visible:ring-ring group relative flex w-0.75 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary/50 focus-visible:ring-offset-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
size="3px"
{...props}
>
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500" />
@@ -37,7 +40,9 @@ const ResizableHandle = ({
))}
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</PanelResizer>
);
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
export type ResizableSnapshot = React.ComponentProps<typeof PanelGroup>["snapshot"];
@@ -241,7 +241,7 @@ export function useTree<TData, TFilterValue>({
index,
});
},
overscan: 20,
overscan: 50,
});
const scrollToNodeFn = useCallback(
@@ -0,0 +1,63 @@
import { ReactNode } from "react";
import { cn } from "~/utils/cn";
type RunTimelineItemProps = {
title: ReactNode;
subtitle?: ReactNode;
state: "complete" | "error";
};
export function RunTimelineEvent({ title, subtitle, state }: RunTimelineItemProps) {
return (
<div className="grid h-5 grid-cols-[1.125rem_1fr] text-sm">
<div className="flex items-center justify-center">
<div
className={cn(
"size-[0.3125rem] rounded-full",
state === "complete" ? "bg-success" : "bg-error"
)}
></div>
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-text-bright">{title}</span>
{subtitle ? <span className="text-xs text-text-dimmed">{subtitle}</span> : null}
</div>
</div>
);
}
type RunTimelineLineProps = {
title: ReactNode;
state: "complete" | "delayed" | "inprogress";
};
export function RunTimelineLine({ title, state }: RunTimelineLineProps) {
return (
<div className="grid h-6 grid-cols-[1.125rem_1fr] text-xs">
<div className="flex items-stretch justify-center">
<div
className={cn(
"w-px",
state === "complete" ? "bg-success" : state === "delayed" ? "bg-text-dimmed" : ""
)}
style={
state === "inprogress"
? {
width: "1px",
height: "100%",
background:
"repeating-linear-gradient(to bottom, #3B82F6 0%, #3B82F6 50%, transparent 50%, transparent 100%)",
backgroundSize: "1px 6px",
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
}
: undefined
}
></div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-text-dimmed">{title}</span>
</div>
</div>
);
}
@@ -0,0 +1,650 @@
import { CheckIcon, ClockIcon, CloudArrowDownIcon, QueueListIcon } from "@heroicons/react/20/solid";
import { Link } from "@remix-run/react";
import { formatDuration, formatDurationMilliseconds, TaskRunError } from "@trigger.dev/core/v3";
import { useEffect } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { CodeBlock, TitleRow } from "~/components/code/CodeBlock";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { RawRun } from "~/hooks/useSyncTraceRuns";
import { loader } from "~/routes/resources.runs.$runParam";
import { cn } from "~/utils/cn";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import {
v3RunDownloadLogsPath,
v3RunPath,
v3RunSpanPath,
v3RunsPath,
v3SchedulePath,
v3TraceSpanPath,
} from "~/utils/pathBuilder";
import { TraceSpan } from "~/utils/taskEvent";
import { SpanLink } from "~/v3/eventRepository.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline";
import { RunTag } from "./RunTag";
import { TaskRunStatusCombo } from "./TaskRunStatus";
/**
* The RunInspector displays live information about a run.
* Most of that data comes in as params but for some we need to fetch it.
*/
export function RunInspector({
run,
span,
runParam,
closePanel,
}: {
run?: RawRun;
span?: TraceSpan;
runParam: string;
closePanel?: () => void;
}) {
const organization = useOrganization();
const project = useProject();
const { value, replace } = useSearchParams();
const tab = value("tab");
const fetcher = useTypedFetcher<typeof loader>();
useEffect(() => {
if (run?.friendlyId === undefined) return;
fetcher.load(`/resources/runs/${run.friendlyId}`);
}, [run?.friendlyId, run?.updatedAt]);
if (!run) {
return (
<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 overflow-x-hidden">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon name={"task"} spanName="" className="h-4 min-h-4 w-4 min-w-4" />
<Header2 className={cn("overflow-x-hidden text-blue-500")}>
<span className="truncate"></span>
</Header2>
</div>
{closePanel && (
<Button
onClick={closePanel}
variant="minimal/medium"
LeadingIcon={ExitIcon}
shortcut={{ key: "esc" }}
/>
)}
</div>
<div />
</div>
);
}
const environment = project.environments.find((e) => e.id === run.runtimeEnvironmentId);
const clientRunData = fetcher.state === "idle" ? fetcher.data : undefined;
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon
name={"task"}
spanName={run.taskIdentifier}
className="h-4 min-h-4 w-4 min-w-4"
/>
<Header2 className={cn("overflow-x-hidden text-blue-500")}>
<span className="truncate">{run.taskIdentifier}</span>
</Header2>
</div>
{closePanel && (
<Button
onClick={closePanel}
variant="minimal/medium"
LeadingIcon={ExitIcon}
shortcut={{ key: "esc" }}
/>
)}
</div>
<div className="px-3">
<TabContainer>
<TabButton
isActive={!tab || tab === "overview"}
layoutId="span-run"
onClick={() => {
replace({ tab: "overview" });
}}
shortcut={{ key: "o" }}
>
Overview
</TabButton>
<TabButton
isActive={tab === "detail"}
layoutId="span-run"
onClick={() => {
replace({ tab: "detail" });
}}
shortcut={{ key: "d" }}
>
Detail
</TabButton>
<TabButton
isActive={tab === "context"}
layoutId="span-run"
onClick={() => {
replace({ tab: "context" });
}}
shortcut={{ key: "c" }}
>
Context
</TabButton>
</TabContainer>
</div>
<div className="overflow-y-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div>
{tab === "detail" ? (
<div className="flex flex-col gap-4 py-3">
<Property.Table>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
{run ? <TaskRunStatusCombo status={run.status} /> : <PropertyLoading />}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<SimpleTooltip
button={
<TextLink
to={v3RunsPath(organization, project, {
tasks: [run.taskIdentifier],
})}
>
{run.taskIdentifier}
</TextLink>
}
content={`Filter runs by ${run.taskIdentifier}`}
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{clientRunData ? (
clientRunData?.version ? (
clientRunData.version
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)
) : (
<PropertyLoading />
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>SDK version</Property.Label>
<Property.Value>
{clientRunData ? (
clientRunData?.sdkVersion ? (
clientRunData.sdkVersion
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)
) : (
<PropertyLoading />
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Test run</Property.Label>
<Property.Value>
{run.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : ""}
</Property.Value>
</Property.Item>
{environment && (
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentLabel environment={environment} />
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Schedule</Property.Label>
<Property.Value>
{clientRunData ? (
clientRunData.schedule ? (
<div>
<div className="flex items-center gap-1">
<span className="font-mono">
{clientRunData.schedule.generatorExpression}
</span>
<span>({clientRunData.schedule.timezone})</span>
</div>
<SimpleTooltip
button={
<TextLink
to={v3SchedulePath(organization, project, clientRunData.schedule)}
>
{clientRunData.schedule.description}
</TextLink>
}
content={`Go to schedule ${clientRunData.schedule.friendlyId}`}
/>
</div>
) : (
"No schedule"
)
) : (
<PropertyLoading />
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue</Property.Label>
<Property.Value>
{clientRunData ? (
<>
<div>Name: {clientRunData.queue.name}</div>
<div>
Concurrency key:{" "}
{clientRunData.queue.concurrencyKey
? clientRunData.queue.concurrencyKey
: ""}
</div>
</>
) : (
<PropertyLoading />
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Time to live (TTL)</Property.Label>
<Property.Value>{run.ttl ?? ""}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
{clientRunData ? (
clientRunData.tags.length === 0 ? (
""
) : (
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
{clientRunData.tags.map((tag) => (
<SimpleTooltip
key={tag}
button={
<Link to={v3RunsPath(organization, project, { tags: [tag] })}>
<RunTag tag={tag} />
</Link>
}
content={`Filter runs by ${tag}`}
/>
))}
</div>
)
) : (
<PropertyLoading />
)}
</Property.Value>
</Property.Item>
{span?.links && span.links.length > 0 && (
<Property.Item>
<Property.Label>Links</Property.Label>
<Property.Value>
<div className="space-y-1">
{span.links.map((link, index) => (
<SpanLinkElement key={index} link={link} />
))}
</div>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Run invocation cost</Property.Label>
<Property.Value>
{run.baseCostInCents > 0
? formatCurrencyAccurate(run.baseCostInCents / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Compute cost</Property.Label>
<Property.Value>
{run.costInCents > 0 ? formatCurrencyAccurate(run.costInCents / 100) : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total cost</Property.Label>
<Property.Value>
{run.costInCents > 0
? formatCurrencyAccurate((run.baseCostInCents + run.costInCents) / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Usage duration</Property.Label>
<Property.Value>
{run.usageDurationMs > 0
? formatDurationMilliseconds(run.usageDurationMs, { style: "short" })
: ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
) : tab === "context" ? (
<div className="flex flex-col gap-4 py-3">
{clientRunData ? (
<CodeBlock code={clientRunData.context} showLineNumbers={false} />
) : (
<div className="relative overflow-hidden rounded-md border border-grid-bright">
<TitleRow
title={
<span className="flex items-center justify-between gap-2 text-sm text-text-bright">
Context loading
<PropertyLoading />
</span>
}
/>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-4 pt-3">
<div className="border-b border-grid-bright pb-3">
<TaskRunStatusCombo status={run.status} className="text-sm" />
</div>
<RunTimeline run={run} />
<>
{clientRunData ? (
<>
{clientRunData.payload !== undefined && (
<PacketDisplay
data={clientRunData.payload}
dataType={clientRunData.payloadType}
title="Payload"
/>
)}
{clientRunData.error !== undefined ? (
<RunError error={clientRunData.error} />
) : clientRunData.output !== undefined ? (
<PacketDisplay
data={clientRunData.output}
dataType={clientRunData.outputType}
title="Output"
/>
) : null}
</>
) : (
<div className="relative overflow-hidden rounded-md border border-grid-bright">
<TitleRow
title={
<span className="flex items-center justify-between gap-2 text-sm text-text-bright">
Payload loading
<PropertyLoading />
</span>
}
/>
</div>
)}
</>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<div className="flex items-center gap-4">
{run.friendlyId !== runParam && (
<LinkButton
to={v3RunSpanPath(
organization,
project,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
variant="minimal/medium"
LeadingIcon={QueueListIcon}
shortcut={{ key: "f" }}
>
Focus on run
</LinkButton>
)}
</div>
<div className="flex items-center gap-4">
{run.logsDeletedAt === null ? (
<LinkButton
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
LeadingIcon={CloudArrowDownIcon}
variant="tertiary/medium"
target="_blank"
download
>
Download logs
</LinkButton>
) : null}
</div>
</div>
</div>
);
}
function PropertyLoading() {
return <Spinner className="mt-1 size-4" color="muted" />;
}
function RunTimeline({ run }: { run: RawRun }) {
const createdAt = new Date(run.createdAt);
const startedAt = run.startedAt ? new Date(run.startedAt) : null;
const delayUntil = run.delayUntil ? new Date(run.delayUntil) : null;
const expiredAt = run.expiredAt ? new Date(run.expiredAt) : null;
const updatedAt = new Date(run.updatedAt);
const isFinished = isFinalRunStatus(run.status);
return (
<div className="min-w-fit max-w-80">
<RunTimelineEvent
title="Triggered"
subtitle={<DateTimeAccurate date={run.createdAt} />}
state="complete"
/>
{delayUntil && !expiredAt ? (
<RunTimelineLine
title={
run.startedAt ? (
<>{formatDuration(createdAt, delayUntil)} delay</>
) : (
<span className="flex items-center gap-1">
<ClockIcon className="size-4" />
<span>
Delayed until <DateTime date={delayUntil} /> {run.ttl && <>(TTL {run.ttl})</>}
</span>
</span>
)
}
state={run.startedAt ? "complete" : "delayed"}
/>
) : startedAt ? (
<RunTimelineLine title={formatDuration(createdAt, startedAt)} state={"complete"} />
) : (
<RunTimelineLine
title={
<>
<LiveTimer startTime={createdAt} endTime={startedAt ?? expiredAt ?? undefined} />{" "}
{run.ttl && <>(TTL {run.ttl})</>}
</>
}
state={run.startedAt || run.expiredAt ? "complete" : "inprogress"}
/>
)}
{expiredAt ? (
<RunTimelineEvent
title="Expired"
subtitle={<DateTimeAccurate date={expiredAt} />}
state="error"
/>
) : startedAt ? (
<>
<RunTimelineEvent
title="Started"
subtitle={<DateTimeAccurate date={startedAt} />}
state="complete"
/>
{isFinished ? (
<>
<RunTimelineLine title={formatDuration(startedAt, updatedAt)} state={"complete"} />
<RunTimelineEvent
title="Finished"
subtitle={<DateTimeAccurate date={updatedAt} />}
state="complete"
/>
</>
) : (
<RunTimelineLine
title={
<span className="flex items-center gap-1">
<Spinner className="size-4" />
<span>
<LiveTimer startTime={startedAt} />
</span>
</span>
}
state={"inprogress"}
/>
)}
</>
) : null}
</div>
);
}
function RunError({ error }: { error: TaskRunError }) {
switch (error.type) {
case "STRING_ERROR":
case "CUSTOM_ERROR": {
return (
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
<CodeBlock
showCopyButton={false}
showLineNumbers={false}
code={error.raw}
maxLines={20}
/>
</div>
);
}
case "BUILT_IN_ERROR":
case "INTERNAL_ERROR": {
const name = "name" in error ? error.name : error.code;
return (
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
<Header3 className="text-rose-500">{name}</Header3>
{error.message && <Callout variant="error">{error.message}</Callout>}
{error.stackTrace && (
<CodeBlock
showCopyButton={false}
showLineNumbers={false}
code={error.stackTrace}
maxLines={20}
/>
)}
</div>
);
}
}
}
function PacketDisplay({
data,
dataType,
title,
}: {
data: string;
dataType: string;
title: string;
}) {
switch (dataType) {
case "application/store": {
return (
<div className="flex flex-col">
<Paragraph variant="base/bright" className="w-full border-b border-grid-dimmed py-2.5">
{title}
</Paragraph>
<LinkButton LeadingIcon={CloudArrowDownIcon} to={data} variant="tertiary/medium" download>
Download
</LinkButton>
</div>
);
}
case "text/plain": {
return (
<CodeBlock
language="markdown"
rowTitle={title}
code={data}
maxLines={20}
showLineNumbers={false}
/>
);
}
default: {
return (
<CodeBlock
language="json"
rowTitle={title}
code={data}
maxLines={20}
showLineNumbers={false}
/>
);
}
}
}
function SpanLinkElement({ link }: { link: SpanLink }) {
const organization = useOrganization();
const project = useProject();
switch (link.type) {
case "run": {
return (
<TextLink to={v3RunPath(organization, project, { friendlyId: link.runId })}>
{link.title}
</TextLink>
);
}
case "span": {
return (
<TextLink to={v3TraceSpanPath(organization, project, link.traceId, link.spanId)}>
{link.title}
</TextLink>
);
}
}
return null;
}
@@ -0,0 +1,313 @@
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Button } from "~/components/primitives/Buttons";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import * as Property from "~/components/primitives/PropertyTable";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptStatus";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { cn } from "~/utils/cn";
import { v3RunPath, v3RunsPath, v3TraceSpanPath } from "~/utils/pathBuilder";
import { TraceSpan } from "~/utils/taskEvent";
import { SpanLink } from "~/v3/eventRepository.server";
import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline";
import { Spinner } from "~/components/primitives/Spinner";
import { LiveTimer } from "./LiveTimer";
import { formatDuration, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
export function SpanInspector({
span,
runParam,
closePanel,
}: {
span?: TraceSpan;
runParam?: string;
closePanel?: () => void;
}) {
const organization = useOrganization();
const project = useProject();
const { value, replace } = useSearchParams();
let tab = value("tab");
if (tab === "context") {
tab = "overview";
}
if (span === undefined) {
return null;
}
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon
name={span.style?.icon}
spanName={span.message}
className="h-4 min-h-4 w-4 min-w-4"
/>
<Header2 className={cn("overflow-x-hidden")}>
<SpanTitle {...span} size="large" />
</Header2>
</div>
{runParam && closePanel && (
<Button
onClick={closePanel}
variant="minimal/medium"
LeadingIcon={ExitIcon}
shortcut={{ key: "esc" }}
/>
)}
</div>
<div className="px-3">
<TabContainer>
<TabButton
isActive={!tab || tab === "overview"}
layoutId="span-span"
onClick={() => {
replace({ tab: "overview" });
}}
shortcut={{ key: "o" }}
>
Overview
</TabButton>
<TabButton
isActive={tab === "detail"}
layoutId="span-span"
onClick={() => {
replace({ tab: "detail" });
}}
shortcut={{ key: "d" }}
>
Detail
</TabButton>
</TabContainer>
</div>
<div className="overflow-y-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div>
{tab === "detail" ? (
<div className="flex flex-col gap-4 pt-3">
<Property.Table>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<TaskRunAttemptStatusCombo
status={
span.isCancelled
? "CANCELED"
: span.isError
? "FAILED"
: span.isPartial
? "EXECUTING"
: "COMPLETED"
}
className="text-sm"
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<SimpleTooltip
button={
<TextLink
to={v3RunsPath(organization, project, { tasks: [span.taskSlug] })}
>
{span.taskSlug}
</TextLink>
}
content={`Filter runs by ${span.taskSlug}`}
/>
</Property.Value>
</Property.Item>
{span.idempotencyKey && (
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>{span.idempotencyKey}</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{span.workerVersion ? (
span.workerVersion
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)}
</Property.Value>
</Property.Item>
{span.links && span.links.length > 0 && (
<Property.Item>
<Property.Label>Links</Property.Label>
<Property.Value>
<div className="space-y-1">
{span.links.map((link, index) => (
<SpanLinkElement key={index} link={link} />
))}
</div>
</Property.Value>
</Property.Item>
)}
</Property.Table>
</div>
) : (
<div className="flex flex-col gap-4 pt-3">
{span.level === "TRACE" ? (
<>
<div className="border-b border-grid-bright pb-3">
<TaskRunAttemptStatusCombo
status={
span.isCancelled
? "CANCELED"
: span.isError
? "FAILED"
: span.isPartial
? "EXECUTING"
: "COMPLETED"
}
className="text-sm"
/>
</div>
<SpanTimeline
startTime={new Date(span.startTime)}
duration={span.duration}
inProgress={span.isPartial}
isError={span.isError}
/>
</>
) : (
<div className="min-w-fit max-w-80">
<RunTimelineEvent
title="Timestamp"
subtitle={<DateTimeAccurate date={span.startTime} />}
state="complete"
/>
</div>
)}
<Property.Table>
<Property.Item>
<Property.Label>Message</Property.Label>
<Property.Value>{span.message}</Property.Value>
</Property.Item>
{span.links && span.links.length > 0 && (
<Property.Item>
<Property.Label>Links</Property.Label>
<Property.Value>
<div className="space-y-1">
{span.links.map((link, index) => (
<SpanLinkElement key={index} link={link} />
))}
</div>
</Property.Value>
</Property.Item>
)}
</Property.Table>
{span.events !== undefined && <SpanEvents spanEvents={span.events} />}
{span.properties !== undefined && (
<CodeBlock
rowTitle="Properties"
code={span.properties}
maxLines={20}
showLineNumbers={false}
/>
)}
</div>
)}
</div>
</div>
</div>
);
}
type TimelineProps = {
startTime: Date;
duration: number;
inProgress: boolean;
isError: boolean;
};
export function SpanTimeline({ startTime, duration, inProgress, isError }: TimelineProps) {
const state = isError ? "error" : inProgress ? "pending" : "complete";
return (
<>
<div className="min-w-fit max-w-80">
<RunTimelineEvent
title="Started"
subtitle={<DateTimeAccurate date={startTime} />}
state="complete"
/>
{state === "pending" ? (
<RunTimelineLine
title={
<span className="flex items-center gap-1">
<Spinner className="size-4" />
<span>
<LiveTimer startTime={startTime} />
</span>
</span>
}
state={"inprogress"}
/>
) : (
<>
<RunTimelineLine
title={formatDuration(
startTime,
new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))
)}
state={"complete"}
/>
<RunTimelineEvent
title="Finished"
subtitle={
<DateTimeAccurate
date={new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))}
/>
}
state={isError ? "error" : "complete"}
/>
</>
)}
</div>
</>
);
}
function SpanLinkElement({ link }: { link: SpanLink }) {
const organization = useOrganization();
const project = useProject();
switch (link.type) {
case "run": {
return (
<TextLink to={v3RunPath(organization, project, { friendlyId: link.runId })}>
{link.title}
</TextLink>
);
}
case "span": {
return (
<TextLink to={v3TraceSpanPath(organization, project, link.traceId, link.spanId)}>
{link.title}
</TextLink>
);
}
}
return null;
}
@@ -2,7 +2,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { TaskEventStyle } from "@trigger.dev/core/v3";
import type { TaskEventLevel } from "@trigger.dev/database";
import { Fragment } from "react";
import { RunEvent } from "~/presenters/v3/RunPresenter.server";
import { cn } from "~/utils/cn";
type SpanTitleProps = {
@@ -107,9 +106,15 @@ function eventTextClassName(event: Pick<SpanTitleProps, "isError" | "style" | "l
}
}
export function eventBackgroundClassName(
event: Pick<RunEvent["data"], "isError" | "style" | "level" | "isPartial" | "isCancelled">
) {
type RunEvent = {
isError: boolean;
style: TaskEventStyle;
level: TaskEventLevel;
isPartial: boolean;
isCancelled: boolean;
};
export function eventBackgroundClassName(event: RunEvent) {
if (event.isError) {
return "bg-error";
}
+1
View File
@@ -31,6 +31,7 @@ const EnvironmentSchema = z.object({
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
ELECTRIC_ORIGIN: z.string(),
APP_ENV: z.string().default(process.env.NODE_ENV),
SERVICE_NAME: z.string().default("trigger.dev webapp"),
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
+16
View File
@@ -0,0 +1,16 @@
import { useSyncTrace } from "./useSyncTrace";
import { useSyncTraceRuns } from "./useSyncTraceRuns";
type Params = {
origin: string;
traceId: string;
};
export function useSyncRunPage({ origin, traceId }: Params) {
const { runs } = useSyncTraceRuns({ origin, traceId });
const { events } = useSyncTrace({ origin, traceId });
const isUpToDate = runs !== undefined && events !== undefined;
return { isUpToDate, runs, events };
}
+19
View File
@@ -0,0 +1,19 @@
import { useShape } from "@electric-sql/react";
import { TaskEvent } from "@trigger.dev/database";
import { createTraceTreeFromEvents } from "~/utils/taskEvent";
type Params = {
origin: string;
traceId: string;
};
export type Trace = ReturnType<typeof createTraceTreeFromEvents>;
export type TraceEvent = Trace["events"][number];
export function useSyncTrace({ origin, traceId }: Params) {
const { data, error, isError } = useShape({
url: `${origin}/sync/traces/${traceId}`,
});
return { error, isError, events: data ? (data as any as TaskEvent[]) : undefined };
}
+18
View File
@@ -0,0 +1,18 @@
import { Prettify } from "@trigger.dev/core";
import { TaskRun } from "@trigger.dev/database";
import { SyncedShapeData, useSyncedShape } from "./useSyncedShape";
type Params = {
origin: string;
traceId: string;
};
export type RawRun = Prettify<SyncedShapeData<TaskRun>>;
export function useSyncTraceRuns({ origin, traceId }: Params) {
const { data, error, isError } = useSyncedShape<TaskRun>({
url: `${origin}/sync/traces/runs/${traceId}`,
});
return { runs: data, error, isError };
}
+71
View File
@@ -0,0 +1,71 @@
import { useShape } from "@electric-sql/react";
export type ShapeInput = Parameters<typeof useShape>[0];
export type ShapeOutput<S> = {
error: Error | false;
isError: boolean;
data: S[] | undefined;
};
export type SyncedShapeData<T> = {
[K in keyof T]: T[K] extends Date
? string
: T[K] extends Date | null
? string | null
: T[K] extends BigInt
? number
: T[K] extends BigInt | null
? number | null
: T[K] extends object
? SyncedShapeData<T[K]>
: T[K];
};
export function useSyncedShape<S>(props: ShapeInput): ShapeOutput<SyncedShapeData<S>> {
const output = useShape(props) as any;
return {
error: output.error,
isError: output.isError,
data: output.data
? (transformInput(output.data as InputObject) as SyncedShapeData<S>[])
: undefined,
};
}
type InputObject = {
[key: string]: Value;
}[];
type Value =
| string
| number
| boolean
| bigint
| null
| Value[]
| {
[key: string]: Value;
};
function transformInput(input: InputObject): SyncedShapeData<Value> {
return input.map((value) => transformValue(value));
}
function transformValue(value: Value): Value {
if (Array.isArray(value)) {
return value.map(transformValue);
} else if (typeof value === "object" && value !== null) {
const result: { [key: string]: Value } = {};
for (const key in value) {
result[key] = transformValue(value[key]);
}
return result;
} else if (typeof value === "bigint") {
return Number(value);
} else {
return value;
}
}
@@ -3,9 +3,9 @@ import parse from "parse-duration";
import { type Direction } from "~/components/runs/RunStatuses";
import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { isCancellableRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
export type RunListOptions = {
userId?: string;
@@ -0,0 +1,87 @@
import { PrismaClient, prisma } from "~/db.server";
import { getUsername } from "~/utils/username";
import { isFinalRunStatus } from "~/v3/taskStatus";
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
export type Run = Result["run"];
export class RunPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
runFriendlyId,
}: {
userId: string;
projectSlug: string;
organizationSlug: string;
runFriendlyId: string;
}) {
const run = await this.#prismaClient.taskRun.findFirstOrThrow({
select: {
id: true,
number: true,
traceId: true,
spanId: true,
friendlyId: true,
status: true,
completedAt: true,
logsDeletedAt: true,
runtimeEnvironment: {
select: {
id: true,
type: true,
slug: true,
organizationId: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
friendlyId: runFriendlyId,
project: {
slug: projectSlug,
},
},
});
return {
run: {
id: run.id,
number: run.number,
friendlyId: run.friendlyId,
traceId: run.traceId,
spanId: run.spanId,
status: run.status,
isFinished: isFinalRunStatus(run.status),
completedAt: run.completedAt,
logsDeletedAt: run.logsDeletedAt,
environment: {
id: run.runtimeEnvironment.id,
organizationId: run.runtimeEnvironment.organizationId,
type: run.runtimeEnvironment.type,
slug: run.runtimeEnvironment.slug,
userId: run.runtimeEnvironment.orgMember?.user.id,
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
},
},
};
}
}
@@ -2,16 +2,12 @@ import {
ArrowPathIcon,
ArrowUturnLeftIcon,
BookOpenIcon,
CommandLineIcon,
ServerIcon,
ServerStackIcon,
} from "@heroicons/react/20/solid";
import { Outlet, useLocation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react/jsx-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
@@ -104,104 +100,108 @@ export default function Page() {
<PageTitle title="Deployments" />
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup direction="horizontal" className="h-full max-h-full">
<ResizablePanel order={1} minSize={20} defaultSize={60}>
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanel id="deployments-main" min="100px" className="max-h-full overflow-y-auto">
{hasDeployments ? (
<div className="flex flex-col gap-4 p-3">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Tasks</TableHeaderCell>
<TableHeaderCell>Deployed at</TableHeaderCell>
<TableHeaderCell>Deployed by</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deployments.length > 0 ? (
deployments.map((deployment) => {
const usernameForEnv =
user.id !== deployment.environment.userId
? deployment.environment.userName
: undefined;
const path = v3DeploymentPath(
organization,
project,
deployment,
currentPage
);
return (
<TableRow key={deployment.id} className="group">
<TableCell to={path}>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small">{deployment.shortCode}</Paragraph>
{deployment.label && (
<Badge variant="outline-rounded">{deployment.label}</Badge>
)}
</div>
</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={deployment.environment}
userName={usernameForEnv}
/>
</TableCell>
<TableCell to={path}>{deployment.version}</TableCell>
<TableCell to={path}>
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
/>
</TableCell>
<TableCell to={path}>
{deployment.tasksCount !== null ? deployment.tasksCount : ""}
</TableCell>
<TableCell to={path}>
{deployment.deployedAt ? (
<DateTime date={deployment.deployedAt} />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{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"
/>
<div className="max-h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="flex flex-col gap-4 p-3">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Tasks</TableHeaderCell>
<TableHeaderCell>Deployed at</TableHeaderCell>
<TableHeaderCell>Deployed by</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deployments.length > 0 ? (
deployments.map((deployment) => {
const usernameForEnv =
user.id !== deployment.environment.userId
? deployment.environment.userName
: undefined;
const path = v3DeploymentPath(
organization,
project,
deployment,
currentPage
);
return (
<TableRow key={deployment.id} className="group">
<TableCell to={path}>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small">
{deployment.deployedBy.name ??
deployment.deployedBy.displayName}
{deployment.shortCode}
</Paragraph>
{deployment.label && (
<Badge variant="outline-rounded">{deployment.label}</Badge>
)}
</div>
) : (
""
)}
</TableCell>
<DeploymentActionsCell deployment={deployment} path={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={6}>
<Paragraph variant="small" className="flex items-center justify-center">
No deploys match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
<div className="flex justify-end">
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={deployment.environment}
userName={usernameForEnv}
/>
</TableCell>
<TableCell to={path}>{deployment.version}</TableCell>
<TableCell to={path}>
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
/>
</TableCell>
<TableCell to={path}>
{deployment.tasksCount !== null ? deployment.tasksCount : ""}
</TableCell>
<TableCell to={path}>
{deployment.deployedAt ? (
<DateTime date={deployment.deployedAt} />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{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="extra-small">
{deployment.deployedBy.name ??
deployment.deployedBy.displayName}
</Paragraph>
</div>
) : (
""
)}
</TableCell>
<DeploymentActionsCell deployment={deployment} path={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={6}>
<Paragraph variant="small" className="flex items-center justify-center">
No deploys match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
<div className="flex justify-end">
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
</div>
) : (
@@ -211,8 +211,8 @@ export default function Page() {
{deploymentParam && (
<>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={20} defaultSize={40}>
<ResizableHandle id="deployments-handle" />
<ResizablePanel id="deployments-inspector" min="225px" max="500px">
<Outlet />
</ResizablePanel>
</>
@@ -66,7 +66,6 @@ import { useReplaceLocation } from "~/hooks/useReplaceLocation";
import { Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useUser } from "~/hooks/useUser";
import { 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";
@@ -80,6 +79,37 @@ import {
} from "~/utils/pathBuilder";
import { SpanView } from "../resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
const resizableSettings = {
parent: {
autosaveId: "panel-run-parent",
handleId: "parent-handle",
main: {
id: "run",
min: "100px" as const,
},
inspector: {
id: "inspector",
default: "430px" as const,
min: "50px" as const,
},
},
tree: {
autosaveId: "panel-run-tree",
handleId: "tree-handle",
tree: {
id: "tree",
default: "50%" as const,
min: "50px" as const,
},
timeline: {
id: "timeline",
default: "50%" as const,
min: "50px" as const,
},
},
};
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
@@ -96,13 +126,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
//resizable settings
const resizeSettings = await getResizableRunSettings(request);
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
return json({
run: result.run,
trace: result.trace,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
resizeSettings,
resizable: {
parent,
tree,
},
});
};
@@ -114,8 +148,7 @@ function getSpanId(location: Location<any>): string | undefined {
}
export default function Page() {
const { run, trace, resizeSettings, maximumLiveReloadingSetting } =
useLoaderData<typeof loader>();
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
const user = useUser();
const organization = useOrganization();
const project = useProject();
@@ -208,14 +241,14 @@ export default function Page() {
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizeSettings={resizeSettings}
resizable={resizable}
/>
) : (
<NoLogsView
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizeSettings={resizeSettings}
resizable={resizable}
/>
)}
</PageBody>
@@ -223,7 +256,7 @@ export default function Page() {
);
}
function TraceView({ run, trace, maximumLiveReloadingSetting, resizeSettings }: LoaderData) {
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
const organization = useOrganization();
const project = useProject();
const { location, replaceSearchParam } = useReplaceLocation();
@@ -255,15 +288,14 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizeSettings }:
return (
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
<ResizablePanelGroup
direction="horizontal"
autosaveId={resizableSettings.parent.autosaveId}
// snapshot={resizable.parent}
className="h-full max-h-full"
onLayout={(layout) => {
if (layout.length !== 2) return;
if (!selectedSpanId) return;
setResizableRunSettings(document, layout);
}}
>
<ResizablePanel order={1} minSize={30} defaultSize={resizeSettings.layout?.[0] ?? 70}>
<ResizablePanel
id={resizableSettings.parent.main.id}
min={resizableSettings.parent.main.min}
>
<TasksTreeView
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
@@ -286,9 +318,15 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizeSettings }:
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle id={resizableSettings.parent.handleId} />
{selectedSpanId && (
<ResizablePanel order={2} minSize={25} defaultSize={resizeSettings.layout?.[1] ?? 30}>
<ResizablePanel
id={resizableSettings.parent.inspector.id}
default={resizableSettings.parent.inspector.default}
min={resizableSettings.parent.inspector.min}
isStaticAtRest
>
{" "}
<SpanView
runParam={run.friendlyId}
spanId={selectedSpanId}
@@ -301,7 +339,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizeSettings }:
);
}
function NoLogsView({ run, resizeSettings }: LoaderData) {
function NoLogsView({ run, resizable }: LoaderData) {
const plan = useCurrentPlan();
const organization = useOrganization();
@@ -320,14 +358,14 @@ function NoLogsView({ run, resizeSettings }: LoaderData) {
return (
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
<ResizablePanelGroup
direction="horizontal"
autosaveId={resizableSettings.parent.autosaveId}
// snapshot={resizable.parent}
className="h-full max-h-full"
onLayout={(layout) => {
if (layout.length !== 2) return;
setResizableRunSettings(document, layout);
}}
>
<ResizablePanel order={1} minSize={30} defaultSize={resizeSettings.layout?.[0] ?? 70}>
<ResizablePanel
id={resizableSettings.parent.main.id}
min={resizableSettings.parent.main.min}
>
<div className="grid h-full place-items-center">
{daysSinceCompleted === undefined ? (
<InfoPanel variant="info" icon={InformationCircleIcon} title="We delete old logs">
@@ -375,8 +413,14 @@ function NoLogsView({ run, resizeSettings }: LoaderData) {
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={25} defaultSize={resizeSettings.layout?.[1] ?? 30}>
<ResizableHandle id={resizableSettings.parent.handleId} />
<ResizablePanel
id={resizableSettings.parent.inspector.id}
default={resizableSettings.parent.inspector.default}
min={resizableSettings.parent.inspector.min}
isStaticAtRest
>
{" "}
<SpanView runParam={run.friendlyId} spanId={run.spanId} />
</ResizablePanel>
</ResizablePanelGroup>
@@ -464,15 +508,14 @@ function TasksTreeView({
/>
</div>
</div>
<ResizablePanelGroup
direction="horizontal"
onLayout={(layout) => {
if (layout.length !== 2) return;
setResizableRunSettings(document, layout);
}}
>
<ResizablePanelGroup autosaveId={resizableSettings.tree.autosaveId}>
{/* Tree list */}
<ResizablePanel order={1} minSize={20} defaultSize={50} className="pl-3">
<ResizablePanel
id={resizableSettings.tree.tree.id}
default={resizableSettings.tree.tree.default}
min={resizableSettings.tree.tree.min}
className="pl-3"
>
<div className="grid h-full grid-rows-[2rem_1fr] overflow-hidden">
<div className="flex items-center pr-2">
{parentRunFriendlyId ? (
@@ -578,9 +621,13 @@ function TasksTreeView({
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle id={resizableSettings.tree.handleId} />
{/* Timeline */}
<ResizablePanel order={2} minSize={20} defaultSize={50}>
<ResizablePanel
id={resizableSettings.tree.timeline.id}
default={resizableSettings.tree.timeline.default}
min={resizableSettings.tree.timeline.min}
>
<TimelineView
totalDuration={totalDuration}
scale={scale}
@@ -177,8 +177,8 @@ export default function Page() {
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup direction="horizontal" className="h-full max-h-full">
<ResizablePanel order={1} minSize={20} defaultSize={60}>
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanel id="schedules-main" min={"100px"}>
<div className="max-h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
{possibleTasks.length === 0 ? (
<CreateScheduledTaskInstructions />
@@ -250,8 +250,8 @@ export default function Page() {
</ResizablePanel>
{(isShowingNewPane || isShowingSchedule) && (
<>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={20} defaultSize={40}>
<ResizableHandle id="schedules-handle" />
<ResizablePanel id="schedules-inspector" min="100px" default="500px">
<Outlet />
</ResizablePanel>
</>
@@ -180,8 +180,8 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
onSubmit={(e) => submitForm(e)}
>
<input type="hidden" name="triggerSource" value={"STANDARD"} />
<ResizablePanelGroup direction="horizontal">
<ResizablePanel order={1} minSize={30} defaultSize={60}>
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel id="test-task-main" min="100px" default="60%">
<div className="h-full bg-charcoal-900">
<JSONEditor
defaultValue={defaultJson}
@@ -207,8 +207,8 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={20} defaultSize={40}>
<ResizableHandle id="test-task-handle" />
<ResizablePanel id="test-task-inspector" min="100px">
<RecentPayloads
runs={runs}
selectedId={selectedCodeSampleId}
@@ -308,8 +308,8 @@ function ScheduledTaskForm({
{...conform.input(environmentId, { type: "hidden" })}
value={task.environment.id}
/>
<ResizablePanelGroup direction="horizontal">
<ResizablePanel order={1} minSize={30} defaultSize={60}>
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel id="test-task-main" min="100px" default="60%">
<div className="p-3">
<Fieldset>
<InputGroup>
@@ -402,8 +402,8 @@ function ScheduledTaskForm({
</Fieldset>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={20} defaultSize={40}>
<ResizableHandle id="test-task-handle" />
<ResizablePanel id="test-task-inspector" min="100px">
<RecentPayloads
runs={runs}
selectedId={selectedCodeSampleId}
@@ -85,8 +85,8 @@ export default function Page() {
</NavBar>
<PageBody scrollable={false}>
<div className={cn("grid h-full max-h-full grid-cols-1")}>
<ResizablePanelGroup direction="horizontal" className="h-full max-h-full">
<ResizablePanel order={1} minSize={20} defaultSize={30}>
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanel id="test-selector" min="225px" default="30%">
<div className="grid h-full max-h-full grid-rows-[5.625rem_1fr] overflow-hidden">
<div className="mx-3 flex flex-col gap-1 border-b border-grid-dimmed">
<div className="flex h-10 items-center">
@@ -117,8 +117,8 @@ export default function Page() {
<Spinner />
</div>
) : hasSelectedEnvironment ? (
<div className="grid grid-rows-[2.5rem_1fr] overflow-hidden">
<div className="mx-3 flex items-center border-b border-grid-dimmed">
<div className="grid grid-rows-[2rem_1fr] overflow-hidden">
<div className="mx-3 flex items-end">
<Header2>Select a task</Header2>
</div>
{!rest.tasks?.length ? (
@@ -135,8 +135,8 @@ export default function Page() {
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={30} defaultSize={70}>
<ResizableHandle id="test-handle" />
<ResizablePanel id="test-main" min="225px">
<Outlet key={taskParam} />
</ResizablePanel>
</ResizablePanelGroup>
@@ -157,7 +157,7 @@ function TaskSelector({
return (
<div className="divide-y divide-charcoal-800 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="px-2 pb-2">
<div className="p-2">
<Input
placeholder="Search tasks"
variant="medium"
@@ -2,15 +2,11 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";
import {
PreparedEvent,
RunPreparedEvent,
eventRepository,
getDateFromNanoseconds,
} from "~/v3/eventRepository.server";
import { PreparedEvent, RunPreparedEvent, eventRepository } from "~/v3/eventRepository.server";
import { createGzip } from "zlib";
import { Readable } from "stream";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations";
import { getDateFromNanoseconds } from "~/utils/taskEvent";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@@ -0,0 +1,240 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { MachinePresetName, prettyPrintPacket, TaskRunError } from "@trigger.dev/core/v3";
import { typedjson, UseDataFunctionReturn } from "remix-typedjson";
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { $replica } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";
import { machinePresetFromName } from "~/v3/machinePresets.server";
import { FINAL_ATTEMPT_STATUSES, isFinalRunStatus } from "~/v3/taskStatus";
export type RunInspectorData = UseDataFunctionReturn<typeof loader>;
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const parsedParams = v3RunParamsSchema.pick({ runParam: true }).parse(params);
const run = await $replica.taskRun.findFirst({
select: {
id: true,
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,
completedAt: true,
logsDeletedAt: 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,
project: {
include: {
organization: true,
},
},
lockedBy: {
select: {
filePath: true,
exportName: true,
},
},
},
where: {
friendlyId: parsedParams.runParam,
project: {
organization: {
members: {
some: {
userId,
},
},
},
},
},
});
if (!run) {
throw new Response("Not found", { status: 404 });
}
const isFinished = isFinalRunStatus(run.status);
const finishedAttempt = isFinished
? await $replica.taskRunAttempt.findFirst({
select: {
output: true,
outputType: true,
error: true,
},
where: {
status: { in: FINAL_ATTEMPT_STATUSES },
taskRunId: run.id,
},
orderBy: {
createdAt: "desc",
},
})
: null;
const output =
finishedAttempt === null
? 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;
let error: TaskRunError | undefined = undefined;
if (finishedAttempt?.error) {
const result = TaskRunError.safeParse(finishedAttempt.error);
if (result.success) {
error = result.data;
} else {
error = {
type: "CUSTOM_ERROR",
raw: JSON.stringify(finishedAttempt.error),
};
}
}
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 typedjson({
friendlyId: run.friendlyId,
status: run.status,
createdAt: run.createdAt,
startedAt: run.startedAt,
updatedAt: run.updatedAt,
delayUntil: run.delayUntil,
expiredAt: run.expiredAt,
completedAt: run.completedAt,
logsDeletedAt: run.logsDeletedAt,
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,
isRunning: RUNNING_STATUSES.includes(run.status),
payload,
payloadType: run.payloadType,
output,
outputType: finishedAttempt?.outputType ?? "application/json",
error,
context: JSON.stringify(context, null, 2),
});
};
@@ -0,0 +1,24 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
export default function Story() {
return (
<div className="h-full">
<ResizablePanelGroup>
<ResizablePanel id={"1-left"} className="bg-sky-500" />
<ResizableHandle id="1-split" />
<ResizablePanel
id={"1-right"}
default="100px"
min="100px"
max="600px"
className="bg-teal-500"
isStaticAtRest
/>
</ResizablePanelGroup>
</div>
);
}
@@ -72,6 +72,10 @@ const stories: Story[] = [
name: "Radio group",
slug: "radio-group",
},
{
name: "Resizable",
slug: "resizable",
},
{
name: "Segemented control",
slug: "segmented-control",
@@ -0,0 +1,69 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { getUserId } from "~/services/session.server";
import { longPollingFetch } from "~/utils/longPollingFetch";
export async function loader({ params, request }: LoaderFunctionArgs) {
try {
const userId = await getUserId(request);
logger.log(`/sync/traces/${params.traceId}`, { userId });
if (!userId) {
return new Response("No user found in cookie", { status: 401 });
}
const trace = await $replica.taskEvent.findFirst({
select: {
organizationId: true,
},
where: {
traceId: params.traceId,
},
});
if (!trace) {
return new Response("No trace found", { status: 404 });
}
const member = await $replica.orgMember.findFirst({
where: {
organizationId: trace.organizationId,
userId,
},
});
if (!member) {
return new Response("Not a member of this org", { status: 401 });
}
const url = new URL(request.url);
const originUrl = new URL(`${env.ELECTRIC_ORIGIN}/v1/shape/public."TaskEvent"`);
url.searchParams.forEach((value, key) => {
originUrl.searchParams.set(key, value);
});
originUrl.searchParams.set("where", `"traceId"='${params.traceId}'`);
const finalUrl = originUrl.toString();
logger.log("Fetching trace data", { url: finalUrl });
return longPollingFetch(finalUrl);
} catch (error) {
if (error instanceof Response) {
// Error responses from longPollingFetch
return error;
} else if (error instanceof TypeError) {
// Unexpected errors
logger.error("Unexpected error in loader:", { error: error.message });
return new Response("An unexpected error occurred", { status: 500 });
} else {
// Unknown errors
logger.error("Unknown error occurred in loader, not Error", { error: JSON.stringify(error) });
return new Response("An unknown error occurred", { status: 500 });
}
}
}
@@ -0,0 +1,79 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { getUserId } from "~/services/session.server";
import { longPollingFetch } from "~/utils/longPollingFetch";
const Params = z.object({
traceId: z.string(),
});
export async function loader({ params, request }: LoaderFunctionArgs) {
try {
const userId = await getUserId(request);
const { traceId } = Params.parse(params);
logger.log(`/sync/runs/${traceId}`, { userId });
if (!userId) {
return new Response("No user found in cookie", { status: 401 });
}
const run = await $replica.taskRun.findFirst({
select: {
project: {
select: {
organizationId: true,
},
},
},
where: {
traceId,
},
});
if (!run) {
return new Response("No run found", { status: 404 });
}
const member = await $replica.orgMember.findFirst({
where: {
organizationId: run.project.organizationId,
userId,
},
});
if (!member) {
return new Response("Not a member of this org", { status: 401 });
}
const url = new URL(request.url);
const originUrl = new URL(`${env.ELECTRIC_ORIGIN}/v1/shape/public."TaskRun"`);
url.searchParams.forEach((value, key) => {
originUrl.searchParams.set(key, value);
});
originUrl.searchParams.set("where", `"traceId"='${traceId}'`);
const finalUrl = originUrl.toString();
logger.log("Fetching trace runs data", { url: finalUrl });
return longPollingFetch(finalUrl);
} catch (error) {
if (error instanceof Response) {
// Error responses from longPollingFetch
return error;
} else if (error instanceof TypeError) {
// Unexpected errors
logger.error("Unexpected error in loader:", { error: error.message });
return new Response("An unexpected error occurred", { status: 500 });
} else {
// Unknown errors
logger.error("Unknown error occurred in loader, not Error", { error: JSON.stringify(error) });
return new Response("An unknown error occurred", { status: 500 });
}
}
}
@@ -0,0 +1,39 @@
import { parse } from "cookie";
import { type ResizableSnapshot } from "~/components/primitives/Resizable";
import { logger } from "./logger.server";
export async function getResizableSnapshot(
request: Request,
id: string
): Promise<ResizableSnapshot | undefined> {
try {
const cookieHeader = request.headers.get("Cookie");
if (!cookieHeader) {
return undefined;
}
const cookies = parse(cookieHeader);
const cookieValue = cookies[id];
if (cookieValue) {
try {
const parsedValue = JSON.parse(cookieValue) as any;
if (typeof parsedValue === "object" && "status" in parsedValue) {
return parsedValue as ResizableSnapshot;
}
} catch (parseError) {
logger.error("getResizableSnapshot() error parsing cookie value:", {
parseError,
cookieValue,
});
}
}
return undefined;
} catch (error) {
logger.error("getResizableSnapshot() error:", {
error,
});
return undefined;
}
}
@@ -1,31 +0,0 @@
import { z } from "zod";
const ResizableConfig = z
.object({ layout: z.array(z.number()).optional() })
.default({ layout: undefined });
type ResizableConfig = z.infer<typeof ResizableConfig>;
function getCookieValue(cookieHeader: string | null, cookieName: string): ResizableConfig {
const cookieValue = cookieHeader?.split(`${cookieName}=`)[1]?.split(";")[0];
if (!cookieValue) {
return { layout: undefined };
}
try {
const json = JSON.parse(cookieValue);
return ResizableConfig.parse(json);
} catch (e) {
return { layout: undefined };
}
}
//run page
const runResizableName = "resizable-panels:run";
export async function getResizableRunSettings(request: Request): Promise<ResizableConfig> {
const cookieHeader = request.headers.get("Cookie");
return getCookieValue(cookieHeader, runResizableName);
}
export async function setResizableRunSettings(document: Document, layout: number[]) {
document.cookie = `${runResizableName}=${JSON.stringify({ layout })}`;
}
+46
View File
@@ -0,0 +1,46 @@
// When proxying long-polling requests, content-encoding & content-length are added
// erroneously (saying the body is gzipped when it's not) so we'll just remove
// them to avoid content decoding errors in the browser.
//
import { logger } from "~/services/logger.server";
// Similar-ish problem to https://github.com/wintercg/fetch/issues/23
export async function longPollingFetch(url: string, options?: RequestInit) {
try {
let response = await fetch(url, options);
// Check if the response is ok (status in the range 200-299)
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP error! status: ${response.status}. ${body}`);
}
if (response.headers.get(`content-encoding`)) {
const headers = new Headers(response.headers);
headers.delete(`content-encoding`);
headers.delete(`content-length`);
response = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
return response;
} catch (error) {
if (error instanceof TypeError) {
// Network error or other fetch-related errors
logger.error("Network error:", { error: error.message });
throw new Response("Network error occurred", { status: 503 });
} else if (error instanceof Error) {
// HTTP errors or other known errors
logger.error("Fetch error:", { error: error.message });
throw new Response(error.message, { status: 500 });
} else {
// Unknown errors
logger.error("Unknown error occurred during fetch");
throw new Response("An unknown error occurred", { status: 500 });
}
}
}
+574
View File
@@ -0,0 +1,574 @@
import { Attributes, Link } from "@opentelemetry/api";
import {
correctErrorStackTrace,
ExceptionEventProperties,
isExceptionSpanEvent,
millisecondsToNanoseconds,
NULL_SENTINEL,
SemanticInternalAttributes,
SpanEvent,
SpanEvents,
SpanMessagingEvent,
TaskEventStyle,
unflattenAttributes,
} from "@trigger.dev/core/v3";
import { Prisma, TaskEvent } from "@trigger.dev/database";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
import type {
PreparedEvent,
SpanLink,
SpanSummary,
TraceSummary,
} from "~/v3/eventRepository.server";
export type TraceSpan = NonNullable<ReturnType<typeof createSpanFromEvents>>;
export function prepareTrace(events: TaskEvent[]): TraceSummary | undefined {
let preparedEvents: Array<PreparedEvent> = [];
let rootSpanId: string | undefined;
const eventsBySpanId = new Map<string, PreparedEvent>();
for (const event of events) {
preparedEvents.push(prepareEvent(event));
if (!rootSpanId && !event.parentId) {
rootSpanId = event.spanId;
}
}
for (const event of preparedEvents) {
const existingEvent = eventsBySpanId.get(event.spanId);
if (!existingEvent) {
eventsBySpanId.set(event.spanId, event);
continue;
}
if (event.isCancelled || !event.isPartial) {
eventsBySpanId.set(event.spanId, event);
}
}
preparedEvents = Array.from(eventsBySpanId.values());
const spansBySpanId = new Map<string, SpanSummary>();
const spans = preparedEvents.map((event) => {
const ancestorCancelled = isAncestorCancelled(eventsBySpanId, event.spanId);
const duration = calculateDurationIfAncestorIsCancelled(
eventsBySpanId,
event.spanId,
event.duration
);
const span = {
recordId: event.id,
id: event.spanId,
parentId: event.parentId ?? undefined,
runId: event.runId,
idempotencyKey: event.idempotencyKey,
data: {
message: event.message,
style: event.style,
duration,
isError: event.isError,
isPartial: ancestorCancelled ? false : event.isPartial,
isCancelled: event.isCancelled === true ? true : event.isPartial && ancestorCancelled,
startTime: getDateFromNanoseconds(event.startTime),
level: event.level,
events: event.events,
environmentType: event.environmentType,
},
};
spansBySpanId.set(event.spanId, span);
return span;
});
if (!rootSpanId) {
return;
}
const rootSpan = spansBySpanId.get(rootSpanId);
if (!rootSpan) {
return;
}
return {
rootSpan,
spans,
};
}
export function createTraceTreeFromEvents(traceSummary: TraceSummary, spanId: string) {
//this tree starts at the passed in span (hides parent elements if there are any)
const tree = createTreeFromFlatItems(traceSummary.spans, spanId);
//we need the start offset for each item, and the total duration of the entire tree
const treeRootStartTimeMs = tree ? tree?.data.startTime.getTime() : 0;
let totalDuration = tree?.data.duration ?? 0;
const events = tree
? flattenTree(tree).map((n) => {
const offset = millisecondsToNanoseconds(n.data.startTime.getTime() - treeRootStartTimeMs);
totalDuration = Math.max(totalDuration, offset + n.data.duration);
return {
...n,
data: {
...n.data,
//set partial nodes to null duration
duration: n.data.isPartial ? null : n.data.duration,
offset,
isRoot: n.id === traceSummary.rootSpan.id,
},
};
})
: [];
//total duration should be a minimum of 1ms
totalDuration = Math.max(totalDuration, millisecondsToNanoseconds(1));
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 {
rootSpanStatus,
events: events,
parentRunFriendlyId:
tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId,
duration: totalDuration,
rootStartedAt: tree?.data.startTime,
};
}
export function createSpanFromEvents(events: TaskEvent[], spanId: string) {
const spanEvent = getSpanEvent(events, spanId);
if (!spanEvent) {
return;
}
const preparedEvent = prepareEvent(spanEvent);
const span = createSpanFromEvent(events, preparedEvent);
const output = rehydrateJson(spanEvent.output);
const payload = rehydrateJson(spanEvent.payload);
const show = rehydrateShow(spanEvent.properties);
const properties = sanitizedAttributes(spanEvent.properties);
const messagingEvent = SpanMessagingEvent.optional().safeParse((properties as any)?.messaging);
const links: SpanLink[] = [];
if (messagingEvent.success && messagingEvent.data) {
if (messagingEvent.data.message && "id" in messagingEvent.data.message) {
if (messagingEvent.data.message.id.startsWith("run_")) {
links.push({
type: "run",
icon: "runs",
title: `Run ${messagingEvent.data.message.id}`,
runId: messagingEvent.data.message.id,
});
}
}
}
const backLinks = spanEvent.links as any as Link[] | undefined;
if (backLinks && backLinks.length > 0) {
backLinks.forEach((l) => {
const title = String(l.attributes?.[SemanticInternalAttributes.LINK_TITLE] ?? "Triggered by");
links.push({
type: "span",
icon: "trigger",
title,
traceId: l.context.traceId,
spanId: l.context.spanId,
});
});
}
const spanEvents = transformEvents(
preparedEvent.events,
spanEvent.metadata as Attributes,
spanEvent.environmentType === "DEVELOPMENT"
);
return {
...spanEvent,
...span.data,
payload,
output,
events: spanEvents,
show,
links,
properties: properties ? JSON.stringify(properties, null, 2) : undefined,
showActionBar: show?.actions === true,
};
}
export function createSpanFromEvent(events: TaskEvent[], event: PreparedEvent) {
let ancestorCancelled = false;
let duration = event.duration;
if (!event.isCancelled && event.isPartial) {
walkSpanAncestors(events, event, (ancestorEvent, level) => {
if (level >= 8) {
return { stop: true };
}
if (ancestorEvent.isCancelled) {
ancestorCancelled = true;
// We need to get the cancellation time from the cancellation span event
const cancellationEvent = ancestorEvent.events.find(
(event) => event.name === "cancellation"
);
if (cancellationEvent) {
duration = calculateDurationFromStart(event.startTime, cancellationEvent.time);
}
return { stop: true };
}
return { stop: false };
});
}
const span = {
recordId: event.id,
id: event.spanId,
parentId: event.parentId ?? undefined,
runId: event.runId,
idempotencyKey: event.idempotencyKey,
data: {
message: event.message,
style: event.style,
duration,
isError: event.isError,
isPartial: ancestorCancelled ? false : event.isPartial,
isCancelled: event.isCancelled === true ? true : event.isPartial && ancestorCancelled,
startTime: getDateFromNanoseconds(event.startTime),
level: event.level,
events: event.events,
environmentType: event.environmentType,
},
};
return span;
}
function walkSpanAncestors(
events: TaskEvent[],
event: PreparedEvent,
callback: (event: PreparedEvent, level: number) => { stop: boolean }
) {
const parentId = event.parentId;
if (!parentId) {
return;
}
let parentEvent = getSpanEvent(events, parentId);
let level = 1;
while (parentEvent) {
const preparedParentEvent = prepareEvent(parentEvent);
const result = callback(preparedParentEvent, level);
if (result.stop) {
return;
}
if (!preparedParentEvent.parentId) {
return;
}
parentEvent = getSpanEvent(events, preparedParentEvent.parentId);
level++;
}
}
function getSpanEvent(events: TaskEvent[], spanId: string) {
const spans = events.filter((e) => e.spanId === spanId);
const completedSpan = spans.find((s) => !s.isPartial);
if (completedSpan) {
return completedSpan;
}
return spans.at(0);
}
export function prepareEvent(event: TaskEvent): PreparedEvent {
return {
...event,
duration: Number(event.duration),
events: parseEventsField(event.events),
style: parseStyleField(event.style),
};
}
function parseEventsField(events: Prisma.JsonValue): SpanEvents {
const unsafe = events
? (events as any[]).map((e) => ({
...e,
properties: unflattenAttributes(e.properties as Attributes),
}))
: undefined;
return unsafe as SpanEvents;
}
function parseStyleField(style: Prisma.JsonValue): TaskEventStyle {
const unsafe = unflattenAttributes(style as Attributes);
if (!unsafe) {
return {};
}
if (typeof unsafe === "object") {
return Object.assign(
{
icon: undefined,
variant: undefined,
},
unsafe
) as TaskEventStyle;
}
return {};
}
export function isAncestorCancelled(events: Map<string, PreparedEvent>, spanId: string) {
const event = events.get(spanId);
if (!event) {
return false;
}
if (event.isCancelled) {
return true;
}
if (event.parentId) {
return isAncestorCancelled(events, event.parentId);
}
return false;
}
function calculateDurationIfAncestorIsCancelled(
events: Map<string, PreparedEvent>,
spanId: string,
defaultDuration: number
) {
const event = events.get(spanId);
if (!event) {
return defaultDuration;
}
if (event.isCancelled) {
return defaultDuration;
}
if (!event.isPartial) {
return defaultDuration;
}
if (event.parentId) {
const cancelledAncestor = findFirstCancelledAncestor(events, event.parentId);
if (cancelledAncestor) {
// We need to get the cancellation time from the cancellation span event
const cancellationEvent = cancelledAncestor.events.find(
(event) => event.name === "cancellation"
);
if (cancellationEvent) {
return calculateDurationFromStart(event.startTime, cancellationEvent.time);
}
}
}
return defaultDuration;
}
export function calculateDurationFromStart(startTime: bigint, endTime: Date = new Date()) {
const $endtime = typeof endTime === "string" ? new Date(endTime) : endTime;
return Number(BigInt($endtime.getTime() * 1_000_000) - startTime);
}
function findFirstCancelledAncestor(events: Map<string, PreparedEvent>, spanId: string) {
const event = events.get(spanId);
if (!event) {
return;
}
if (event.isCancelled) {
return event;
}
if (event.parentId) {
return findFirstCancelledAncestor(events, event.parentId);
}
return;
}
export function getDateFromNanoseconds(nanoseconds: bigint) {
return new Date(Number(nanoseconds) / 1_000_000);
}
export function getNowInNanoseconds(): bigint {
return BigInt(new Date().getTime() * 1_000_000);
}
export function rehydrateJson(json: Prisma.JsonValue): any {
if (json === null) {
return undefined;
}
if (json === NULL_SENTINEL) {
return null;
}
if (typeof json === "string") {
return json;
}
if (typeof json === "number") {
return json;
}
if (typeof json === "boolean") {
return json;
}
if (Array.isArray(json)) {
return json.map((item) => rehydrateJson(item));
}
if (typeof json === "object") {
return unflattenAttributes(json as Attributes);
}
return null;
}
export function rehydrateShow(properties: Prisma.JsonValue): { actions?: boolean } | undefined {
if (properties === null || properties === undefined) {
return;
}
if (typeof properties !== "object") {
return;
}
if (Array.isArray(properties)) {
return;
}
const actions = properties[SemanticInternalAttributes.SHOW_ACTIONS];
if (typeof actions === "boolean") {
return { actions };
}
return;
}
export function sanitizedAttributes(json: Prisma.JsonValue) {
if (json === null || json === undefined) {
return;
}
const withoutPrivateProperties = removePrivateProperties(json as Attributes);
if (!withoutPrivateProperties) {
return;
}
return unflattenAttributes(withoutPrivateProperties);
}
// removes keys that start with a $ sign. If there are no keys left, return undefined
function removePrivateProperties(
attributes: Attributes | undefined | null
): Attributes | undefined {
if (!attributes) {
return undefined;
}
const result: Attributes = {};
for (const [key, value] of Object.entries(attributes)) {
if (key.startsWith("$")) {
continue;
}
result[key] = value;
}
if (Object.keys(result).length === 0) {
return undefined;
}
return result;
}
export function transformEvents(
events: SpanEvents,
properties: Attributes,
isDev: boolean
): SpanEvents {
return (events ?? []).map((event) => transformEvent(event, properties, isDev));
}
function transformEvent(event: SpanEvent, properties: Attributes, isDev: boolean): SpanEvent {
if (isExceptionSpanEvent(event)) {
return {
...event,
properties: {
exception: transformException(event.properties.exception, properties, isDev),
},
};
}
return event;
}
function transformException(
exception: ExceptionEventProperties,
properties: Attributes,
isDev: boolean
): ExceptionEventProperties {
const projectDirAttributeValue = properties[SemanticInternalAttributes.PROJECT_DIR];
if (projectDirAttributeValue !== undefined && typeof projectDirAttributeValue !== "string") {
return exception;
}
return {
...exception,
stacktrace: exception.stacktrace
? correctErrorStackTrace(exception.stacktrace, projectDirAttributeValue, {
removeFirstLine: true,
isDev,
})
: undefined,
};
}
+5 -1
View File
@@ -46,6 +46,7 @@
"@conform-to/react": "^0.6.1",
"@conform-to/zod": "^0.6.1",
"@depot/sdk-node": "^1.0.0",
"@electric-sql/react": "^0.3.5",
"@headlessui/react": "^1.7.8",
"@heroicons/react": "^2.0.12",
"@internationalized/date": "^3.5.1",
@@ -107,6 +108,7 @@
"class-variance-authority": "^0.5.2",
"clsx": "^1.2.1",
"compression": "^1.7.4",
"cookie": "^0.6.0",
"cron-parser": "^4.9.0",
"cronstrue": "^2.21.0",
"cross-env": "^7.0.3",
@@ -150,6 +152,7 @@
"react-resizable-panels": "^2.0.9",
"react-stately": "^3.29.1",
"react-use": "^17.4.0",
"react-window-splitter": "^0.4.1",
"recharts": "^2.12.6",
"regression": "^2.0.1",
"remix-auth": "^3.6.0",
@@ -190,6 +193,7 @@
"@total-typescript/ts-reset": "^0.4.2",
"@types/bcryptjs": "^2.4.2",
"@types/compression": "^1.7.2",
"@types/cookie": "^0.6.0",
"@types/eslint": "^8.4.6",
"@types/express": "^4.17.13",
"@types/humanize-duration": "^3.27.1",
@@ -243,4 +247,4 @@
"engines": {
"node": ">=16.0.0"
}
}
}
+13
View File
@@ -29,6 +29,7 @@ services:
- listen_addresses=*
- -c
- wal_level=logical
pgadmin:
container_name: pgadmin
image: dpage/pgadmin4:8
@@ -58,6 +59,18 @@ services:
ports:
- 6379:6379
electric:
image: electricsql/electric
restart: always
environment:
DATABASE_URL: postgresql://postgres:postgres@database:5432/postgres
networks:
- app_network
ports:
- "3060:3000"
depends_on:
- database
# otel-collector:
# container_name: otel-collector
# image: otel/opentelemetry-collector-contrib:latest
+1474 -79
View File
File diff suppressed because it is too large Load Diff