diff --git a/.server-changes/runs-list-column-customization.md b/.server-changes/runs-list-column-customization.md new file mode 100644 index 00000000000..a069ca8ce73 --- /dev/null +++ b/.server-changes/runs-list-column-customization.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Customize the runs list: show, hide, and reorder columns, and add smart columns that pull a value straight out of a run's payload, metadata, or output. Your column choices are saved in the page URL so you can share or bookmark a view. diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx new file mode 100644 index 00000000000..db29d6df8f2 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -0,0 +1,420 @@ +import { BoltIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; +import { useEffect, useMemo, useState } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { Button } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { cn } from "~/utils/cn"; +import { + SMART_COLUMN_DISPLAYS, + type SmartColumnDef, + type SmartColumnDisplay, + type SmartColumnSource, +} from "./runColumns"; +import { + extractSmartValue, + labelFromPath, + parseSource, + type ParsedSource, +} from "./smartColumnData"; +import { SmartColumnSample } from "./SmartColumnSample"; +import { isNumericSmartDisplay, SmartCellContent } from "./smartColumnCell"; +import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample"; + +type AddSmartColumnDialogProps = { + open: boolean; + /** When set, the dialog edits this existing column instead of adding a new one. */ + editing: SmartColumnDef | null; + onOpenChange: (open: boolean) => void; + onSubmit: (def: SmartColumnDef) => void; + currentSearch: string; + /** + * Extra filters merged into the sample request so the preview samples the + * runs the host page actually lists (e.g. its task or error), for pages that + * carry that scope in the route path rather than the query string. + */ + sampleFilters?: Record; +}; + +const SOURCE_CARDS: { value: SmartColumnSource; label: string; description: string }[] = [ + { value: "payload", label: "Payload", description: "What you triggered the run with." }, + { value: "metadata", label: "Metadata", description: "What the run writes while it runs." }, + { value: "output", label: "Output", description: "What the run returned." }, +]; + +const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({ + label: display.charAt(0).toUpperCase() + display.slice(1), + value: display, +})); + +const DEFAULT_SOURCE: SmartColumnSource = "payload"; + +export function AddSmartColumnDialog({ + open, + editing, + onOpenChange, + onSubmit, + currentSearch, + sampleFilters, +}: AddSmartColumnDialogProps) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const sample = useTypedFetcher(); + + const [source, setSource] = useState(DEFAULT_SOURCE); + const [path, setPath] = useState(""); + const [label, setLabel] = useState(""); + const [labelEdited, setLabelEdited] = useState(false); + const [displayAs, setDisplayAs] = useState("text"); + const [sampleIndex, setSampleIndex] = useState(0); + + useEffect(() => { + if (!open) return; + setSource(editing?.source ?? DEFAULT_SOURCE); + setPath(editing?.path ?? ""); + setLabel(editing?.label ?? ""); + setLabelEdited(editing !== null); + setDisplayAs(editing?.displayAs ?? "text"); + setSampleIndex(0); + }, [open, editing]); + + const sampleFiltersKey = sampleFilters ? JSON.stringify(sampleFilters) : ""; + const sampleUrl = useMemo(() => { + const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/smart-column-sample`; + const params = new URLSearchParams(currentSearch.replace(/^\?/, "")); + if (sampleFilters) { + for (const [key, val] of Object.entries(sampleFilters)) params.set(key, val); + } + params.set("source", source); + const qs = params.toString(); + return qs ? `${base}?${qs}` : base; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organization.slug, project.slug, environment.slug, currentSearch, sampleFiltersKey, source]); + + useEffect(() => { + if (open) { + sample.load(sampleUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, sampleUrl]); + + useEffect(() => { + setSampleIndex(0); + }, [source]); + + const handleSourceChange = (next: SmartColumnSource) => { + if (next === source) return; + setSource(next); + setPath(""); + setLabel(""); + setLabelEdited(false); + }; + + const effectiveLabel = labelEdited ? label : labelFromPath(path); + + const sampleLoaded = sample.data !== undefined && sample.state === "idle"; + const sampleData = sample.data; + + const { perRun, usable, anyOffloaded, runCount } = useMemo(() => { + const runs = sampleData?.runs ?? []; + const perRun = runs.map((run) => ({ + hasFinished: run.hasFinished, + parsed: + source === "payload" + ? parseSource({ data: run.payload, dataType: run.payloadType }) + : source === "metadata" + ? parseSource({ data: run.metadata, dataType: run.metadataType }) + : parseSource({ data: run.output, dataType: run.outputType }), + })); + return { + runCount: runs.length, + perRun, + anyOffloaded: perRun.some((r) => r.parsed.state === "offloaded"), + usable: perRun.filter( + (r): r is { hasFinished: boolean; parsed: Extract } => + r.parsed.state === "parsed" + ), + }; + }, [sampleData, source]); + + const activeIndex = usable.length > 0 ? Math.min(sampleIndex, usable.length - 1) : 0; + const activeSample = usable[activeIndex]?.parsed; + + const canSubmit = path.trim().length > 0; + + const previewDef: SmartColumnDef = { + source, + path: path.trim(), + label: effectiveLabel, + displayAs, + }; + + const handleSubmit = () => { + if (!canSubmit) return; + onSubmit({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs }); + onOpenChange(false); + }; + + return ( + + + {editing ? "Edit smart column" : "Add smart column"} +
+ + Smart columns are display only. You can't sort or filter by them. + + +
+
+
+ +
+ {SOURCE_CARDS.map((card) => ( + handleSourceChange(card.value)} + /> + ))} +
+
+ +
+
+ + setPath(e.target.value)} + placeholder="$.order.total" + spellCheck={false} + /> + + e.g. $.order.total, $.items[0].sku,{" "} + $.items.length + +
+
+ + { + setLabel(e.target.value); + setLabelEdited(true); + }} + placeholder={labelFromPath(path)} + /> +
+
+ +
+ +
+ {DISPLAY_OPTIONS.map((option) => ( + + ))} +
+
+
+ +
+
+ Sample {source} + {usable.length > 1 && ( + setSampleIndex((i) => Math.max(0, i - 1))} + onNext={() => setSampleIndex((i) => Math.min(usable.length - 1, i + 1))} + /> + )} +
+
+ {!sampleLoaded ? ( + + Loading… + + ) : activeSample ? ( + + ) : runCount === 0 ? ( + + No runs to sample yet. + + ) : anyOffloaded ? ( + + Recent {source}s are too large to sample here. + + ) : ( + + No recent run has a {source} to sample. + + )} +
+
+ +
+
+ Preview +
+ +
+
+
+
+ + +
+
+
+ ); +} + +function SampleRunPicker({ + index, + total, + onPrev, + onNext, +}: { + index: number; + total: number; + onPrev: () => void; + onNext: () => void; +}) { + return ( +
+ + +
+ ); +} + +function SourceCard({ + label, + description, + selected, + onSelect, +}: { + label: string; + description: string; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function SmartColumnPreview({ + rows, + def, + loaded, +}: { + rows: { hasFinished: boolean; parsed: ParsedSource }[]; + def: SmartColumnDef; + loaded: boolean; +}) { + const numeric = isNumericSmartDisplay(def.displayAs); + const alignClass = numeric ? "justify-end text-right tabular-nums" : "justify-start text-left"; + + return ( +
+
+ + + {def.label || "Column"} + +
+
+ {!loaded ? ( +
Loading…
+ ) : rows.length === 0 ? ( +
No runs yet
+ ) : ( + rows.map((row, index) => { + const cell = def.path + ? extractSmartValue(row.parsed, def.path) + : ({ state: "empty" } as const); + return ( +
+ +
+ ); + }) + )} +
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 7dd6e7d9a61..2091d3e0f5b 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -406,6 +406,15 @@ export function RunsFilters(props: RunFiltersProps) { {hasFilters && (
+ {searchParams.getAll("cols").map((v, i) => ( + + ))} + {searchParams.getAll("hide").map((v, i) => ( + + ))} + {searchParams.getAll("sc").map((v, i) => ( + + ))} + + +
+ Columns + + {shownCount} of {totalCount} + +
+
+ {layout.ordered.map(({ col, hidden }) => { + const key = keyFor(col); + return ( + setDragKey(key)} + onDragEnter={() => setOverKey(key)} + onDragEnd={endDrag} + onDrop={() => { + if (dragKey) reorder(dragKey, key); + endDrag(); + }} + onToggle={() => toggleHidden(key)} + onMove={(delta) => move(key, delta)} + onEdit={ + col.kind === "smart" + ? () => setEditing({ index: col.index, def: col.def }) + : undefined + } + onRemove={col.kind === "smart" ? () => removeSmart(col.index) : undefined} + /> + ); + })} +
+
+ + +
+
+ + { + if (!next) { + setAddOpen(false); + setEditing(null); + } + }} + onSubmit={submitSmart} + currentSearch={location.search} + sampleFilters={sampleFilters} + /> + + ); +} + +function ColumnRow({ + col, + checked, + locked, + dragging, + isOver, + onToggle, + onMove, + onEdit, + onRemove, + onDragStart, + onDragEnter, + onDragEnd, + onDrop, +}: { + col: ResolvedColumn; + checked: boolean; + locked: boolean; + dragging: boolean; + isOver: boolean; + onToggle: () => void; + onMove: (delta: number) => void; + onEdit?: () => void; + onRemove?: () => void; + onDragStart: () => void; + onDragEnter: () => void; + onDragEnd: () => void; + onDrop: () => void; +}) { + const isSmart = col.kind === "smart"; + + return ( +
{ + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", ""); + onDragStart(); + }} + onDragEnter={onDragEnter} + onDragEnd={onDragEnd} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + onDrop(); + }} + > + {isOver &&
} + {locked ? : } + + + {col.def.label} + + {isSmart && } + +
+ {onEdit && ( + + )} + {onRemove && ( + + )} + +
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx new file mode 100644 index 00000000000..8e075910eb1 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx @@ -0,0 +1,133 @@ +import { cn } from "~/utils/cn"; + +/** Max children rendered per node so a large blob can't blow up the DOM. */ +const MAX_CHILDREN = 200; +const MAX_STRING = 80; + +/** + * A clickable, syntax-colored JSON tree for the smart-column sample, rendered + * fully expanded. Only leaf values are selectable: clicking one fills the JSON + * path field via `onSelectPath` and highlights it. Objects and arrays are shown + * inline (not clickable) so you can see the shape and pick a leaf inside them. + */ +export function SmartColumnSample({ + value, + activePath, + onSelectPath, +}: { + value: unknown; + activePath: string; + onSelectPath: (path: string) => void; +}) { + return ( +
+ +
+ ); +} + +function childPath(parentPath: string, key: string | number): string { + if (typeof key === "number") return `${parentPath}[${key}]`; + if (key !== "length" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) return `${parentPath}.${key}`; + return `${parentPath}['${key.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}']`; +} + +function JsonNode({ + name, + path, + value, + activePath, + onSelectPath, +}: { + name: string | number | undefined; + path: string; + value: unknown; + activePath: string; + onSelectPath: (path: string) => void; +}) { + const isObject = value !== null && typeof value === "object"; + const selected = path === activePath; + const keyLabel = name === undefined ? null : typeof name === "number" ? name : `"${name}"`; + + if (!isObject) { + const target = name === undefined ? "$" : path; + return ( + + ); + } + + const isArray = Array.isArray(value); + const entries: [string | number, unknown][] = isArray + ? (value as unknown[]).map((v, i) => [i, v]) + : Object.entries(value as Record); + const shown = entries.slice(0, MAX_CHILDREN); + const openBrace = isArray ? "[" : "{"; + const closeBrace = isArray ? "]" : "}"; + + if (entries.length === 0) { + return ( +
+ {keyLabel !== null && {keyLabel}} + {keyLabel !== null && : } + + {openBrace} + {closeBrace} + +
+ ); + } + + return ( +
+
+ {keyLabel !== null && {keyLabel}} + {keyLabel !== null && : } + {openBrace} +
+
+ {shown.map(([key, childValue]) => ( + + ))} + {entries.length > MAX_CHILDREN && ( +
… {entries.length - MAX_CHILDREN} more
+ )} +
+
{closeBrace}
+
+ ); +} + +function PrimitiveValue({ value }: { value: unknown }) { + if (value === null) return null; + if (typeof value === "string") { + const truncated = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value; + return "{truncated}"; + } + if (typeof value === "number") return {String(value)}; + if (typeof value === "boolean") return {String(value)}; + return {String(value)}; +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index ddb7220213c..b8bfab9279c 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -2,6 +2,7 @@ import { ArrowPathIcon, ArrowRightIcon, ClockIcon, + BoltIcon, CpuChipIcon, NoSymbolIcon, RectangleStackIcon, @@ -9,7 +10,7 @@ import { import { BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid"; import { useLocation } from "@remix-run/react"; import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3"; -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { TasksIcon } from "~/assets/icons/TasksIcon"; import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { MachineTooltipInfo } from "~/components/MachineTooltipInfo"; @@ -63,6 +64,17 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; import type { TaskTriggerSource } from "@trigger.dev/database"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; +import { + parseColumnParams, + resolveColumnLayout, + visibleSmartSources, + type ResolvedColumn, + type RunColumnRuntime, + type SmartColumnDef, + type SmartColumnSource, +} from "./runColumns"; +import { extractSmartValue, parseSource, type ParsedSource } from "./smartColumnData"; +import { isNumericSmartDisplay, SmartCellContent } from "./smartColumnCell"; type RunsTableProps = { total: number; @@ -79,6 +91,13 @@ type RunsTableProps = { showTopBorder?: boolean; stickyHeader?: boolean; childrenStatusesBasePath?: string; + /** + * Whether URL-driven smart columns render here. Default true; embedded run + * tables whose loader does not hydrate payload/metadata/output (schedule + * inspector, waitpoint, webhook) pass false so they never show a column they + * cannot fill. + */ + enableSmartColumns?: boolean; /** * Display-only write:runs flags from the caller's loader. Default true so * callers that don't pass them (and OSS, where the ability is permissive) @@ -89,6 +108,464 @@ type RunsTableProps = { canReplayRuns?: boolean; }; +type CellRenderContext = { + run: NextRunListItem; + path: string; + regionByMasterQueue: Map; + childrenStatusesBasePath?: string; + sources: Partial>; +}; + +type StandardColumnRenderer = { + header: React.ReactNode; + cell: (ctx: CellRenderContext) => React.ReactNode; + /** Cells/header this column occupies (Duration renders three). */ + span: number; +}; + +const STANDARD_RENDERERS: Record = { + id: { + span: 1, + header: ID, + cell: ({ run, path }) => ( + + + + ), + }, + task: { + span: 1, + header: Task, + cell: ({ run, path }) => ( + + + + {run.taskIdentifier} + {run.rootTaskRunId === null ? Root : null} + + + ), + }, + ver: { + span: 1, + header: Version, + cell: ({ run, path }) => {run.version ?? "–"}, + }, + status: { + span: 1, + header: ( + + {filterableTaskRunStatuses.map((status) => ( +
+
+ +
+ + {descriptionForTaskRunStatus(status)} + +
+ ))} +
+ } + > + Status + + ), + cell: ({ run, path, childrenStatusesBasePath }) => ( + + {run.rootTaskRunId === null && childrenStatusesBasePath ? ( + + ) : ( + } + /> + )} + + ), + }, + started: { + span: 1, + header: Started, + cell: ({ run, path }) => ( + {run.startedAt ? : "–"} + ), + }, + dur: { + span: 3, + header: ( + +
+
+ + Queued duration +
+ + The amount of time from when the run was created to it starting to run. + +
+
+
+ Run duration +
+ + The total amount of time from the run starting to it finishing. This includes all + time spent waiting. + +
+
+
+ + Compute duration +
+ + The amount of compute time used in the run. This does not include time spent + waiting. + +
+ + } + > + Duration +
+ ), + cell: ({ run, path }) => ( + <> + +
+ + {run.isPending ? ( + "–" + ) : run.startedAt ? ( + formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), { + style: "short", + }) + ) : run.isCancellable ? ( + + ) : ( + formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), { + style: "short", + }) + )} +
+
+ +
+ + {run.startedAt && run.finishedAt ? ( + formatDuration(new Date(run.startedAt), new Date(run.finishedAt), { + style: "short", + }) + ) : run.startedAt ? ( + + ) : ( + "–" + )} +
+
+ +
+ + {run.usageDurationMs > 0 + ? formatDurationMilliseconds(run.usageDurationMs, { + style: "short", + }) + : "–"} +
+
+ + ), + }, + compute: { + span: 1, + header: Compute, + cell: ({ run, path }) => ( + + {run.costInCents > 0 + ? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100) + : "–"} + + ), + }, + machine: { + span: 1, + header: ( + }> + Machine + + ), + cell: ({ run, path }) => ( + + + + ), + }, + queue: { + span: 1, + header: Queue, + cell: ({ run, path }) => ( + + {run.queue.type === "task" ? ( + + + {run.queue.name} + + } + content={`This queue was automatically created from your "${run.queue.name}" task`} + disableHoverableContent + /> + ) : ( + + + {run.queue.name} + + } + content={`This is a custom queue you added in your code.`} + disableHoverableContent + /> + )} + + ), + }, + region: { + span: 1, + header: Region, + cell: ({ run, path, regionByMasterQueue }) => ( + + {run.region ? ( + + ) : ( + "–" + )} + + ), + }, + test: { + span: 1, + header: Test, + cell: ({ run, path }) => ( + + {run.isTest ? ( + + ) : ( + "–" + )} + + ), + }, + created: { + span: 1, + header: Created at, + cell: ({ run, path }) => ( + {run.createdAt ? : "–"} + ), + }, + delayed: { + span: 1, + header: ( + + + When you want to trigger a task now, but have it run at a later time, you can use the + delay option. + + + Runs that are delayed and have not been enqueued yet will display in the dashboard + with a “Delayed” status. + + + Read docs + + + } + > + Delayed until + + ), + cell: ({ run, path }) => ( + {run.delayUntil ? : "–"} + ), + }, + ttl: { + span: 1, + header: ( + + + You can set a TTL (time to live) when triggering a task, which will automatically + expire the run if it hasn’t started within the specified time. + + + All runs in development have a default ttl of 10 minutes. You can disable this by + setting the ttl option. + + + Read docs + + + } + > + TTL + + ), + cell: ({ run, path }) => {run.ttl ?? "–"}, + }, + tags: { + span: 1, + header: ( + + + You can add tags to a run and then filter runs using them. + + + You can add tags when triggering a run or inside the run function. + + + Read docs + + + } + > + Tags + + ), + cell: ({ run, path }) => ( + +
+ {run.tags.length > 0 ? run.tags.map((tag) => ) : "–"} +
+
+ ), + }, +}; + +function SmartColumnHeader({ def }: { def: SmartColumnDef }) { + return ( + + + + {def.label} + + + ); +} + +function SmartColumnCell({ + def, + run, + path, + parsed, +}: { + def: SmartColumnDef; + run: NextRunListItem; + path: string; + parsed: ParsedSource | undefined; +}) { + const numeric = isNumericSmartDisplay(def.displayAs); + const cell = extractSmartValue(parsed ?? { state: "empty" }, def.path); + + return ( + + + + ); +} + +const EMPTY_SOURCES: Partial> = {}; + +function buildRowSources( + run: NextRunListItem, + sources: SmartColumnSource[] +): Partial> { + const result: Partial> = {}; + for (const source of sources) { + switch (source) { + case "payload": + result.payload = parseSource({ data: run.payload, dataType: run.payloadType }); + break; + case "metadata": + result.metadata = parseSource({ data: run.metadata, dataType: run.metadataType }); + break; + case "output": + result.output = parseSource({ data: run.output, dataType: run.outputType }); + break; + } + } + return result; +} + +function columnKey(col: ResolvedColumn): string { + return col.kind === "standard" ? `std:${col.def.id}` : `smart:${col.index}`; +} + +function ColumnHeader({ column }: { column: ResolvedColumn }) { + if (column.kind === "smart") { + return ; + } + return <>{STANDARD_RENDERERS[column.def.id]?.header ?? null}; +} + +function ColumnCell({ column, ctx }: { column: ResolvedColumn; ctx: CellRenderContext }) { + if (column.kind === "smart") { + return ( + + ); + } + return <>{STANDARD_RENDERERS[column.def.id]?.cell(ctx) ?? null}; +} + export function TaskRunsTable({ total, hasFilters, @@ -103,6 +580,7 @@ export function TaskRunsTable({ showTopBorder = true, stickyHeader = false, childrenStatusesBasePath, + enableSmartColumns = true, canCancelRuns = true, canReplayRuns = true, }: RunsTableProps) { @@ -114,7 +592,7 @@ export function TaskRunsTable({ const checkboxes = useRef<(HTMLInputElement | null)[]>([]); const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); const { isManagedCloud } = useFeatures(); - const { value } = useSearchParams(); + const { value, values } = useSearchParams(); const location = useOptimisticLocation(); const params = new URLSearchParams(location.search || ""); if (!value("rootOnly")) { @@ -129,8 +607,37 @@ export function TaskRunsTable({ /** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */ const tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search); - const showCompute = isManagedCloud; - const showRegion = environment.type !== "DEVELOPMENT"; + const isDevelopment = environment.type === "DEVELOPMENT"; + const colsParam = value("cols"); + const hideParam = value("hide"); + const scFromUrl = values("sc"); + const scKey = scFromUrl.join(" "); + const layout = useMemo(() => { + const runtime: RunColumnRuntime = { isManagedCloud, isDevelopment }; + return resolveColumnLayout(parseColumnParams(colsParam, scFromUrl, hideParam), runtime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colsParam, hideParam, scKey, isManagedCloud, isDevelopment]); + + const visibleColumns = useMemo( + () => (enableSmartColumns ? layout.visible : layout.visible.filter((c) => c.kind !== "smart")), + [layout, enableSmartColumns] + ); + const referencedSources = useMemo(() => visibleSmartSources(visibleColumns), [visibleColumns]); + + const sourcesByRunId = useMemo(() => { + const map = new Map>>(); + if (referencedSources.length === 0) return map; + for (const run of runs) { + map.set(run.id, buildRowSources(run, referencedSources)); + } + return map; + }, [runs, referencedSources]); + + const dataColSpan = visibleColumns.reduce( + (sum, col) => sum + (col.kind === "standard" ? (STANDARD_RENDERERS[col.def.id]?.span ?? 1) : 1), + 0 + ); + const totalColSpan = (allowSelection ? 1 : 0) + dataColSpan + 1; const navigateCheckboxes = useCallback( (event: React.KeyboardEvent, index: number) => { @@ -189,152 +696,9 @@ export function TaskRunsTable({ )} )} - ID - Task - Version - - {filterableTaskRunStatuses.map((status) => ( -
-
- -
- - {descriptionForTaskRunStatus(status)} - -
- ))} - - } - > - Status -
- Started - -
-
- - Queued duration -
- - The amount of time from when the run was created to it starting to run. - -
-
-
- Run duration -
- - The total amount of time from the run starting to it finishing. This includes - all time spent waiting. - -
-
-
- - Compute duration -
- - The amount of compute time used in the run. This does not include time spent - waiting. - -
- - } - > - Duration -
- {showCompute && ( - <> - Compute - - )} - }> - Machine - - Queue - {showRegion && Region} - Test - Created at - - - When you want to trigger a task now, but have it run at a later time, you can use - the delay option. - - - Runs that are delayed and have not been enqueued yet will display in the dashboard - with a “Delayed” status. - - - Read docs - - - } - > - Delayed until - - - - You can set a TTL (time to live) when triggering a task, which will automatically - expire the run if it hasn’t started within the specified time. - - - All runs in development have a default ttl of 10 minutes. You can disable this by - setting the ttl option. - - - Read docs - - - } - > - TTL - - - - You can add tags to a run and then filter runs using them. - - - You can add tags when triggering a run or inside the run function. - - - Read docs - - - } - > - Tags - + {visibleColumns.map((col) => ( + + ))} Go to page @@ -342,11 +706,11 @@ export function TaskRunsTable({ {total === 0 && !hasFilters ? ( - + {!isLoading && } ) : runs.length === 0 ? ( - + ) : ( runs.map((run, index) => { const searchParams = new URLSearchParams(); @@ -363,6 +727,7 @@ export function TaskRunsTable({ }, searchParams ); + const sources = sourcesByRunId.get(run.id) ?? EMPTY_SOURCES; return ( {allowSelection && ( @@ -379,149 +744,13 @@ export function TaskRunsTable({ /> )} - - - - - - - {run.taskIdentifier} - {run.rootTaskRunId === null ? Root : null} - - - {run.version ?? "–"} - - {run.rootTaskRunId === null && childrenStatusesBasePath ? ( - - ) : ( - } - /> - )} - - - {run.startedAt ? : "–"} - - -
- - {run.isPending ? ( - "–" - ) : run.startedAt ? ( - formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), { - style: "short", - }) - ) : run.isCancellable ? ( - - ) : ( - formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), { - style: "short", - }) - )} -
-
- -
- - {run.startedAt && run.finishedAt ? ( - formatDuration(new Date(run.startedAt), new Date(run.finishedAt), { - style: "short", - }) - ) : run.startedAt ? ( - - ) : ( - "–" - )} -
-
- -
- - {run.usageDurationMs > 0 - ? formatDurationMilliseconds(run.usageDurationMs, { - style: "short", - }) - : "–"} -
-
- {showCompute && ( - - {run.costInCents > 0 - ? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100) - : "–"} - - )} - - - - - {run.queue.type === "task" ? ( - - - {run.queue.name} - - } - content={`This queue was automatically created from your "${run.queue.name}" task`} - disableHoverableContent - /> - ) : ( - - - {run.queue.name} - - } - content={`This is a custom queue you added in your code.`} - disableHoverableContent - /> - )} - - {showRegion && ( - - {run.region ? ( - - ) : ( - "–" - )} - - )} - - {run.isTest ? ( - - ) : ( - "–" - )} - - - {run.createdAt ? : "–"} - - - {run.delayUntil ? : "–"} - - {run.ttl ?? "–"} - -
- {run.tags.map((tag) => ) || "–"} -
-
+ {visibleColumns.map((col) => ( + + ))} Loading… @@ -711,12 +940,11 @@ function NoRuns({ title }: { title: string }) { function BlankState({ isLoading, filters, - showRegion, -}: Pick & { showRegion: boolean }) { + colSpan, +}: Pick & { colSpan: number }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const colSpan = showRegion ? 16 : 15; if (isLoading) return ; const { tasks, from, to, ...otherFilters } = filters; diff --git a/apps/webapp/app/components/runs/v3/runColumns.test.ts b/apps/webapp/app/components/runs/v3/runColumns.test.ts new file mode 100644 index 00000000000..d2c3782e8af --- /dev/null +++ b/apps/webapp/app/components/runs/v3/runColumns.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { + availableStandardColumns, + decodeSmartColumn, + deriveRunSelect, + encodeColumnLayout, + encodeSmartColumn, + resolveColumnLayout, + type ResolvedColumn, + type RunColumnRuntime, + type SmartColumnDef, +} from "./runColumns"; + +const cloud: RunColumnRuntime = { isManagedCloud: true, isDevelopment: false }; +const dev: RunColumnRuntime = { isManagedCloud: false, isDevelopment: true }; + +describe("deriveRunSelect", () => { + it("always includes the presenter's scalar contract", () => { + const select = deriveRunSelect([], []); + for (const field of [ + "id", + "friendlyId", + "spanId", + "status", + "runtimeEnvironmentId", + "rootTaskRunId", + "createdAt", + "updatedAt", + "startedAt", + "lockedAt", + "completedAt", + "queueTimestamp", + "delayUntil", + "scheduleId", + "taskIdentifier", + "machinePreset", + "queue", + "runTags", + ]) { + expect(select[field as keyof typeof select]).toBe(true); + } + }); + + it("does not hydrate the source blobs unless a smart column references them", () => { + const select = deriveRunSelect(["task", "status", "tags"], []); + expect(select.payload).toBeUndefined(); + expect(select.payloadType).toBeUndefined(); + expect(select.output).toBeUndefined(); + expect(select.outputType).toBeUndefined(); + expect(select.metadata).toBeUndefined(); + expect(select.metadataType).toBeUndefined(); + }); + + it("adds payload/output fields only for referenced smart sources", () => { + const payloadOnly = deriveRunSelect([], ["payload"]); + expect(payloadOnly.payload).toBe(true); + expect(payloadOnly.payloadType).toBe(true); + expect(payloadOnly.output).toBeUndefined(); + + const both = deriveRunSelect([], ["payload", "output"]); + expect(both.output).toBe(true); + expect(both.outputType).toBe(true); + }); + + it("adds metadata fields only when a metadata smart column references them", () => { + expect(deriveRunSelect([], []).metadata).toBeUndefined(); + const select = deriveRunSelect([], ["metadata"]); + expect(select.metadata).toBe(true); + expect(select.metadataType).toBe(true); + }); +}); + +describe("availableStandardColumns gating", () => { + it("includes compute and region on managed cloud", () => { + const ids = availableStandardColumns(cloud).map((c) => c.id); + expect(ids).toContain("compute"); + expect(ids).toContain("region"); + }); + + it("drops compute and region on development / self-host", () => { + const ids = availableStandardColumns(dev).map((c) => c.id); + expect(ids).not.toContain("compute"); + expect(ids).not.toContain("region"); + }); +}); + +const orderedIds = (layout: { ordered: { col: ResolvedColumn }[] }) => + layout.ordered.map((o) => (o.col.kind === "standard" ? o.col.def.id : o.col.def.label)); + +const visibleIds = (layout: { visible: ResolvedColumn[] }) => + layout.visible.map((c) => (c.kind === "standard" ? c.def.id : c.def.label)); + +const params = (over: Partial<{ cols: string[]; sc: string[]; hide: string[] }> = {}) => ({ + cols: [], + sc: [], + hide: [], + ...over, +}); + +describe("resolveColumnLayout", () => { + it("returns the default layout when no params are set", () => { + const layout = resolveColumnLayout(params(), cloud); + expect(layout.isCustomized).toBe(false); + expect(layout.ordered.every((o) => !o.hidden)).toBe(true); + expect(layout.ordered[0].col).toMatchObject({ kind: "standard", def: { id: "id" } }); + expect(layout.visible).toHaveLength(availableStandardColumns(cloud).length); + }); + + it("keeps every column in the requested order (columns are reorderable)", () => { + const layout = resolveColumnLayout(params({ cols: ["task", "status", "id"] }), cloud); + expect(orderedIds(layout).slice(0, 3)).toEqual(["task", "status", "id"]); + }); + + it("hides columns from the `hide` list in place, keeping the default order", () => { + const layout = resolveColumnLayout(params({ hide: ["ttl"] }), cloud); + const ttl = layout.ordered.find((o) => o.col.kind === "standard" && o.col.def.id === "ttl"); + expect(ttl?.hidden).toBe(true); + const ids = orderedIds(layout); + expect(ids.indexOf("ttl")).toBeLessThan(ids.indexOf("tags")); + expect(visibleIds(layout)).not.toContain("ttl"); + }); + + it("never hides locked columns even if the `hide` list names them", () => { + const layout = resolveColumnLayout(params({ hide: ["task", "status"] }), cloud); + const locked = layout.ordered.filter((o) => o.col.kind === "standard" && o.col.def.locked); + expect(locked.every((o) => !o.hidden)).toBe(true); + }); + + it("reinserts standard columns missing from the URL as visible", () => { + const layout = resolveColumnLayout(params({ cols: ["id", "ver"] }), cloud); + expect(visibleIds(layout)).toEqual(expect.arrayContaining(["task", "status", "tags", "ttl"])); + }); + + it("resolves smart-column refs positionally, even without a cols order", () => { + const sc = [ + encodeSmartColumn({ + source: "metadata", + path: "$.failed", + label: "Failed", + displayAs: "number", + }), + ]; + const layout = resolveColumnLayout(params({ sc }), cloud); + const smart = layout.visible.find((c) => c.kind === "smart"); + expect(smart).toMatchObject({ kind: "smart", def: { label: "Failed", source: "metadata" } }); + }); + + it("drops gated columns referenced on a runtime that lacks them", () => { + const layout = resolveColumnLayout(params({ cols: ["id", "region", "compute", "task"] }), dev); + expect(orderedIds(layout)).not.toContain("region"); + expect(orderedIds(layout)).not.toContain("compute"); + expect(orderedIds(layout).slice(0, 2)).toEqual(["id", "task"]); + }); +}); + +describe("encodeColumnLayout compactness + round-trip", () => { + const std = (id: string) => ({ + kind: "standard" as const, + def: availableStandardColumns(cloud).find((c) => c.id === id)!, + }); + + it("encodes the default layout to empty params", () => { + const layout = resolveColumnLayout(params(), cloud); + expect(encodeColumnLayout(layout.ordered, cloud)).toEqual({ cols: [], sc: [], hide: [] }); + }); + + it("hiding a column with the default order emits only a hide entry, no cols", () => { + const layout = resolveColumnLayout(params({ hide: ["ver"] }), cloud); + const encoded = encodeColumnLayout(layout.ordered, cloud); + expect(encoded.cols).toEqual([]); + expect(encoded.hide).toEqual(["ver"]); + expect(encoded.sc).toEqual([]); + }); + + it("appending a smart column with the default order emits only sc, no cols", () => { + const scDef: SmartColumnDef = { + source: "metadata", + path: "$.failed", + label: "Failed", + displayAs: "number", + }; + const layout = resolveColumnLayout(params(), cloud); + const encoded = encodeColumnLayout( + [...layout.ordered, { col: { kind: "smart", index: 0, def: scDef }, hidden: false }], + cloud + ); + expect(encoded.cols).toEqual([]); + expect(encoded.sc).toHaveLength(1); + }); + + it("round-trips a reordered, hidden, smart-augmented layout", () => { + const scDef: SmartColumnDef = { + source: "payload", + path: "$.order.total", + label: "Order total", + displayAs: "number", + }; + const encoded = encodeColumnLayout( + [ + { col: std("id"), hidden: false }, + { col: std("status"), hidden: false }, + { col: std("ttl"), hidden: true }, + { col: { kind: "smart", index: 0, def: scDef }, hidden: false }, + ], + cloud + ); + expect(encoded.cols).toEqual(["id", "status", "ttl", "sc1"]); + expect(encoded.hide).toEqual(["ttl"]); + expect(encoded.sc).toHaveLength(1); + + const layout = resolveColumnLayout(encoded, cloud); + const ttl = layout.ordered.find((o) => o.col.kind === "standard" && o.col.def.id === "ttl"); + expect(ttl?.hidden).toBe(true); + expect(visibleIds(layout)).toContain("Order total"); + expect(visibleIds(layout)).not.toContain("ttl"); + }); +}); + +describe("smart column codec", () => { + it("round-trips including delimiter-dangerous characters", () => { + const def: SmartColumnDef = { + source: "metadata", + path: "$['a:b'].c", + label: "Weird: 50%", + displayAs: "badge", + }; + const decoded = decodeSmartColumn(encodeSmartColumn(def)); + expect(decoded).toEqual(def); + }); + + it("rejects an unknown source or display", () => { + expect(decodeSmartColumn("bogus:$.a:A:number")).toBeUndefined(); + expect(decodeSmartColumn("metadata:$.a:A:bogus")).toBeUndefined(); + expect(decodeSmartColumn("metadata::A:number")).toBeUndefined(); + }); +}); diff --git a/apps/webapp/app/components/runs/v3/runColumns.ts b/apps/webapp/app/components/runs/v3/runColumns.ts new file mode 100644 index 00000000000..4b1cf60c608 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/runColumns.ts @@ -0,0 +1,428 @@ +import type { Prisma } from "@trigger.dev/database"; + +/** + * Isomorphic column catalog for the runs list. Shared by the client table + * renderer, the display-options popover, the URL codec, and the server-side + * Postgres select derivation, so none of it may import React or server code. + * + * The order of `RUN_COLUMN_IDS`/`STANDARD_COLUMNS` is the default column order. + */ +const RUN_COLUMN_IDS = [ + "id", + "task", + "status", + "ver", + "started", + "dur", + "compute", + "machine", + "queue", + "region", + "test", + "created", + "delayed", + "ttl", + "tags", +] as const; + +export type RunColumnId = (typeof RUN_COLUMN_IDS)[number]; + +type RunColumnGate = "managedCloud" | "nonDev"; + +type RunSelectField = keyof Prisma.TaskRunSelect; + +export type StandardColumnDef = { + id: RunColumnId; + label: string; + /** + * When set, the column only exists in this runtime; otherwise it is absent + * from the table AND the popover (not merely hidden). + */ + gate?: RunColumnGate; + /** Locked columns can be reordered but never hidden (their toggle is disabled). */ + locked?: boolean; + /** Raw ListedRun/TaskRun fields the column needs hydrated from Postgres. */ + fields: readonly RunSelectField[]; +}; + +/** + * The scalar fields the shared presenter always maps into its stable output, + * regardless of which columns show. These are all small single-row columns with + * no DB win from narrowing, so the select keeps them for a stable contract and + * gates only the large blobs: payload, output, and metadata are added solely + * when a smart column references them (metadata is display-only on the list, so + * there is no reason to hydrate it for every row otherwise). + */ +const ALWAYS_SELECTED_FIELDS = [ + "id", + "friendlyId", + "taskIdentifier", + "taskVersion", + "runtimeEnvironmentId", + "status", + "createdAt", + "queueTimestamp", + "scheduleId", + "startedAt", + "lockedAt", + "delayUntil", + "updatedAt", + "completedAt", + "isTest", + "spanId", + "idempotencyKey", + "ttl", + "expiredAt", + "costInCents", + "baseCostInCents", + "usageDurationMs", + "runTags", + "depth", + "rootTaskRunId", + "batchId", + "machinePreset", + "queue", + "workerQueue", + "region", + "annotations", +] as const satisfies readonly RunSelectField[]; + +const STANDARD_COLUMNS: readonly StandardColumnDef[] = [ + { id: "id", label: "ID", locked: true, fields: ["friendlyId", "spanId"] }, + { + id: "task", + label: "Task", + locked: true, + fields: ["taskIdentifier", "annotations", "rootTaskRunId"], + }, + { id: "status", label: "Status", locked: true, fields: ["status"] }, + { id: "ver", label: "Version", fields: ["taskVersion"] }, + { id: "started", label: "Started", fields: ["startedAt", "lockedAt"] }, + { + id: "dur", + label: "Duration", + fields: [ + "startedAt", + "lockedAt", + "completedAt", + "updatedAt", + "createdAt", + "queueTimestamp", + "delayUntil", + "scheduleId", + "usageDurationMs", + "status", + ], + }, + { + id: "compute", + label: "Compute", + gate: "managedCloud", + fields: ["costInCents", "baseCostInCents"], + }, + { id: "machine", label: "Machine", fields: ["machinePreset"] }, + { id: "queue", label: "Queue", fields: ["queue"] }, + { id: "region", label: "Region", gate: "nonDev", fields: ["region", "workerQueue"] }, + { id: "test", label: "Test", fields: ["isTest"] }, + { id: "created", label: "Created at", fields: ["createdAt"] }, + { id: "delayed", label: "Delayed until", fields: ["delayUntil"] }, + { id: "ttl", label: "TTL", fields: ["ttl", "expiredAt"] }, + { id: "tags", label: "Tags", fields: ["runTags"] }, +]; + +const STANDARD_COLUMNS_BY_ID = new Map(STANDARD_COLUMNS.map((c) => [c.id, c] as const)); + +const SMART_COLUMN_SOURCES = ["payload", "metadata", "output"] as const; +export type SmartColumnSource = (typeof SMART_COLUMN_SOURCES)[number]; + +export const SMART_COLUMN_DISPLAYS = ["text", "number", "duration", "badge"] as const; +export type SmartColumnDisplay = (typeof SMART_COLUMN_DISPLAYS)[number]; + +export type SmartColumnDef = { + source: SmartColumnSource; + path: string; + label: string; + displayAs: SmartColumnDisplay; +}; + +const SMART_SOURCE_FIELDS: Record = { + payload: ["payload", "payloadType"], + metadata: ["metadata", "metadataType"], + output: ["output", "outputType"], +}; + +const SMART_REF_PREFIX = "sc"; + +function smartColumnRef(index: number): string { + return `${SMART_REF_PREFIX}${index + 1}`; +} + +function parseSmartColumnRef(ref: string): number | undefined { + if (!ref.startsWith(SMART_REF_PREFIX)) return undefined; + const n = Number(ref.slice(SMART_REF_PREFIX.length)); + return Number.isInteger(n) && n >= 1 ? n - 1 : undefined; +} + +/** + * Build the Postgres `select` for a page from the visible columns. Fields for + * shown standard columns are added on top of the always-selected set (a no-op + * while that set is the full scalar contract); payload/output are hydrated + * solely when a smart column references them. + */ +export function deriveRunSelect( + visibleStandardIds: readonly RunColumnId[], + smartSources: readonly SmartColumnSource[] +): Prisma.TaskRunSelect { + const select: Prisma.TaskRunSelect = {}; + + const add = (field: RunSelectField) => { + (select as Record)[field] = true; + }; + + for (const field of ALWAYS_SELECTED_FIELDS) add(field); + + for (const id of visibleStandardIds) { + const def = STANDARD_COLUMNS_BY_ID.get(id); + if (!def) continue; + for (const field of def.fields) add(field); + } + + for (const source of smartSources) { + for (const field of SMART_SOURCE_FIELDS[source]) add(field); + } + + return select; +} + +export type RunColumnRuntime = { + isManagedCloud: boolean; + isDevelopment: boolean; +}; + +function isColumnAvailable(def: StandardColumnDef, runtime: RunColumnRuntime): boolean { + switch (def.gate) { + case "managedCloud": + return runtime.isManagedCloud; + case "nonDev": + return !runtime.isDevelopment; + default: + return true; + } +} + +export function availableStandardColumns(runtime: RunColumnRuntime): StandardColumnDef[] { + return STANDARD_COLUMNS.filter((def) => isColumnAvailable(def, runtime)); +} + +function escapeSmartPart(value: string): string { + return value.replace(/%/g, "%25").replace(/:/g, "%3A"); +} + +function unescapeSmartPart(value: string): string { + return value.replace(/%3A/g, ":").replace(/%25/g, "%"); +} + +export function encodeSmartColumn(def: SmartColumnDef): string { + return [def.source, escapeSmartPart(def.path), escapeSmartPart(def.label), def.displayAs].join( + ":" + ); +} + +export function decodeSmartColumn(raw: string): SmartColumnDef | undefined { + const parts = raw.split(":"); + if (parts.length < 4) return undefined; + + const [source, path, label, displayAs] = parts; + if (!SMART_COLUMN_SOURCES.includes(source as SmartColumnSource)) return undefined; + if (!SMART_COLUMN_DISPLAYS.includes(displayAs as SmartColumnDisplay)) return undefined; + + const decodedPath = unescapeSmartPart(path); + if (decodedPath.length === 0) return undefined; + + return { + source: source as SmartColumnSource, + path: decodedPath, + label: unescapeSmartPart(label), + displayAs: displayAs as SmartColumnDisplay, + }; +} + +export type ResolvedColumn = + | { kind: "standard"; def: StandardColumnDef } + | { kind: "smart"; index: number; def: SmartColumnDef }; + +/** A column in the popover's full display order, with its current visibility. */ +export type LayoutColumn = { col: ResolvedColumn; hidden: boolean }; + +export type ColumnLayout = { + /** Every column in display order, hidden ones included (drives the popover). */ + ordered: LayoutColumn[]; + /** Shown columns in display order (drives the table). */ + visible: ResolvedColumn[]; + /** All decoded smart columns (visible or not), indexed by position. */ + smartColumns: SmartColumnDef[]; + /** Whether the layout differs from the default (drives "Reset to default"). */ + isCustomized: boolean; +}; + +export type ColumnLayoutParams = { cols: string[]; sc: string[]; hide: string[] }; +export type EncodedColumnLayout = { cols: string[]; sc: string[]; hide: string[] }; + +/** + * The order columns take when `cols` is absent: standard columns in default + * order, then smart columns in their `sc` definition order. + */ +function canonicalOrder(available: StandardColumnDef[], smartCount: number): string[] { + return [ + ...available.map((def) => def.id as string), + ...Array.from({ length: smartCount }, (_, i) => smartColumnRef(i)), + ]; +} + +/** + * Resolve the on-screen layout from the URL params and the runtime gates. + * `cols` is present only when the order differs from the default; otherwise the + * default order is used. `hide` lists the columns that are hidden but still + * occupy their slot, so hiding a column does not rewrite the whole order. + */ +export function resolveColumnLayout( + params: ColumnLayoutParams, + runtime: RunColumnRuntime +): ColumnLayout { + const available = availableStandardColumns(runtime); + const availableById = new Map(available.map((c) => [c.id, c] as const)); + const smartColumns = params.sc + .map(decodeSmartColumn) + .filter((c): c is SmartColumnDef => c !== undefined); + const hideSet = new Set(params.hide); + + const baseTokens = + params.cols.length > 0 ? params.cols : canonicalOrder(available, smartColumns.length); + + const ordered: LayoutColumn[] = []; + const seenStandard = new Set(); + const seenSmart = new Set(); + + for (const token of baseTokens) { + const smartIndex = parseSmartColumnRef(token); + if (smartIndex !== undefined) { + const def = smartColumns[smartIndex]; + if (!def || seenSmart.has(smartIndex)) continue; + ordered.push({ col: { kind: "smart", index: smartIndex, def }, hidden: hideSet.has(token) }); + seenSmart.add(smartIndex); + continue; + } + + if (seenStandard.has(token as RunColumnId)) continue; + const def = availableById.get(token as RunColumnId); + if (!def) continue; + ordered.push({ + col: { kind: "standard", def }, + hidden: hideSet.has(token) && !def.locked, + }); + seenStandard.add(def.id); + } + + ensureAllStandardColumnsPresent(ordered, seenStandard, available); + + for (let i = 0; i < smartColumns.length; i++) { + if (seenSmart.has(i)) continue; + ordered.push({ + col: { kind: "smart", index: i, def: smartColumns[i] }, + hidden: hideSet.has(smartColumnRef(i)), + }); + } + + const visible = ordered.filter((o) => !o.hidden).map((o) => o.col); + const isCustomized = params.cols.length > 0 || params.hide.length > 0 || smartColumns.length > 0; + return { ordered, visible, smartColumns, isCustomized }; +} + +/** + * Any available standard column missing from `cols` (a locked column, or one + * added after a URL was saved) is inserted, shown, at its default position. + */ +function ensureAllStandardColumnsPresent( + ordered: LayoutColumn[], + seenStandard: Set, + available: StandardColumnDef[] +): void { + const defaultIndex = new Map(available.map((def, index) => [def.id, index] as const)); + for (const def of available) { + if (seenStandard.has(def.id)) continue; + const target = defaultIndex.get(def.id) ?? 0; + let insertAt = ordered.length; + for (let i = 0; i < ordered.length; i++) { + const { col } = ordered[i]; + if (col.kind === "standard" && (defaultIndex.get(col.def.id) ?? 0) > target) { + insertAt = i; + break; + } + } + ordered.splice(insertAt, 0, { col: { kind: "standard", def }, hidden: false }); + seenStandard.add(def.id); + } +} + +/** + * Serialize a layout to compact `cols`/`sc`/`hide` params. `cols` is omitted + * whenever the order still matches the default, so hiding a column produces just + * a `hide` entry rather than the entire ordered list. All arrays empty means the + * default layout, and the caller deletes the keys. + */ +export function encodeColumnLayout( + ordered: LayoutColumn[], + runtime: RunColumnRuntime +): EncodedColumnLayout { + const available = availableStandardColumns(runtime); + + const sc: string[] = []; + const smartRefByIndex = new Map(); + for (const { col } of ordered) { + if (col.kind === "smart") { + const ref = smartColumnRef(sc.length); + smartRefByIndex.set(col.index, ref); + sc.push(encodeSmartColumn(col.def)); + } + } + + const tokenFor = (col: ResolvedColumn) => + col.kind === "standard" ? (col.def.id as string) : (smartRefByIndex.get(col.index) as string); + + const baseTokens = ordered.map(({ col }) => tokenFor(col)); + const hide = ordered.filter((o) => o.hidden).map(({ col }) => tokenFor(col)); + + const canonical = canonicalOrder(available, sc.length); + const orderIsDefault = + baseTokens.length === canonical.length && baseTokens.every((t, i) => t === canonical[i]); + + return { cols: orderIsDefault ? [] : baseTokens, sc, hide }; +} + +/** + * Parse the raw URL values into layout params. `cols` and `hide` are single + * comma-joined params; `sc` is repeated. + */ +export function parseColumnParams( + cols: string | null | undefined, + sc: string[], + hide: string | null | undefined +): ColumnLayoutParams { + const split = (value: string | null | undefined) => + value ? value.split(",").filter(Boolean) : []; + return { cols: split(cols), sc, hide: split(hide) }; +} + +/** The set of smart-column sources referenced by the visible layout. */ +export function visibleSmartSources(visible: ResolvedColumn[]): SmartColumnSource[] { + const sources = new Set(); + for (const col of visible) { + if (col.kind === "smart") sources.add(col.def.source); + } + return Array.from(sources); +} + +/** Visible standard column ids, for select derivation. */ +export function visibleStandardIds(visible: ResolvedColumn[]): RunColumnId[] { + return visible.filter((c) => c.kind === "standard").map((c) => c.def.id); +} diff --git a/apps/webapp/app/components/runs/v3/smartColumnCell.tsx b/apps/webapp/app/components/runs/v3/smartColumnCell.tsx new file mode 100644 index 00000000000..57850bbeb75 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/smartColumnCell.tsx @@ -0,0 +1,92 @@ +import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; +import { Badge } from "~/components/primitives/Badge"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { cn } from "~/utils/cn"; +import type { SmartColumnDef } from "./runColumns"; +import type { SmartCellValue } from "./smartColumnData"; + +/** Number and duration columns right-align and use tabular figures. */ +export function isNumericSmartDisplay(display: SmartColumnDef["displayAs"]): boolean { + return display === "number" || display === "duration"; +} + +function stringifySmartValue(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Coerce to a finite number only from an actual number or a non-empty numeric + * string. Returns NaN for null/boolean/empty-string/array/object so those fall + * back to their raw rendering instead of coercing to a misleading 0. + */ +function toFiniteNumber(value: unknown): number { + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim().length > 0) return Number(value); + return NaN; +} + +function renderSmartValue(value: unknown, displayAs: SmartColumnDef["displayAs"]): React.ReactNode { + switch (displayAs) { + case "number": { + const n = toFiniteNumber(value); + return Number.isFinite(n) ? n.toLocaleString() : stringifySmartValue(value); + } + case "duration": { + const n = toFiniteNumber(value); + return Number.isFinite(n) + ? formatDurationMilliseconds(n, { style: "short" }) + : stringifySmartValue(value); + } + case "badge": + return {stringifySmartValue(value)}; + default: + return stringifySmartValue(value); + } +} + +/** + * The inner content of a smart-column cell (no table/row wrapper), shared by the + * runs table and the add-column preview so both look identical. `offloaded` + * shows a "Too large" tooltip, an absent path shows "–", and an in-flight run's + * value is dotted-underlined to mark it provisional. + */ +export function SmartCellContent({ + cell, + def, + provisional, +}: { + cell: SmartCellValue; + def: SmartColumnDef; + provisional: boolean; +}) { + if (cell.state === "offloaded") { + return ( + + Too large + + } + content={`This run's ${def.source} is offloaded to object storage instead of the run row. Open the run to read it.`} + /> + ); + } + + if (cell.state === "empty") { + return ; + } + + return ( + + {renderSmartValue(cell.value, def.displayAs)} + + ); +} diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.test.ts b/apps/webapp/app/components/runs/v3/smartColumnData.test.ts new file mode 100644 index 00000000000..63605e9e11d --- /dev/null +++ b/apps/webapp/app/components/runs/v3/smartColumnData.test.ts @@ -0,0 +1,140 @@ +import superjson from "superjson"; +import { describe, expect, it } from "vitest"; +import { extractSmartValue, getAtPath, labelFromPath, parseSource } from "./smartColumnData"; + +describe("parseSource", () => { + it("reports empty for missing data", () => { + expect(parseSource({ data: null, dataType: "application/json" })).toEqual({ state: "empty" }); + expect(parseSource({ data: undefined, dataType: "application/json" })).toEqual({ + state: "empty", + }); + expect(parseSource({ data: "", dataType: "application/json" })).toEqual({ state: "empty" }); + }); + + it("reports offloaded for application/store without touching the path", () => { + expect(parseSource({ data: "s3://bucket/key", dataType: "application/store" })).toEqual({ + state: "offloaded", + }); + }); + + it("parses application/json", () => { + expect(parseSource({ data: '{"a":1}', dataType: "application/json" })).toEqual({ + state: "parsed", + value: { a: 1 }, + }); + }); + + it("parses application/super+json (dates survive)", () => { + const serialized = superjson.stringify({ when: new Date("2026-01-01T00:00:00.000Z"), n: 2 }); + const parsed = parseSource({ data: serialized, dataType: "application/super+json" }); + expect(parsed.state).toBe("parsed"); + if (parsed.state === "parsed") { + const value = parsed.value as { when: Date; n: number }; + expect(value.when).toBeInstanceOf(Date); + expect(value.n).toBe(2); + } + }); + + it("defaults an unknown/absent content type to raw string and json respectively", () => { + expect(parseSource({ data: "hello", dataType: "text/plain" })).toEqual({ + state: "parsed", + value: "hello", + }); + expect(parseSource({ data: '{"a":1}', dataType: undefined })).toEqual({ + state: "parsed", + value: { a: 1 }, + }); + }); + + it("falls back to the raw string on malformed json", () => { + expect(parseSource({ data: "{not json", dataType: "application/json" })).toEqual({ + state: "parsed", + value: "{not json", + }); + }); +}); + +describe("getAtPath", () => { + const obj = { + failed: 3, + suites: [{ name: "nightly" }, { name: "smoke" }], + "a.b": { c: 7 }, + nested: { deep: { value: "x" } }, + }; + + it("reads a top-level key with and without $ / dot prefixes", () => { + expect(getAtPath(obj, "$.failed")).toBe(3); + expect(getAtPath(obj, "failed")).toBe(3); + expect(getAtPath(obj, ".failed")).toBe(3); + }); + + it("reads array indices and nested keys", () => { + expect(getAtPath(obj, "$.suites[0].name")).toBe("nightly"); + expect(getAtPath(obj, "suites[1].name")).toBe("smoke"); + expect(getAtPath(obj, "nested.deep.value")).toBe("x"); + }); + + it("reads quoted bracket keys containing a dot", () => { + expect(getAtPath(obj, "$['a.b'].c")).toBe(7); + }); + + it("returns undefined for missing segments", () => { + expect(getAtPath(obj, "$.nope")).toBeUndefined(); + expect(getAtPath(obj, "$.suites[9].name")).toBeUndefined(); + expect(getAtPath(obj, "$.failed.x")).toBeUndefined(); + }); + + it("rejects malformed paths", () => { + expect(getAtPath(obj, "$.a..b")).toBeUndefined(); + expect(getAtPath(obj, "$.a[b]")).toBeUndefined(); + }); + + it("reads bracket keys with escaped quotes and backslashes (the form childPath emits)", () => { + expect(getAtPath({ "a'b": 1 }, "$['a\\'b']")).toBe(1); + expect(getAtPath({ "a\\b": 2 }, "$['a\\\\b']")).toBe(2); + }); + + it("computes a dot-accessed .length for arrays, strings, and objects", () => { + const data = { tags: ["a", "b", "c"], name: "hello", info: { x: 1, y: 2 }, count: 5 }; + expect(getAtPath(data, "$.tags.length")).toBe(3); + expect(getAtPath(data, "$.name.length")).toBe(5); + expect(getAtPath(data, "$.info.length")).toBe(2); + expect(getAtPath(data, "$.count.length")).toBeUndefined(); + }); + + it("treats a bracket-quoted ['length'] as a literal key, not the computed length", () => { + expect(getAtPath({ length: 42 }, "$['length']")).toBe(42); + expect(getAtPath({ length: 42 }, "$.length")).toBe(1); + }); +}); + +describe("extractSmartValue", () => { + it("passes through empty and offloaded states", () => { + expect(extractSmartValue({ state: "empty" }, "$.a")).toEqual({ state: "empty" }); + expect(extractSmartValue({ state: "offloaded" }, "$.a")).toEqual({ state: "offloaded" }); + }); + + it("returns the value when present and empty when absent", () => { + const parsed = { state: "parsed" as const, value: { a: { b: 5 } } }; + expect(extractSmartValue(parsed, "$.a.b")).toEqual({ state: "value", value: 5 }); + expect(extractSmartValue(parsed, "$.a.c")).toEqual({ state: "empty" }); + }); +}); + +describe("labelFromPath", () => { + it("uses the last named key", () => { + expect(labelFromPath("$.suites[0].name")).toBe("name"); + expect(labelFromPath("$.failed")).toBe("failed"); + expect(labelFromPath("failed")).toBe("failed"); + }); + + it("skips trailing array indices and uses the array's key", () => { + expect(labelFromPath("$.tags[0]")).toBe("tags"); + expect(labelFromPath("$.matrix[0][1]")).toBe("matrix"); + expect(labelFromPath("$.a.b[3]")).toBe("b"); + }); + + it("keeps a numeric object key that was addressed with quotes", () => { + expect(labelFromPath("$.data['2024']")).toBe("2024"); + }); +}); diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.ts b/apps/webapp/app/components/runs/v3/smartColumnData.ts new file mode 100644 index 00000000000..4d9885bce59 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/smartColumnData.ts @@ -0,0 +1,154 @@ +import superjson from "superjson"; + +export type SourcePacket = { + data: string | null | undefined; + dataType: string | null | undefined; +}; + +export type ParsedSource = + | { state: "empty" } + | { state: "offloaded" } + | { state: "parsed"; value: unknown }; + +/** + * Parse a raw payload/metadata/output packet on the client, respecting its + * content type. Never fetches: an offloaded (`application/store`) packet returns + * the `offloaded` state rather than its object-store path. A parse failure falls + * back to the raw string so a malformed value degrades to text, not a throw. + */ +export function parseSource(packet: SourcePacket): ParsedSource { + const { data, dataType } = packet; + if (data === null || data === undefined || data === "") { + return { state: "empty" }; + } + + const type = dataType ?? "application/json"; + if (type === "application/store") { + return { state: "offloaded" }; + } + + try { + switch (type) { + case "application/json": + return { state: "parsed", value: JSON.parse(data) }; + case "application/super+json": + return { state: "parsed", value: superjson.parse(data) }; + default: + return { state: "parsed", value: data }; + } + } catch { + return { state: "parsed", value: data }; + } +} + +export type SmartCellValue = + | { state: "empty" } + | { state: "offloaded" } + | { state: "value"; value: unknown }; + +export function extractSmartValue(parsed: ParsedSource, path: string): SmartCellValue { + if (parsed.state === "empty") return { state: "empty" }; + if (parsed.state === "offloaded") return { state: "offloaded" }; + + const value = getAtPath(parsed.value, path); + if (value === undefined) return { state: "empty" }; + return { state: "value", value }; +} + +const PATH_TOKEN_RE = /\.([^.[\]]+)|\[(\d+)\]|\['((?:\\.|[^'\\])*)'\]|\["((?:\\.|[^"\\])*)"\]/g; + +/** Reverse the backslash escaping applied to bracket-notation keys (e.g. `\'` -> `'`). */ +function unescapeBracketKey(raw: string): string { + return raw.replace(/\\(.)/g, "$1"); +} + +type PathToken = + | { kind: "dot"; key: string } + | { kind: "key"; key: string } + | { kind: "index"; index: number }; + +/** + * Read a value out of a parsed object with dot/bracket notation. Accepts a + * leading `$`, dotted keys, and numeric or quoted bracket indices, e.g. + * `$.failed`, `suites[0].name`, `$['a.b'].c`. Returns undefined when any + * segment is missing. + * + * A dot-accessed `.length` is computed: array/string length, or an object's + * key count. To read a real property literally named `length`, use a bracket + * key (`['length']`). + */ +export function getAtPath(root: unknown, path: string): unknown { + let normalized = path.trim(); + if (normalized.startsWith("$")) normalized = normalized.slice(1); + if (normalized.length === 0) return root; + if (!normalized.startsWith(".") && !normalized.startsWith("[")) { + normalized = `.${normalized}`; + } + + const tokens: PathToken[] = []; + let lastIndex = 0; + PATH_TOKEN_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PATH_TOKEN_RE.exec(normalized)) !== null) { + if (match.index !== lastIndex) return undefined; + lastIndex = PATH_TOKEN_RE.lastIndex; + + if (match[1] !== undefined) tokens.push({ kind: "dot", key: match[1] }); + else if (match[2] !== undefined) tokens.push({ kind: "index", index: Number(match[2]) }); + else if (match[3] !== undefined) + tokens.push({ kind: "key", key: unescapeBracketKey(match[3]) }); + else if (match[4] !== undefined) + tokens.push({ kind: "key", key: unescapeBracketKey(match[4]) }); + } + if (lastIndex !== normalized.length) return undefined; + + let current: unknown = root; + for (const token of tokens) { + if (current === null || current === undefined) return undefined; + + if (token.kind === "dot" && token.key === "length") { + if (Array.isArray(current) || typeof current === "string") { + current = current.length; + } else if (typeof current === "object") { + current = Object.keys(current).length; + } else { + return undefined; + } + continue; + } + + if (typeof current !== "object") return undefined; + const key = token.kind === "index" ? token.index : token.key; + current = (current as Record)[key]; + } + return current; +} + +/** + * Default column label from a path: its last named key, ignoring trailing array + * indices (so `$.tags[0]` labels as `tags`, not `0`). Falls back to the last + * segment, then the raw path. + */ +export function labelFromPath(path: string): string { + let normalized = path.trim(); + if (normalized.startsWith("$")) normalized = normalized.slice(1); + if (normalized.length > 0 && !normalized.startsWith(".") && !normalized.startsWith("[")) { + normalized = `.${normalized}`; + } + + const re = /\.([^.[\]]+)|\[(\d+)\]|\['((?:\\.|[^'\\])*)'\]|\["((?:\\.|[^"\\])*)"\]/g; + let lastKey: string | undefined; + let lastSegment: string | undefined; + let match: RegExpExecArray | null; + while ((match = re.exec(normalized)) !== null) { + const bracketKey = match[3] ?? match[4]; + const key = match[1] ?? (bracketKey !== undefined ? unescapeBracketKey(bracketKey) : undefined); + if (key !== undefined) { + lastKey = key; + lastSegment = key; + } else if (match[2] !== undefined) { + lastSegment = match[2]; + } + } + return lastKey ?? lastSegment ?? path; +} diff --git a/apps/webapp/app/components/schedules/ScheduleInspector.tsx b/apps/webapp/app/components/schedules/ScheduleInspector.tsx index 5b65b73afb7..6f6874d2bb6 100644 --- a/apps/webapp/app/components/schedules/ScheduleInspector.tsx +++ b/apps/webapp/app/components/schedules/ScheduleInspector.tsx @@ -184,6 +184,7 @@ export function ScheduleInspector({
Last 5 runs { const options: RunListOptions = { projectId: project.id, + columns: { visibleStandardIds: [], smartSources: ["metadata"] }, }; // pagination @@ -310,7 +311,7 @@ export class ApiRunListPresenter extends BasePresenter { const metadata = await parsePacket( { data: run.metadata ?? undefined, - dataType: run.metadataType, + dataType: run.metadataType ?? "application/json", }, { filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"], diff --git a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts index 6eb299a5e7b..38149abe5e7 100644 --- a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts @@ -12,6 +12,12 @@ import { type NextRunList, } from "~/presenters/v3/NextRunListPresenter.server"; import { sortVersionsDescending } from "~/utils/semver"; +import type { RunColumnId, SmartColumnSource } from "~/components/runs/v3/runColumns"; + +type RunColumnsSelect = { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; +}; const errorGroupGranularity = new TimeGranularity([ { max: "1h", granularity: "1m" }, @@ -33,6 +39,7 @@ export type ErrorGroupOptions = { to?: number; cursor?: string; direction?: Direction; + columns?: RunColumnsSelect; }; const DEFAULT_RUNS_PAGE_SIZE = 25; @@ -99,6 +106,7 @@ export class ErrorGroupPresenter extends BasePresenter { to, cursor, direction, + columns, }: ErrorGroupOptions ) { const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId); @@ -128,6 +136,7 @@ export class ErrorGroupPresenter extends BasePresenter { to: time.to.getTime(), cursor, direction, + columns, }), this.getState(environmentId, summary?.taskIdentifier, fingerprint), ]); @@ -397,6 +406,7 @@ export class ErrorGroupPresenter extends BasePresenter { to?: number; cursor?: string; direction?: Direction; + columns?: RunColumnsSelect; } ): Promise { const runListPresenter = new NextRunListPresenter(this.replica, this.clickhouse); @@ -412,6 +422,7 @@ export class ErrorGroupPresenter extends BasePresenter { to: options.to, cursor: options.cursor, direction: options.direction, + columns: options.columns, }); if (result.runs.length === 0) { diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index c9a120334f3..52836fad293 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -25,6 +25,11 @@ import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus"; import { runTriggeredAt } from "~/v3/runTimestamps"; +import { + deriveRunSelect, + type RunColumnId, + type SmartColumnSource, +} from "~/components/runs/v3/runColumns"; // Positive-only cache: only envs known to have runs are stored (empty envs are re-checked), // so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis. @@ -81,6 +86,15 @@ export type RunListOptions = { pageSize?: number; // Run the empty-state "has any run ever" probe. Only the runs list consumes it. includeHasAnyRuns?: boolean; + /** + * Visible-column set used to derive the Postgres select. Omitted => the + * default select (all fields, no payload/output). Provided by the list route + * so payload/output are only hydrated when a smart column references them. + */ + columns?: { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; + }; }; const DEFAULT_PAGE_SIZE = 25; @@ -159,6 +173,7 @@ export class NextRunListPresenter { cursor, pageSize = DEFAULT_PAGE_SIZE, includeHasAnyRuns = false, + columns, }: RunListOptions ) { //get the time values from the raw values (including a default period) @@ -255,7 +270,12 @@ export class NextRunListPresenter { return date > now ? now : date; } + const runSelect = columns + ? deriveRunSelect(columns.visibleStandardIds, columns.smartSources) + : undefined; + const { runs, pagination } = await runsRepository.listRuns({ + runSelect, organizationId, environmentId, projectId, @@ -335,6 +355,10 @@ export class NextRunListPresenter { rootTaskRunId: run.rootTaskRunId, metadata: run.metadata, metadataType: run.metadataType, + payload: run.payload, + payloadType: run.payloadType, + output: run.output, + outputType: run.outputType, machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined, queue: { name: run.queue.replace("task/", ""), diff --git a/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts b/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts index ce7654cc1bc..25b3e5ad2b9 100644 --- a/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts +++ b/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts @@ -19,5 +19,11 @@ export function mapRunToLiveFields(run: ListedRun) { usageDurationMs: Number(run.usageDurationMs), costInCents: run.costInCents, baseCostInCents: run.baseCostInCents, + metadata: run.metadata, + metadataType: run.metadataType, + payload: run.payload, + payloadType: run.payloadType, + output: run.output, + outputType: run.outputType, }; } diff --git a/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts b/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts new file mode 100644 index 00000000000..1a2c7c8d3c0 --- /dev/null +++ b/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts @@ -0,0 +1,34 @@ +import { + parseColumnParams, + resolveColumnLayout, + visibleSmartSources, + visibleStandardIds, + type RunColumnId, + type SmartColumnSource, +} from "~/components/runs/v3/runColumns"; + +/** + * Read the runs-list column state (`cols`/`sc`) off the request and resolve the + * column set the presenter needs to derive its Postgres select. Gates are + * resolved permissively here because they do not affect the always-selected + * fields; only the referenced smart-column sources change what is hydrated. + */ +export function getRunColumnsForSelect(request: Request): { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; +} { + const url = new URL(request.url); + const layout = resolveColumnLayout( + parseColumnParams( + url.searchParams.get("cols"), + url.searchParams.getAll("sc"), + url.searchParams.get("hide") + ), + { isManagedCloud: true, isDevelopment: false } + ); + + return { + visibleStandardIds: visibleStandardIds(layout.visible), + smartSources: visibleSmartSources(layout.visible), + }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx index d2197776f69..e0354185363 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx @@ -38,6 +38,8 @@ import { type AgentDetail, } from "~/presenters/v3/AgentDetailPresenter.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; @@ -162,6 +164,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + columns: getRunColumnsForSelect(request), }) .catch(() => null); @@ -335,11 +338,14 @@ export default function Page() { ) : ( - - - {(list) => (list ? : null)} - - + <> + + + + {(list) => (list ? : null)} + + + )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 1946312aac3..f4035b62279 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -74,6 +74,8 @@ import { type ErrorGroupSummary, } from "~/presenters/v3/ErrorGroupPresenter.server"; import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { rbac } from "~/services/rbac.server"; @@ -268,6 +270,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + columns: getRunColumnsForSelect(request), }) .catch((error) => { if (error instanceof ServiceValidationError) { @@ -534,6 +537,12 @@ function ErrorGroupDetail({ > Bulk replay… + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 50bfb36a583..7b3ba95c1da 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -39,6 +39,7 @@ import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; import { $replica } from "~/db.server"; @@ -52,6 +53,7 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { setRootOnlyFilterPreference, @@ -123,6 +125,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { projectId: project.id, ...filters, includeHasAnyRuns: true, + columns: getRunColumnsForSelect(request), }); // Only persist rootOnly when no tasks are filtered. While a task filter is active, @@ -402,6 +405,7 @@ function RunsList({ )} + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts index e443fb84cfb..8127533634b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts @@ -87,6 +87,12 @@ function patchVisibleRunsWithLiveUpdates(currentRuns: ListedRun[], liveRuns: Liv usageDurationMs: update.usageDurationMs, costInCents: update.costInCents, baseCostInCents: update.baseCostInCents, + metadata: update.metadata !== undefined ? update.metadata : run.metadata, + metadataType: update.metadataType !== undefined ? update.metadataType : run.metadataType, + payload: update.payload !== undefined ? update.payload : run.payload, + payloadType: update.payloadType !== undefined ? update.payloadType : run.payloadType, + output: update.output !== undefined ? update.output : run.output, + outputType: update.outputType !== undefined ? update.outputType : run.outputType, }; }); } @@ -242,6 +248,13 @@ export function useRunsLiveReload({ searchParams.set("runIds", activeRunIdsParam); } + const locationParams = new URLSearchParams(location.search); + const colsValue = locationParams.get("cols"); + if (colsValue) searchParams.set("cols", colsValue); + const hideValue = locationParams.get("hide"); + if (hideValue) searchParams.set("hide", hideValue); + for (const smart of locationParams.getAll("sc")) searchParams.append("sc", smart); + if (checkForNewRuns) { appendNewRunsSearchParams(searchParams, { locationSearch: location.search, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 60a3aaf1043..684dceef70d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -75,6 +75,8 @@ import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server"; import { TaskDetailPresenter, @@ -219,6 +221,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { cursor, direction, includeHasAnyRuns: true, + columns: getRunColumnsForSelect(request), }) .catch(() => null); @@ -369,6 +372,7 @@ export default function Page() { onClick={() => showNewRunsRef.current()} /> ) : null} + {(list) => (list ? : null)} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index db829fc74b6..ffd7a400b95 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -46,6 +46,8 @@ import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { TaskDetailPresenter, type TaskActivity, @@ -163,6 +165,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { cursor, direction, includeHasAnyRuns: true, + columns: getRunColumnsForSelect(request), }) .catch(() => null); @@ -266,6 +269,7 @@ export default function Page() { onClick={() => showNewRunsRef.current()} /> ) : null} + {(list) => (list ? : null)} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx index 232ad8d7cce..8903e53ede9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx @@ -136,6 +136,7 @@ export default function Page() { 0 @@ -45,6 +48,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { projectId: project.id, environmentId: environment.id, runId: runIds, + runSelect: deriveRunSelect( + columns.visibleStandardIds, + columns.smartSources.filter((source) => source !== "payload") + ), page: { size: 100 }, }) .then(({ runs: listedRuns }) => listedRuns.map(mapRunToLiveFields)) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts new file mode 100644 index 00000000000..b1edcd0e22b --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts @@ -0,0 +1,78 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { deriveRunSelect, type SmartColumnSource } from "~/components/runs/v3/runColumns"; +import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { loadProjectEnvironmentFromRequest } from "~/services/loadProjectEnvironmentFromRequest.server"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { $replica } from "~/db.server"; +import { isFinalRunStatus } from "~/v3/taskStatus"; + +/** How many recent runs the smart-column preview can page through. */ +const SAMPLE_RUN_COUNT = 10; + +const SAMPLE_SOURCES: SmartColumnSource[] = ["payload", "metadata", "output"]; + +function parseSampleSource(value: string | null): SmartColumnSource { + return SAMPLE_SOURCES.includes(value as SmartColumnSource) + ? (value as SmartColumnSource) + : "payload"; +} + +/** + * The most recent runs for the current filters, with their raw + * payload/metadata/output packets, feeding the "Add smart column" preview. The + * client picks which run to sample, parses, and resolves the JSON path; the + * server never parses (same rule as the list). + */ +export async function loader({ request, params }: LoaderFunctionArgs) { + const { project, environment } = await loadProjectEnvironmentFromRequest(request, params); + const filters = await getRunFiltersFromRequest(request); + const source = parseSampleSource(new URL(request.url).searchParams.get("source")); + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "runsList" + ); + const runsRepository = new RunsRepository({ clickhouse, prisma: $replica }); + + const { runs } = await runsRepository.listRuns({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + tasks: filters.tasks, + versions: filters.versions, + statuses: filters.statuses, + tags: filters.tags, + scheduleId: filters.scheduleId, + period: filters.period, + from: filters.from, + to: filters.to, + rootOnly: filters.rootOnly, + batchId: filters.batchId, + runId: filters.runId, + bulkId: filters.bulkId, + queues: filters.queues, + regions: filters.regions, + machines: filters.machines, + errorId: filters.errorId, + taskKinds: filters.sources, + runSelect: deriveRunSelect([], [source]), + page: { size: SAMPLE_RUN_COUNT }, + }); + + return { + runs: runs.map((run) => ({ + friendlyId: run.friendlyId, + status: run.status, + hasFinished: isFinalRunStatus(run.status), + startedAt: (run.startedAt ?? run.lockedAt)?.toISOString(), + createdAt: run.createdAt.toISOString(), + payload: run.payload, + payloadType: run.payloadType, + metadata: run.metadata, + metadataType: run.metadataType, + output: run.output, + outputType: run.outputType, + })), + }; +} diff --git a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts index 808e30431ea..2e911e5e958 100644 --- a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts @@ -3,6 +3,7 @@ import { ErrorId, RunId } from "@trigger.dev/core/v3/isomorphic"; import { type FilterRunsOptions, type IRunsRepository, + type ListedRun, type ListRunsOptions, type RunIdsPage, type RunListInputOptions, @@ -15,9 +16,48 @@ import { decodeRunsCursor, encodeRunsCursor } from "./runsCursor.server"; import { runStore } from "~/v3/runStore.server"; import { type PrismaClientOrTransaction } from "~/db.server"; -import { boundedIn } from "@trigger.dev/database"; +import { boundedIn, type Prisma } from "@trigger.dev/database"; type RunCursorRow = { runId: string; createdAt: number }; +/** + * Default hydrate select for the runs list, used when a caller does not derive + * one from the visible columns (bulk actions, the live poll). Kept in sync with + * the `ListedRun` payload type. + */ +const LIST_RUN_DEFAULT_SELECT = { + id: true, + friendlyId: true, + taskIdentifier: true, + taskVersion: true, + runtimeEnvironmentId: true, + status: true, + createdAt: true, + queueTimestamp: true, + scheduleId: true, + startedAt: true, + lockedAt: true, + delayUntil: true, + updatedAt: true, + completedAt: true, + isTest: true, + spanId: true, + idempotencyKey: true, + ttl: true, + expiredAt: true, + costInCents: true, + baseCostInCents: true, + usageDurationMs: true, + runTags: true, + depth: true, + rootTaskRunId: true, + batchId: true, + machinePreset: true, + queue: true, + workerQueue: true, + region: true, + annotations: true, +} satisfies Prisma.TaskRunSelect; + /** * Hydrates a set of rows for a ClickHouse-derived run-id set against the given * read client. The closure MUST select `id` so `#hydrateRunsByIds` can key @@ -264,52 +304,24 @@ export class ClickHouseRunsRepository implements IRunsRepository { const store = this.options.runStore ?? runStore; - let runs = await this.#hydrateRunsByIds(runIds, (client, ids) => - store.findRuns( - { - where: { - id: { - in: boundedIn(ids), + const select: Prisma.TaskRunSelect = options.runSelect + ? { ...options.runSelect, id: true } + : LIST_RUN_DEFAULT_SELECT; + + let runs = await this.#hydrateRunsByIds( + runIds, + (client, ids) => + store.findRuns( + { + where: { + id: { + in: boundedIn(ids), + }, }, + select, }, - select: { - id: true, - friendlyId: true, - taskIdentifier: true, - taskVersion: true, - runtimeEnvironmentId: true, - status: true, - createdAt: true, - queueTimestamp: true, - scheduleId: true, - startedAt: true, - lockedAt: true, - delayUntil: true, - updatedAt: true, - completedAt: true, - isTest: true, - spanId: true, - idempotencyKey: true, - ttl: true, - expiredAt: true, - costInCents: true, - baseCostInCents: true, - usageDurationMs: true, - runTags: true, - depth: true, - rootTaskRunId: true, - batchId: true, - metadata: true, - metadataType: true, - machinePreset: true, - queue: true, - workerQueue: true, - region: true, - annotations: true, - }, - }, - client - ) + client + ) as Promise ); // ClickHouse is slightly delayed, so we're going to do in-memory status filtering too diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 431ed271883..0b1049125dd 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -119,17 +119,37 @@ export type ListedRun = Prisma.TaskRunGetPayload<{ depth: true; rootTaskRunId: true; batchId: true; - metadata: true; - metadataType: true; machinePreset: true; queue: true; workerQueue: true; region: true; annotations: true; }; -}>; +}> & { + /** + * Source blobs hydrated only when a smart column references them (see + * `runSelect`). Absent from the default list select; metadata is display-only + * on the list, payload/output can be large. + */ + payload?: string; + payloadType?: string; + output?: string | null; + outputType?: string; + metadata?: string | null; + metadataType?: string; +}; -export type ListRunsOptions = RunListInputOptions & Pagination; +export type ListRunsOptions = RunListInputOptions & + Pagination & { + /** + * Overrides the default list `select`. The runs list derives this from the + * visible columns so only the fields a shown column needs are hydrated (in + * particular payload/output are omitted unless a smart column asks). Must + * include `id` for hydration keying; behaviour-critical fields are enforced + * by the caller's `deriveRunSelect`. + */ + runSelect?: Prisma.TaskRunSelect; + }; export type TagListOptions = { organizationId: string; diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 80ce4c0a275..0c36292e6cd 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ "app/runEngine/services/**/*.test.ts", "app/utils/**/*.test.ts", "app/components/code/**/*.test.ts", + "app/components/runs/**/*.test.ts", "app/components/dashboard-agent/**/*.test.ts", "app/components/queues/**/*.test.ts", "app/routes/storybook.agent-ui/*.test.ts",