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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+100
-100
@@ -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>
|
||||
</>
|
||||
|
||||
+83
-36
@@ -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}
|
||||
|
||||
+1286
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -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>
|
||||
</>
|
||||
|
||||
+8
-8
@@ -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}
|
||||
|
||||
+7
-7
@@ -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 })}`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1474
-79
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user