feat(webapp): customizable runs list with columns and smart columns (#4652)
## Summary Makes the runs list customizable. A new **Display** control lets you show, hide, and reorder columns, and add **smart columns** that pull a single value out of a run's payload, metadata, or output by JSON path (e.g. `$.failed`, `$.order.total`). Column choices live in the page URL, so a view can be bookmarked or shared. Applies to the global runs list and every per-task / scheduled / agent / webhook / error list, which all share one table. ID, Task, and Status can be reordered but not hidden. Smart columns are display-only (no sort or filter, which would defeat the ClickHouse sort key and cursor). ## How it works Columns come from a shared registry; the Postgres `select` is derived from the visible columns, so a run's large payload/output are only hydrated when a smart column actually references them. All JSON parsing for smart columns happens client-side, respecting the packet content type, parsed once per source per row. Offloaded (too-large) values and paths that aren't present render distinct placeholders rather than fetching per row. The live poll carries the same sources so smart-column values update in place. Scalar columns stay always-selected for now: the shared list presenter has a fixed output shape consumed by several routes and the live poll, and narrowing individual scalar fields would add no real query cost benefit on a single-row read. The select derivation is already column-driven, so tightening this later is a one-line change. ## Screenshots <img width="590" height="1028" alt="CleanShot 2026-08-21 at 16 48 17@2x" src="https://github.com/user-attachments/assets/86b39856-bfcc-47c0-85ed-ee6ccddc3590" /> <img width="1924" height="1528" alt="CleanShot 2026-08-21 at 16 48 27@2x" src="https://github.com/user-attachments/assets/6c766249-6d5b-45be-9330-c6caa75af7f7" /> <!-- conductor-workspace-link --> --- [Open workspace in Conductor](https://app.conductor.build/workspace/d6911080-2140-4de1-b88a-1b0623593caa) --------- Co-authored-by: James Ritchie <james@trigger.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 a view, bookmark it, or save it straight to your favorites.
|
||||
@@ -0,0 +1,9 @@
|
||||
export function ColumnsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="9" y1="19" x2="9" y2="5" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="15" y1="19" x2="15" y2="5" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function ResetIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7 3L4 6L7 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5 6H13.5C17.0899 6 20 8.91015 20 12.5C20 16.0899 17.0899 19 13.5 19H6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Marks a smart column: in the runs table header, the Columns popover, and the dialog preview. */
|
||||
export function SmartColumnIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.94723 12.4318L12.3011 3.53646C12.9468 2.63242 14.3689 3.24855 14.1511 4.33794L13.1543 9.32131C13.0905 9.64031 13.3346 9.93793 13.6599 9.93793H17.2138C18.0524 9.93793 18.5402 10.8859 18.0527 11.5682L11.6989 20.4636C11.0532 21.3676 9.63107 20.7515 9.84895 19.6621L10.8456 14.6788C10.9095 14.3598 10.6654 14.0621 10.3401 14.0621H6.78622C5.9476 14.0621 5.45978 13.1142 5.94723 12.4318Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ASK_AI_SHORTCUT, askAiCanOpen } from "~/components/dashboard-agent/ask-
|
||||
import { useDashboardAgentAvailable } from "~/components/dashboard-agent/dashboardAgentOpenRequest";
|
||||
import { NEW_CHAT_SHORTCUT } from "~/components/dashboard-agent/DashboardAgentHeader";
|
||||
import { TOGGLE_PANEL_SHORTCUT } from "~/components/dashboard-agent/dashboardAgentLauncher";
|
||||
import { COLUMNS_SHORTCUT } from "~/components/runs/v3/RunsDisplayOptions";
|
||||
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
@@ -142,6 +143,9 @@ function ShortcutContent() {
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<Header3>Runs page</Header3>
|
||||
<Shortcut name="Customize columns">
|
||||
<ShortcutKey shortcut={COLUMNS_SHORTCUT} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Bulk action: Cancel runs">
|
||||
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline";
|
||||
import { StarIcon as StarIconSolid } from "@heroicons/react/20/solid";
|
||||
import { useFetcher, useLocation, useSearchParams } from "@remix-run/react";
|
||||
import { useLocation, useSearchParams } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
import { useIsImpersonating } from "~/hooks/useOrganizations";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { useOptionalUser } from "~/hooks/useUser";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import {
|
||||
buildFavoriteLabel,
|
||||
canonicalFavoriteUrl,
|
||||
FAVORITE_SEARCH_PARAM,
|
||||
FAVORITES_ACTION_PATH,
|
||||
favoritePageUrl,
|
||||
resolvePageMeta,
|
||||
useFavorites,
|
||||
} from "./favoritePages";
|
||||
import { FAVORITE_SEARCH_PARAM, useFavoritePageToggle, useFavorites } from "./favoritePages";
|
||||
|
||||
/**
|
||||
* The star in the page header that favorites the current page (full URL, including filters and
|
||||
@@ -31,15 +22,10 @@ export function FavoritePageButton({
|
||||
className?: string;
|
||||
}) {
|
||||
const user = useOptionalUser();
|
||||
const isImpersonating = useIsImpersonating();
|
||||
const location = useLocation();
|
||||
const favorites = useFavorites();
|
||||
const fetcher = useFetcher();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
// The marker param and pagination position never count toward URL identity, so paging through
|
||||
// a favorited view keeps the same favorite (and never saves a soon-stale cursor)
|
||||
const url = favoritePageUrl(location.pathname, location.search);
|
||||
const { isFavorited, pageName, canFavorite, toggle } = useFavoritePageToggle(pageTitle);
|
||||
|
||||
// A marker that isn't one of this user's favorites came from a shared link (or a favorite
|
||||
// that's since been removed): clean it from the URL so the page behaves like a normal visit.
|
||||
@@ -58,34 +44,8 @@ export function FavoritePageButton({
|
||||
{ replace: true, preventScrollReset: true }
|
||||
);
|
||||
}, [hasForeignMarker, setSearchParams]);
|
||||
const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url);
|
||||
const isFavorited = existing !== undefined;
|
||||
// The tooltip names the favorite: its custom name once saved, else the label saving would use
|
||||
// (which includes detail-page ids and filter summaries, e.g. "Runs: Completed, last 7d")
|
||||
const pageName =
|
||||
existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle);
|
||||
|
||||
const toggle = () => {
|
||||
if (existing) {
|
||||
fetcher.submit(
|
||||
{ intent: "remove", id: existing.id },
|
||||
{ method: "POST", action: FAVORITES_ACTION_PATH }
|
||||
);
|
||||
} else {
|
||||
fetcher.submit(
|
||||
{
|
||||
intent: "add",
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
label: buildFavoriteLabel(location.pathname, location.search, pageTitle),
|
||||
icon: resolvePageMeta(location.pathname).icon,
|
||||
},
|
||||
{ method: "POST", action: FAVORITES_ACTION_PATH }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const showButton = user !== undefined && !isImpersonating;
|
||||
const showButton = canFavorite;
|
||||
|
||||
// Option+F reports event.key "ƒ" on macOS, but the hotkeys matcher falls back to the physical
|
||||
// event.code ("KeyF"), so the standard hook captures it; exact modifier matching keeps the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BeakerIcon } from "@heroicons/react/24/outline";
|
||||
import { IconChartHistogram } from "@tabler/icons-react";
|
||||
import { useFetchers, useLocation } from "@remix-run/react";
|
||||
import { useFetcher, useFetchers, useLocation } from "@remix-run/react";
|
||||
import { ClockIcon } from "~/assets/icons/ClockIcon";
|
||||
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
@@ -42,8 +42,10 @@ import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import { WebhookIcon } from "~/assets/icons/WebhookIcon";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { useIsImpersonating } from "~/hooks/useOrganizations";
|
||||
import { useOptionalUser } from "~/hooks/useUser";
|
||||
import { type FavoritePage } from "~/services/dashboardPreferences.server";
|
||||
import { RUN_COLUMN_SEARCH_PARAMS } from "../runs/v3/runColumns";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
|
||||
export const FAVORITES_ACTION_PATH = "/resources/preferences/favorites";
|
||||
@@ -144,7 +146,7 @@ const PAGINATION_PARAMS = ["cursor", "direction", "page"];
|
||||
* marker (presentation-only) and the pagination position (cursors go stale, and page N of a view
|
||||
* is not a different view). A favorite pins filters and tabs, never a transient page of them.
|
||||
*/
|
||||
export function favoritePageUrl(pathname: string, search: string): string {
|
||||
function favoritePageUrl(pathname: string, search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete(FAVORITE_SEARCH_PARAM);
|
||||
for (const param of PAGINATION_PARAMS) {
|
||||
@@ -156,7 +158,7 @@ export function favoritePageUrl(pathname: string, search: string): string {
|
||||
|
||||
/** favoritePageUrl for an already-joined URL, e.g. a favorite's stored one (which may predate
|
||||
* pagination stripping). */
|
||||
export function canonicalFavoriteUrl(url: string): string {
|
||||
function canonicalFavoriteUrl(url: string): string {
|
||||
const [pathname, search = ""] = url.split("?");
|
||||
return favoritePageUrl(pathname, search);
|
||||
}
|
||||
@@ -260,7 +262,7 @@ const ACCOUNT_PAGE_META: Record<string, PageMeta> = {
|
||||
};
|
||||
|
||||
/** Best-effort icon + name for any dashboard page, derived from its URL shape. */
|
||||
export function resolvePageMeta(pathname: string): PageMeta {
|
||||
function resolvePageMeta(pathname: string): PageMeta {
|
||||
const envMatch = pathname.match(/^\/orgs\/[^/]+\/projects\/[^/]+\/env\/[^/]+(?:\/([^?]*))?$/);
|
||||
if (envMatch) {
|
||||
const segments = (envMatch[1] ?? "").split("/").filter(Boolean);
|
||||
@@ -365,7 +367,14 @@ function humanizeValue(value: string): string {
|
||||
}
|
||||
|
||||
/** Pagination/UI-state params that never describe what the user filtered. */
|
||||
const NON_FILTER_PARAMS = [FAVORITE_SEARCH_PARAM, ...PAGINATION_PARAMS, "span"];
|
||||
const NON_FILTER_PARAMS = [
|
||||
FAVORITE_SEARCH_PARAM,
|
||||
...PAGINATION_PARAMS,
|
||||
"span",
|
||||
// The runs list stores its column layout in the URL; that's presentation, not a filter,
|
||||
// so it must not count toward the tally ("Runs: 3 filters" for an unfiltered view).
|
||||
...RUN_COLUMN_SEARCH_PARAMS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Summarize a filtered view's search params into a short, selective descriptor for the favorite
|
||||
@@ -425,7 +434,7 @@ function describeFilters(search: string): string | undefined {
|
||||
* id for friendly-id pages: "Run: 05hrqq9n"); filtered views summarize their filters ("Runs:
|
||||
* Completed successfully, last 7d"). Users can always rename.
|
||||
*/
|
||||
export function buildFavoriteLabel(
|
||||
function buildFavoriteLabel(
|
||||
pathname: string,
|
||||
search: string,
|
||||
pageTitle: string | undefined
|
||||
@@ -512,3 +521,53 @@ export function useFavorites(): FavoritePage[] {
|
||||
|
||||
return favorites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared favorite state + toggle for the current page (full URL, including filters and tabs).
|
||||
* Backs both the page-header star and the runs list "Save to favorites" menu item, so the two
|
||||
* always agree on what counts as favorited and produce identical favorites.
|
||||
*/
|
||||
export function useFavoritePageToggle(pageTitle?: string): {
|
||||
isFavorited: boolean;
|
||||
/** The favorite's custom name once saved, else the label saving would use. */
|
||||
pageName: string;
|
||||
/** False for logged-out and impersonating sessions, which must not mutate preferences. */
|
||||
canFavorite: boolean;
|
||||
toggle: () => void;
|
||||
} {
|
||||
const user = useOptionalUser();
|
||||
const isImpersonating = useIsImpersonating();
|
||||
const location = useLocation();
|
||||
const favorites = useFavorites();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const url = favoritePageUrl(location.pathname, location.search);
|
||||
const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url);
|
||||
|
||||
const toggle = () => {
|
||||
if (existing) {
|
||||
fetcher.submit(
|
||||
{ intent: "remove", id: existing.id },
|
||||
{ method: "POST", action: FAVORITES_ACTION_PATH }
|
||||
);
|
||||
} else {
|
||||
fetcher.submit(
|
||||
{
|
||||
intent: "add",
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
label: buildFavoriteLabel(location.pathname, location.search, pageTitle),
|
||||
icon: resolvePageMeta(location.pathname).icon,
|
||||
},
|
||||
{ method: "POST", action: FAVORITES_ACTION_PATH }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isFavorited: existing !== undefined,
|
||||
pageName: existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle),
|
||||
canFavorite: user !== undefined && !isImpersonating,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,19 +5,44 @@ import { SimpleTooltip } from "./Tooltip";
|
||||
type MiddleTruncateProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
/** Hover delay before the full-text tooltip opens. Defaults to the tooltip default (0). */
|
||||
tooltipDelay?: number;
|
||||
/** Merged onto the tooltip body, for callers whose text needs a bigger or scrollable box. */
|
||||
tooltipContentClassName?: string;
|
||||
/**
|
||||
* Roughly how many characters fit, used only for the very first render. Truncation needs
|
||||
* layout, so the server (and the pre-hydration client) can only render the full string --
|
||||
* long values visibly snapped shorter once React hydrated. Seeding from a character count
|
||||
* is deterministic, so it matches on both sides and the measured pass just refines it.
|
||||
*/
|
||||
initialCharBudget?: number;
|
||||
};
|
||||
|
||||
/** Deterministic, layout-free middle truncation used to seed the first render. */
|
||||
function seedTruncation(text: string, budget: number | undefined): string {
|
||||
if (budget === undefined || text.length <= budget) return text;
|
||||
const keep = Math.max(1, Math.floor((budget - 1) / 2));
|
||||
return `${text.slice(0, keep)}…${text.slice(-keep)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that truncates text in the middle, showing the beginning and end.
|
||||
* Shows the full text in a tooltip on hover when truncated.
|
||||
*
|
||||
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
|
||||
*/
|
||||
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
|
||||
export function MiddleTruncate({
|
||||
text,
|
||||
className,
|
||||
tooltipDelay,
|
||||
tooltipContentClassName,
|
||||
initialCharBudget,
|
||||
}: MiddleTruncateProps) {
|
||||
const seed = seedTruncation(text, initialCharBudget);
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const [displayText, setDisplayText] = useState(text);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const [displayText, setDisplayText] = useState(seed);
|
||||
const [isTruncated, setIsTruncated] = useState(seed !== text);
|
||||
|
||||
const calculateTruncation = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -151,9 +176,14 @@ export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={content}
|
||||
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
|
||||
content={
|
||||
<span className={cn("max-w-xs break-all font-mono text-xs", tooltipContentClassName)}>
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
side="top"
|
||||
asChild
|
||||
delayDuration={tooltipDelay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { SmartColumnIcon } from "~/assets/icons/SmartColumnIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
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<string, string>;
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
/** One title row for all three columns, so their labels and content line up. */
|
||||
const TITLE_ROW_CLASS = "flex min-h-6 items-center";
|
||||
|
||||
/**
|
||||
* The sample and preview panels fill their column but contribute no height to it, so the
|
||||
* dialog is sized by the form alone. Without this, wrapped preview text pushed the whole
|
||||
* dialog taller as you typed.
|
||||
*/
|
||||
const PANEL_FRAME_CLASS = "relative min-h-0 flex-1";
|
||||
|
||||
export function AddSmartColumnDialog({
|
||||
open,
|
||||
editing,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
currentSearch,
|
||||
sampleFilters,
|
||||
}: AddSmartColumnDialogProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const sample = useTypedFetcher<typeof sampleLoader>();
|
||||
|
||||
const [source, setSource] = useState<SmartColumnSource>(DEFAULT_SOURCE);
|
||||
const [path, setPath] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [labelEdited, setLabelEdited] = useState(false);
|
||||
const [displayAs, setDisplayAs] = useState<SmartColumnDisplay>("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<ParsedSource, { state: "parsed" }> } =>
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* Bounded height with the columns absorbing it, so the stacked form can't push the
|
||||
header or footer off a short screen. */}
|
||||
<DialogContent className="max-h-[90vh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-[930px]!">
|
||||
<DialogHeader>{editing ? "Edit smart column" : "Add smart column"}</DialogHeader>
|
||||
<div className="flex min-h-0 flex-col gap-5 pt-3">
|
||||
<Paragraph variant="base/bright">
|
||||
Pick a source, then click a value in the sample payload to turn it into a column. Smart
|
||||
columns are display only, so you can't sort or filter by them.
|
||||
</Paragraph>
|
||||
|
||||
<div className="grid min-h-0 grid-cols-1 items-stretch gap-2.5 md:grid-cols-3">
|
||||
{/* p-1/-m-1: overflow-y-auto clips at the content box, which cut the inputs' focus ring. */}
|
||||
<div className="-m-1 flex flex-col gap-4 overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<InputGroup fullWidth>
|
||||
<div className={TITLE_ROW_CLASS}>
|
||||
<Label>Source</Label>
|
||||
</div>
|
||||
<RadioGroup
|
||||
className="flex flex-col gap-2"
|
||||
value={source}
|
||||
onValueChange={(next) => handleSourceChange(next as SmartColumnSource)}
|
||||
>
|
||||
{SOURCE_CARDS.map((card) => (
|
||||
<RadioGroupItem
|
||||
key={card.value}
|
||||
id={`smart-source-${card.value}`}
|
||||
value={card.value}
|
||||
variant="description"
|
||||
label={card.label}
|
||||
description={card.description}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label>JSON path</Label>
|
||||
<Input
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="$.order.total"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Hint className="text-balance">
|
||||
e.g. <code>$.order.total</code>, <code>$.items[0].sku</code>,{" "}
|
||||
<code>$.items.length</code>
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label>Column label</Label>
|
||||
<Input
|
||||
value={effectiveLabel}
|
||||
onChange={(e) => {
|
||||
setLabel(e.target.value);
|
||||
setLabelEdited(true);
|
||||
}}
|
||||
placeholder={labelFromPath(path)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label>Display as</Label>
|
||||
<RadioGroup
|
||||
className="grid grid-cols-2 gap-2"
|
||||
value={displayAs}
|
||||
onValueChange={(next) => setDisplayAs(next as SmartColumnDisplay)}
|
||||
>
|
||||
{DISPLAY_OPTIONS.map((option) => (
|
||||
<RadioGroupItem
|
||||
key={option.value}
|
||||
id={`smart-display-${option.value}`}
|
||||
value={option.value}
|
||||
variant="button/small"
|
||||
label={option.label}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-1.5">
|
||||
<div className={cn(TITLE_ROW_CLASS, "justify-between gap-2")}>
|
||||
<Label>Sample {source}</Label>
|
||||
{usable.length > 1 && (
|
||||
<SampleRunPicker
|
||||
index={activeIndex}
|
||||
total={usable.length}
|
||||
onPrev={() => setSampleIndex((i) => Math.max(0, i - 1))}
|
||||
onNext={() => setSampleIndex((i) => Math.min(usable.length - 1, i + 1))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={PANEL_FRAME_CLASS}>
|
||||
<div className="absolute inset-0 overflow-auto rounded-lg border border-grid-dimmed bg-charcoal-900 p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
{!sampleLoaded ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Loading…
|
||||
</Paragraph>
|
||||
) : activeSample ? (
|
||||
<SmartColumnSample
|
||||
value={activeSample.value}
|
||||
activePath={path.trim()}
|
||||
onSelectPath={setPath}
|
||||
/>
|
||||
) : runCount === 0 ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
No runs to sample yet.
|
||||
</Paragraph>
|
||||
) : anyOffloaded ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Recent {source}s are too large to sample here.
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
No recent run has a {source} to sample.
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-1.5">
|
||||
<div className={TITLE_ROW_CLASS}>
|
||||
<Label>Preview</Label>
|
||||
</div>
|
||||
<div className={PANEL_FRAME_CLASS}>
|
||||
<SmartColumnPreview rows={perRun} def={previewDef} loaded={sampleLoaded} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary/medium" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary/medium" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{editing ? "Save changes" : "Add column"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SampleRunPicker({
|
||||
index,
|
||||
total,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
index: number;
|
||||
total: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-none items-center gap-1 text-xs text-text-dimmed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
aria-label="Newer run"
|
||||
className="flex size-5 items-center justify-center rounded hover:bg-charcoal-750 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={index >= total - 1}
|
||||
aria-label="Older run"
|
||||
className="flex size-5 items-center justify-center rounded hover:bg-charcoal-750 disabled:opacity-30"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="absolute inset-0 flex flex-col overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<div className="flex flex-none items-center gap-1 border-b border-grid-dimmed bg-background-dimmed px-2.5 py-1.5">
|
||||
<span className="truncate text-xs font-medium text-text-bright">
|
||||
{def.label || "Column"}
|
||||
</span>
|
||||
<SmartColumnIcon className="size-4 flex-none text-text-dimmed" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
{!loaded ? (
|
||||
<div className="px-2.5 py-2 text-xs text-text-dimmed">Loading…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-text-dimmed">No runs yet</div>
|
||||
) : (
|
||||
rows.map((row, index) => {
|
||||
const cell = def.path
|
||||
? extractSmartValue(row.parsed, def.path)
|
||||
: ({ state: "empty" } as const);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex min-h-8 items-center break-all border-b border-grid-dimmed/60 px-2.5 py-1.5 text-sm last:border-b-0",
|
||||
alignClass
|
||||
)}
|
||||
>
|
||||
<SmartCellContent cell={cell} def={def} provisional={!row.hasFinished} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -406,6 +406,15 @@ export function RunsFilters(props: RunFiltersProps) {
|
||||
<FilterMenu {...props} />
|
||||
{hasFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
{searchParams.getAll("cols").map((v, i) => (
|
||||
<input key={`cols-${i}`} type="hidden" name="cols" value={v} />
|
||||
))}
|
||||
{searchParams.getAll("hide").map((v, i) => (
|
||||
<input key={`hide-${i}`} type="hidden" name="hide" value={v} />
|
||||
))}
|
||||
{searchParams.getAll("sc").map((v, i) => (
|
||||
<input key={`sc-${i}`} type="hidden" name="sc" value={v} />
|
||||
))}
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { PencilSquareIcon, StarIcon as StarIconSolid, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ColumnsIcon } from "~/assets/icons/ColumnsIcon";
|
||||
import { ResetIcon } from "~/assets/icons/ResetIcon";
|
||||
import { SmartColumnIcon } from "~/assets/icons/SmartColumnIcon";
|
||||
import { useFavoritePageToggle } from "~/components/navigation/favoritePages";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
encodeColumnLayout,
|
||||
parseColumnParams,
|
||||
resolveColumnLayout,
|
||||
type LayoutColumn,
|
||||
type ResolvedColumn,
|
||||
type RunColumnRuntime,
|
||||
type SmartColumnDef,
|
||||
} from "./runColumns";
|
||||
import { AddSmartColumnDialog } from "./AddSmartColumnDialog";
|
||||
|
||||
function keyFor(col: ResolvedColumn): string {
|
||||
return col.kind === "standard" ? `std:${col.def.id}` : `smart:${col.index}`;
|
||||
}
|
||||
|
||||
type SmartEditTarget = { index: number; def: SmartColumnDef };
|
||||
|
||||
/** The three footer actions share one icon size so the mixed icon sets line up. */
|
||||
const FOOTER_ICON_CLASS = "size-[1.15rem]";
|
||||
|
||||
/** Opens the Columns popover. "l" is free on every list this control appears on. */
|
||||
export const COLUMNS_SHORTCUT = { key: "l" as const };
|
||||
|
||||
export function RunsDisplayOptions({
|
||||
sampleFilters,
|
||||
}: {
|
||||
sampleFilters?: Record<string, string>;
|
||||
} = {}) {
|
||||
const environment = useEnvironment();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const location = useOptimisticLocation();
|
||||
const { value, values, replace } = useSearchParams();
|
||||
// Same favorite the page-header star toggles, so the two stay in lockstep on this URL.
|
||||
const { isFavorited, canFavorite, toggle: toggleFavorite } = useFavoritePageToggle();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SmartEditTarget | null>(null);
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
const [overKey, setOverKey] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Whether this open came from the shortcut, which decides if focus moves into the list.
|
||||
const openedByShortcut = useRef(false);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: COLUMNS_SHORTCUT,
|
||||
action: (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openedByShortcut.current = true;
|
||||
setOpen((previous) => !previous);
|
||||
},
|
||||
});
|
||||
|
||||
const runtime: RunColumnRuntime = {
|
||||
isManagedCloud,
|
||||
isDevelopment: environment.type === "DEVELOPMENT",
|
||||
};
|
||||
|
||||
const colsParam = value("cols");
|
||||
const hideParam = value("hide");
|
||||
const sc = values("sc");
|
||||
const layout = useMemo(
|
||||
() => resolveColumnLayout(parseColumnParams(colsParam, sc, hideParam), runtime),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[colsParam, hideParam, sc.join(" "), runtime.isManagedCloud, runtime.isDevelopment]
|
||||
);
|
||||
|
||||
const totalCount = layout.ordered.filter((o) => o.col.kind === "standard").length;
|
||||
const shownCount = layout.ordered.filter((o) => o.col.kind === "standard" && !o.hidden).length;
|
||||
|
||||
const applyLayout = (next: LayoutColumn[]) => {
|
||||
const encoded = encodeColumnLayout(next, runtime);
|
||||
replace({
|
||||
cols: encoded.cols.length > 0 ? encoded.cols.join(",") : undefined,
|
||||
sc: encoded.sc.length > 0 ? encoded.sc : undefined,
|
||||
hide: encoded.hide.length > 0 ? encoded.hide.join(",") : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const reset = () => replace({ cols: undefined, sc: undefined, hide: undefined });
|
||||
|
||||
const toggleHidden = (key: string) => {
|
||||
applyLayout(
|
||||
layout.ordered.map((o) => (keyFor(o.col) === key ? { ...o, hidden: !o.hidden } : o))
|
||||
);
|
||||
};
|
||||
|
||||
const removeSmart = (index: number) => {
|
||||
applyLayout(layout.ordered.filter((o) => !(o.col.kind === "smart" && o.col.index === index)));
|
||||
};
|
||||
|
||||
const submitSmart = (def: SmartColumnDef) => {
|
||||
if (editing) {
|
||||
applyLayout(
|
||||
layout.ordered.map((o) =>
|
||||
o.col.kind === "smart" && o.col.index === editing.index
|
||||
? { ...o, col: { ...o.col, def } }
|
||||
: o
|
||||
)
|
||||
);
|
||||
} else {
|
||||
applyLayout([
|
||||
...layout.ordered,
|
||||
{ col: { kind: "smart", index: layout.smartColumns.length, def }, hidden: false },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const reorder = (fromKey: string, toKey: string) => {
|
||||
if (fromKey === toKey) return;
|
||||
const arr = [...layout.ordered];
|
||||
const from = arr.findIndex((o) => keyFor(o.col) === fromKey);
|
||||
const to = arr.findIndex((o) => keyFor(o.col) === toKey);
|
||||
if (from < 0 || to < 0) return;
|
||||
const [moved] = arr.splice(from, 1);
|
||||
arr.splice(from < to ? to - 1 : to, 0, moved);
|
||||
applyLayout(arr);
|
||||
};
|
||||
|
||||
const move = (key: string, delta: number) => {
|
||||
const arr = [...layout.ordered];
|
||||
const from = arr.findIndex((o) => keyFor(o.col) === key);
|
||||
const to = from + delta;
|
||||
if (from < 0 || to < 0 || to >= arr.length) return;
|
||||
const [moved] = arr.splice(from, 1);
|
||||
arr.splice(to, 0, moved);
|
||||
applyLayout(arr);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
setDragKey(null);
|
||||
setOverKey(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) openedByShortcut.current = false;
|
||||
}}
|
||||
>
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
side="bottom"
|
||||
disableHoverableContent
|
||||
button={
|
||||
// Plain wrapper: Button drops the pointer-event props Radix injects via asChild,
|
||||
// so the tooltip anchor can't be the Button itself (same as NotificationPanel).
|
||||
<div className="flex">
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary/small" LeadingIcon={ColumnsIcon}>
|
||||
Columns
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-2">
|
||||
Customize columns
|
||||
<ShortcutKey shortcut={COLUMNS_SHORTCUT} variant="small" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-64 p-0"
|
||||
// Opened by shortcut: let Radix focus the first row so the list can be tabbed
|
||||
// straight away. Opened by mouse: keep focus put, or the first row's
|
||||
// hover-revealed reorder handle would appear before the cursor ever got there.
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (!openedByShortcut.current) event.preventDefault();
|
||||
openedByShortcut.current = false;
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-xs font-medium text-text-dimmed">Columns</span>
|
||||
<span className="text-xs text-text-dimmed">
|
||||
{shownCount} of {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto border-y border-grid-dimmed p-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
{layout.ordered.map(({ col, hidden }) => {
|
||||
const key = keyFor(col);
|
||||
return (
|
||||
<ColumnRow
|
||||
key={key}
|
||||
col={col}
|
||||
checked={!hidden}
|
||||
locked={col.kind === "standard" && !!col.def.locked}
|
||||
dragging={dragKey === key}
|
||||
isOver={overKey === key && dragKey !== key}
|
||||
onDragStart={() => 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col p-1">
|
||||
<PopoverMenuItem
|
||||
icon={SmartColumnIcon}
|
||||
title="Add smart column…"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="h-8"
|
||||
leadingIconClassName={FOOTER_ICON_CLASS}
|
||||
/>
|
||||
{canFavorite && (
|
||||
<PopoverMenuItem
|
||||
icon={
|
||||
isFavorited ? (
|
||||
<StarIconSolid className={cn(FOOTER_ICON_CLASS, "text-yellow-500")} />
|
||||
) : (
|
||||
// The outline star is 1.5px by default, noticeably thinner than the
|
||||
// custom 2px icons beside it.
|
||||
<StarIconOutline className={FOOTER_ICON_CLASS} strokeWidth={2} />
|
||||
)
|
||||
}
|
||||
title={isFavorited ? "Remove from favorites" : "Save to favorites"}
|
||||
onClick={toggleFavorite}
|
||||
className="h-8"
|
||||
/>
|
||||
)}
|
||||
{/* Wrapper carries the cursor: the disabled button has pointer-events-none. */}
|
||||
<div className={cn("flex", !layout.isCustomized && "cursor-not-allowed")}>
|
||||
<PopoverMenuItem
|
||||
icon={ResetIcon}
|
||||
title="Reset to default"
|
||||
onClick={reset}
|
||||
disabled={!layout.isCustomized}
|
||||
className="h-8 group-disabled/button:opacity-50 group-disabled/button:[&_span]:text-text-dimmed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<AddSmartColumnDialog
|
||||
open={addOpen || editing !== null}
|
||||
editing={editing?.def ?? null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
setAddOpen(false);
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={submitSmart}
|
||||
currentSearch={location.search}
|
||||
sampleFilters={sampleFilters}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The label owns the focus ring (see ColumnRow), so the checkbox itself never rings. */
|
||||
const CHECKBOX_NO_RING = "focus:ring-0 group-focus:ring-0 focus-visible:ring-0";
|
||||
|
||||
/**
|
||||
* The row's hover-revealed actions. Square, and hidden until the row is hovered or the
|
||||
* control itself takes keyboard focus (a checkbox click must not reveal them).
|
||||
*/
|
||||
const ROW_ACTION_CLASS =
|
||||
"aspect-square h-6 p-1 opacity-0 transition group-hover:opacity-100 group-focus-visible/button:opacity-100";
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex h-8 items-center rounded-sm transition-colors hover:bg-background-hover",
|
||||
dragging && "opacity-40"
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", "");
|
||||
onDragStart();
|
||||
}}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
onDrop();
|
||||
}}
|
||||
>
|
||||
{isOver && <div className="absolute inset-x-0 top-0 h-0.5 bg-indigo-500" />}
|
||||
{/* Native label so the whole name area toggles the column, matching CheckboxWithLabel. */}
|
||||
{/* The label is the hit area, so it carries the focus ring rather than the checkbox
|
||||
inside it, and only for keyboard focus -- a click must not ring anything. */}
|
||||
<label
|
||||
className={cn(
|
||||
"flex h-full min-w-0 flex-1 items-center gap-x-2 rounded-sm pl-2",
|
||||
"has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:-outline-offset-1 has-[:focus-visible]:outline-text-link",
|
||||
locked ? "cursor-default" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
{locked ? (
|
||||
<Checkbox checked disabled className={CHECKBOX_NO_RING} />
|
||||
) : (
|
||||
<Checkbox checked={checked} onChange={onToggle} className={CHECKBOX_NO_RING} />
|
||||
)}
|
||||
<span className="flex min-w-0 items-center gap-x-1">
|
||||
<span
|
||||
className={cn("truncate text-2sm", checked ? "text-text-bright" : "text-text-dimmed")}
|
||||
>
|
||||
{col.def.label}
|
||||
</span>
|
||||
{isSmart && <SmartColumnIcon className="size-3.5 flex-none text-text-dimmed" />}
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex flex-none items-center gap-0.5 pr-1">
|
||||
{onRemove && (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${col.def.label}`}
|
||||
LeadingIcon={<XMarkIcon className="size-4" />}
|
||||
className={cn(ROW_ACTION_CLASS, "group-hover/button:text-error")}
|
||||
/>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={onEdit}
|
||||
aria-label={`Edit ${col.def.label}`}
|
||||
LeadingIcon={<PencilSquareIcon className="size-4" />}
|
||||
className={ROW_ACTION_CLASS}
|
||||
/>
|
||||
)}
|
||||
{/* Button forwards no onKeyDown, so the arrow-key reorder listens on the wrapper
|
||||
and catches the event bubbling up from the focused button. */}
|
||||
<span
|
||||
role="presentation"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
onMove(-1);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
onMove(1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
aria-label={`Reorder ${col.def.label} (use arrow up and down)`}
|
||||
LeadingIcon={<GripVerticalIcon className="size-4" />}
|
||||
className={cn(
|
||||
ROW_ACTION_CLASS,
|
||||
"cursor-grab group-hover/button:bg-transparent active:cursor-grabbing"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-h-80 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<JsonNode
|
||||
name={undefined}
|
||||
path="$"
|
||||
value={value}
|
||||
activePath={activePath}
|
||||
onSelectPath={onSelectPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectPath(target)}
|
||||
className={cn(
|
||||
"flex w-full items-baseline whitespace-pre rounded px-0.5 text-left hover:bg-blue-500/15",
|
||||
selected && "bg-blue-500/25"
|
||||
)}
|
||||
>
|
||||
{keyLabel !== null && <span className="text-sky-300">{keyLabel}</span>}
|
||||
{keyLabel !== null && <span className="text-text-dimmed">: </span>}
|
||||
<PrimitiveValue value={value} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const isArray = Array.isArray(value);
|
||||
const entries: [string | number, unknown][] = isArray
|
||||
? (value as unknown[]).map((v, i) => [i, v])
|
||||
: Object.entries(value as Record<string, unknown>);
|
||||
const shown = entries.slice(0, MAX_CHILDREN);
|
||||
const openBrace = isArray ? "[" : "{";
|
||||
const closeBrace = isArray ? "]" : "}";
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="whitespace-pre px-0.5">
|
||||
{keyLabel !== null && <span className="text-sky-300">{keyLabel}</span>}
|
||||
{keyLabel !== null && <span className="text-text-dimmed">: </span>}
|
||||
<span className="text-text-dimmed">
|
||||
{openBrace}
|
||||
{closeBrace}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="whitespace-pre px-0.5">
|
||||
{keyLabel !== null && <span className="text-sky-300">{keyLabel}</span>}
|
||||
{keyLabel !== null && <span className="text-text-dimmed">: </span>}
|
||||
<span className="text-text-dimmed">{openBrace}</span>
|
||||
</div>
|
||||
<div className="ml-[0.4rem] border-l border-grid-dimmed/50 pl-3">
|
||||
{shown.map(([key, childValue]) => (
|
||||
<JsonNode
|
||||
key={String(key)}
|
||||
name={key}
|
||||
path={childPath(path, key)}
|
||||
value={childValue}
|
||||
activePath={activePath}
|
||||
onSelectPath={onSelectPath}
|
||||
/>
|
||||
))}
|
||||
{entries.length > MAX_CHILDREN && (
|
||||
<div className="text-text-dimmed">… {entries.length - MAX_CHILDREN} more</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre px-0.5 text-text-dimmed">{closeBrace}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimitiveValue({ value }: { value: unknown }) {
|
||||
if (value === null) return <span className="text-purple-400">null</span>;
|
||||
if (typeof value === "string") {
|
||||
const truncated = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value;
|
||||
return <span className="text-green-400">"{truncated}"</span>;
|
||||
}
|
||||
if (typeof value === "number") return <span className="text-amber-400">{String(value)}</span>;
|
||||
if (typeof value === "boolean") return <span className="text-purple-400">{String(value)}</span>;
|
||||
return <span className="text-text-dimmed">{String(value)}</span>;
|
||||
}
|
||||
@@ -9,7 +9,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 +63,18 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
|
||||
import { SmartColumnIcon } from "~/assets/icons/SmartColumnIcon";
|
||||
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,484 @@ type RunsTableProps = {
|
||||
canReplayRuns?: boolean;
|
||||
};
|
||||
|
||||
type CellRenderContext = {
|
||||
run: NextRunListItem;
|
||||
path: string;
|
||||
regionByMasterQueue: Map<string, { name: string }>;
|
||||
childrenStatusesBasePath?: string;
|
||||
sources: Partial<Record<SmartColumnSource, ParsedSource>>;
|
||||
};
|
||||
|
||||
type StandardColumnRenderer = {
|
||||
header: React.ReactNode;
|
||||
cell: (ctx: CellRenderContext) => React.ReactNode;
|
||||
/** Cells/header this column occupies (Duration renders three). */
|
||||
span: number;
|
||||
};
|
||||
|
||||
const STANDARD_RENDERERS: Record<string, StandardColumnRenderer> = {
|
||||
id: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>ID</TableHeaderCell>,
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<TruncatedCopyableValue value={run.friendlyId} />
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
task: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>Task</TableHeaderCell>,
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path}>
|
||||
<span className="flex items-center gap-x-1">
|
||||
<TaskTriggerSourceIcon
|
||||
source={run.taskKind as TaskTriggerSource}
|
||||
className="size-3.5 flex-none"
|
||||
/>
|
||||
{run.taskIdentifier}
|
||||
{run.rootTaskRunId === null ? <Badge variant="extra-small">Root</Badge> : null}
|
||||
</span>
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
ver: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>Version</TableHeaderCell>,
|
||||
cell: ({ run, path }) => <TableCell to={path}>{run.version ?? "–"}</TableCell>,
|
||||
},
|
||||
status: {
|
||||
span: 1,
|
||||
header: (
|
||||
<TableHeaderCell
|
||||
disableTooltipHoverableContent
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{filterableTaskRunStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<TaskRunStatusCombo status={status} />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||||
{descriptionForTaskRunStatus(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
),
|
||||
cell: ({ run, path, childrenStatusesBasePath }) => (
|
||||
<TableCell to={path}>
|
||||
{run.rootTaskRunId === null && childrenStatusesBasePath ? (
|
||||
<RunStatusCellTooltip
|
||||
friendlyId={run.friendlyId}
|
||||
status={run.status}
|
||||
hasFinished={run.hasFinished}
|
||||
childrenStatusesBasePath={childrenStatusesBasePath}
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
content={descriptionForTaskRunStatus(run.status)}
|
||||
disableHoverableContent
|
||||
button={<TaskRunStatusCombo status={run.status} />}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
started: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>Started</TableHeaderCell>,
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path}>{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}</TableCell>
|
||||
),
|
||||
},
|
||||
dur: {
|
||||
span: 3,
|
||||
header: (
|
||||
<TableHeaderCell
|
||||
colSpan={3}
|
||||
disableTooltipHoverableContent
|
||||
tooltip={
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1">
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<RectangleStackIcon className="size-4 text-text-dimmed" />
|
||||
<Header3>Queued duration</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||||
The amount of time from when the run was created to it starting to run.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<ClockIcon className="size-4 text-blue-500" /> <Header3>Run duration</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||||
The total amount of time from the run starting to it finishing. This includes all
|
||||
time spent waiting.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<CpuChipIcon className="size-4 text-success" />
|
||||
<Header3>Compute duration</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||||
The amount of compute time used in the run. This does not include time spent
|
||||
waiting.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Duration
|
||||
</TableHeaderCell>
|
||||
),
|
||||
cell: ({ run, path }) => (
|
||||
<>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
<div className="flex items-center gap-1">
|
||||
<RectangleStackIcon className="size-4 text-text-dimmed" />
|
||||
{run.isPending ? (
|
||||
"–"
|
||||
) : run.startedAt ? (
|
||||
formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : run.isCancellable ? (
|
||||
<LiveTimer startTime={new Date(run.triggeredAt)} />
|
||||
) : (
|
||||
formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), {
|
||||
style: "short",
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="px-4 tabular-nums">
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockIcon className="size-4 text-blue-500" />
|
||||
{run.startedAt && run.finishedAt ? (
|
||||
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : run.startedAt ? (
|
||||
<LiveTimer startTime={new Date(run.startedAt)} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="pl-0 tabular-nums">
|
||||
<div className="flex items-center gap-1">
|
||||
<CpuChipIcon className="size-4 text-success" />
|
||||
{run.usageDurationMs > 0
|
||||
? formatDurationMilliseconds(run.usageDurationMs, {
|
||||
style: "short",
|
||||
})
|
||||
: "–"}
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
},
|
||||
compute: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>Compute</TableHeaderCell>,
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path} className="tabular-nums">
|
||||
{run.costInCents > 0
|
||||
? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100)
|
||||
: "–"}
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
machine: {
|
||||
span: 1,
|
||||
header: (
|
||||
<TableHeaderCell className="pl-4" tooltip={<MachineTooltipInfo />}>
|
||||
Machine
|
||||
</TableHeaderCell>
|
||||
),
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path}>
|
||||
<MachineLabelCombo preset={run.machinePreset} />
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
queue: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>Queue</TableHeaderCell>,
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path}>
|
||||
{run.queue.type === "task" ? (
|
||||
<SimpleTooltip
|
||||
buttonClassName="w-fit"
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
<TasksIcon className="size-[1.125rem] text-blue-500" />
|
||||
<span>{run.queue.name}</span>
|
||||
</span>
|
||||
}
|
||||
content={`This queue was automatically created from your "${run.queue.name}" task`}
|
||||
disableHoverableContent
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
buttonClassName="w-fit"
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
<RectangleStackIcon className="size-[1.125rem] text-purple-500" />
|
||||
<span>{run.queue.name}</span>
|
||||
</span>
|
||||
}
|
||||
content={`This is a custom queue you added in your code.`}
|
||||
disableHoverableContent
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
region: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>Region</TableHeaderCell>,
|
||||
cell: ({ run, path, regionByMasterQueue }) => (
|
||||
<TableCell to={path}>
|
||||
{run.region ? (
|
||||
<RegionLabel
|
||||
region={regionByMasterQueue.get(run.region) ?? { name: run.region }}
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
test: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>Test</TableHeaderCell>,
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path}>
|
||||
{run.isTest ? (
|
||||
<CheckIcon className="size-4 text-text-dimmed group-hover/table-row:text-text-bright" />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
created: {
|
||||
span: 1,
|
||||
header: <TableHeaderCell>Created at</TableHeaderCell>,
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path}>{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}</TableCell>
|
||||
),
|
||||
},
|
||||
delayed: {
|
||||
span: 1,
|
||||
header: (
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="max-w-xs p-1">
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
When you want to trigger a task now, but have it run at a later time, you can use the
|
||||
delay option.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
Runs that are delayed and have not been enqueued yet will display in the dashboard
|
||||
with a “Delayed” status.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/triggering")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Delayed until
|
||||
</TableHeaderCell>
|
||||
),
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path}>{run.delayUntil ? <DateTime date={run.delayUntil} /> : "–"}</TableCell>
|
||||
),
|
||||
},
|
||||
ttl: {
|
||||
span: 1,
|
||||
header: (
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="max-w-xs p-1">
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
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.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
All runs in development have a default ttl of 10 minutes. You can disable this by
|
||||
setting the ttl option.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/triggering")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
TTL
|
||||
</TableHeaderCell>
|
||||
),
|
||||
cell: ({ run, path }) => <TableCell to={path}>{run.ttl ?? "–"}</TableCell>,
|
||||
},
|
||||
tags: {
|
||||
span: 1,
|
||||
header: (
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="max-w-xs p-1">
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
You can add tags to a run and then filter runs using them.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
You can add tags when triggering a run or inside the run function.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tags")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Tags
|
||||
</TableHeaderCell>
|
||||
),
|
||||
cell: ({ run, path }) => (
|
||||
<TableCell to={path} actionClassName="py-1" className="pr-16">
|
||||
<div className="flex gap-1">
|
||||
{run.tags.length > 0 ? run.tags.map((tag) => <RunTag key={tag} tag={tag} />) : "–"}
|
||||
</div>
|
||||
</TableCell>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const SMART_SOURCE_LABELS: Record<SmartColumnSource, string> = {
|
||||
payload: "payload",
|
||||
metadata: "metadata",
|
||||
output: "output",
|
||||
};
|
||||
|
||||
function SmartColumnHeader({ def }: { def: SmartColumnDef }) {
|
||||
return (
|
||||
<TableHeaderCell>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="truncate">{def.label}</span>
|
||||
{/* The bolt is the tooltip trigger, so the cell doesn't also get an info icon. */}
|
||||
<SimpleTooltip
|
||||
disableHoverableContent
|
||||
button={<SmartColumnIcon className="size-4 flex-none text-text-dimmed" />}
|
||||
content={
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="max-w-xs text-wrap! normal-case tracking-normal text-text-dimmed"
|
||||
>
|
||||
Reads <span className="font-mono text-text-bright">{def.path}</span> from each run's{" "}
|
||||
{SMART_SOURCE_LABELS[def.source]}, shown as {def.displayAs}. Display only, so this
|
||||
column can't be sorted or filtered.
|
||||
</Paragraph>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableCell to={path} className={numeric ? "text-right tabular-nums" : undefined}>
|
||||
<SmartCellContent cell={cell} def={def} provisional={!run.hasFinished} truncate />
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_SOURCES: Partial<Record<SmartColumnSource, ParsedSource>> = {};
|
||||
|
||||
function buildRowSources(
|
||||
run: NextRunListItem,
|
||||
sources: SmartColumnSource[]
|
||||
): Partial<Record<SmartColumnSource, ParsedSource>> {
|
||||
const result: Partial<Record<SmartColumnSource, ParsedSource>> = {};
|
||||
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 <SmartColumnHeader def={column.def} />;
|
||||
}
|
||||
return STANDARD_RENDERERS[column.def.id]?.header ?? null;
|
||||
}
|
||||
|
||||
function ColumnCell({ column, ctx }: { column: ResolvedColumn; ctx: CellRenderContext }) {
|
||||
if (column.kind === "smart") {
|
||||
return (
|
||||
<SmartColumnCell
|
||||
def={column.def}
|
||||
run={ctx.run}
|
||||
path={ctx.path}
|
||||
parsed={ctx.sources[column.def.source]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return STANDARD_RENDERERS[column.def.id]?.cell(ctx) ?? null;
|
||||
}
|
||||
|
||||
export function TaskRunsTable({
|
||||
total,
|
||||
hasFilters,
|
||||
@@ -103,6 +600,7 @@ export function TaskRunsTable({
|
||||
showTopBorder = true,
|
||||
stickyHeader = false,
|
||||
childrenStatusesBasePath,
|
||||
enableSmartColumns = true,
|
||||
canCancelRuns = true,
|
||||
canReplayRuns = true,
|
||||
}: RunsTableProps) {
|
||||
@@ -114,7 +612,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 +627,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<string, Partial<Record<SmartColumnSource, ParsedSource>>>();
|
||||
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<HTMLInputElement>, index: number) => {
|
||||
@@ -189,148 +716,9 @@ export function TaskRunsTable({
|
||||
)}
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
disableTooltipHoverableContent
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{filterableTaskRunStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<TaskRunStatusCombo status={status} />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||||
{descriptionForTaskRunStatus(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Started</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
colSpan={3}
|
||||
disableTooltipHoverableContent
|
||||
tooltip={
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1">
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<RectangleStackIcon className="size-4 text-text-dimmed" />
|
||||
<Header3>Queued duration</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||||
The amount of time from when the run was created to it starting to run.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<ClockIcon className="size-4 text-blue-500" /> <Header3>Run duration</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||||
The total amount of time from the run starting to it finishing. This includes
|
||||
all time spent waiting.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<CpuChipIcon className="size-4 text-success" />
|
||||
<Header3>Compute duration</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||||
The amount of compute time used in the run. This does not include time spent
|
||||
waiting.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Duration
|
||||
</TableHeaderCell>
|
||||
{showCompute && <TableHeaderCell>Compute</TableHeaderCell>}
|
||||
<TableHeaderCell className="pl-4" tooltip={<MachineTooltipInfo />}>
|
||||
Machine
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Queue</TableHeaderCell>
|
||||
{showRegion && <TableHeaderCell>Region</TableHeaderCell>}
|
||||
<TableHeaderCell>Test</TableHeaderCell>
|
||||
<TableHeaderCell>Created at</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="max-w-xs p-1">
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
When you want to trigger a task now, but have it run at a later time, you can use
|
||||
the delay option.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
Runs that are delayed and have not been enqueued yet will display in the dashboard
|
||||
with a “Delayed” status.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/triggering")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Delayed until
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="max-w-xs p-1">
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
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.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
All runs in development have a default ttl of 10 minutes. You can disable this by
|
||||
setting the ttl option.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/triggering")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
TTL
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="max-w-xs p-1">
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
You can add tags to a run and then filter runs using them.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
|
||||
You can add tags when triggering a run or inside the run function.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tags")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Tags
|
||||
</TableHeaderCell>
|
||||
{visibleColumns.map((col) => (
|
||||
<ColumnHeader key={columnKey(col)} column={col} />
|
||||
))}
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Go to page</span>
|
||||
</TableHeaderCell>
|
||||
@@ -338,11 +726,11 @@ export function TaskRunsTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{total === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={showRegion ? 16 : 15}>
|
||||
<TableBlankRow colSpan={totalColSpan}>
|
||||
{!isLoading && <NoRuns title="No runs found" />}
|
||||
</TableBlankRow>
|
||||
) : runs.length === 0 ? (
|
||||
<BlankState isLoading={isLoading} filters={filters} showRegion={showRegion} />
|
||||
<BlankState isLoading={isLoading} filters={filters} colSpan={totalColSpan} />
|
||||
) : (
|
||||
runs.map((run, index) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -359,6 +747,7 @@ export function TaskRunsTable({
|
||||
},
|
||||
searchParams
|
||||
);
|
||||
const sources = sourcesByRunId.get(run.id) ?? EMPTY_SOURCES;
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
{allowSelection && (
|
||||
@@ -375,149 +764,13 @@ export function TaskRunsTable({
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<TruncatedCopyableValue value={run.friendlyId} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<span className="flex items-center gap-x-1">
|
||||
<TaskTriggerSourceIcon
|
||||
source={run.taskKind as TaskTriggerSource}
|
||||
className="size-3.5 flex-none"
|
||||
/>
|
||||
{run.taskIdentifier}
|
||||
{run.rootTaskRunId === null ? <Badge variant="extra-small">Root</Badge> : null}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell to={path}>{run.version ?? "–"}</TableCell>
|
||||
<TableCell to={path}>
|
||||
{run.rootTaskRunId === null && childrenStatusesBasePath ? (
|
||||
<RunStatusCellTooltip
|
||||
friendlyId={run.friendlyId}
|
||||
status={run.status}
|
||||
hasFinished={run.hasFinished}
|
||||
childrenStatusesBasePath={childrenStatusesBasePath}
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
content={descriptionForTaskRunStatus(run.status)}
|
||||
disableHoverableContent
|
||||
button={<TaskRunStatusCombo status={run.status} />}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
<div className="flex items-center gap-1">
|
||||
<RectangleStackIcon className="size-4 text-text-dimmed" />
|
||||
{run.isPending ? (
|
||||
"–"
|
||||
) : run.startedAt ? (
|
||||
formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : run.isCancellable ? (
|
||||
<LiveTimer startTime={new Date(run.triggeredAt)} />
|
||||
) : (
|
||||
formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), {
|
||||
style: "short",
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="px-4 tabular-nums">
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockIcon className="size-4 text-blue-500" />
|
||||
{run.startedAt && run.finishedAt ? (
|
||||
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : run.startedAt ? (
|
||||
<LiveTimer startTime={new Date(run.startedAt)} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="pl-0 tabular-nums">
|
||||
<div className="flex items-center gap-1">
|
||||
<CpuChipIcon className="size-4 text-success" />
|
||||
{run.usageDurationMs > 0
|
||||
? formatDurationMilliseconds(run.usageDurationMs, {
|
||||
style: "short",
|
||||
})
|
||||
: "–"}
|
||||
</div>
|
||||
</TableCell>
|
||||
{showCompute && (
|
||||
<TableCell to={path} className="tabular-nums">
|
||||
{run.costInCents > 0
|
||||
? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100)
|
||||
: "–"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell to={path}>
|
||||
<MachineLabelCombo preset={run.machinePreset} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{run.queue.type === "task" ? (
|
||||
<SimpleTooltip
|
||||
buttonClassName="w-fit"
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
<TasksIcon className="size-[1.125rem] text-blue-500" />
|
||||
<span>{run.queue.name}</span>
|
||||
</span>
|
||||
}
|
||||
content={`This queue was automatically created from your "${run.queue.name}" task`}
|
||||
disableHoverableContent
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
buttonClassName="w-fit"
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
<RectangleStackIcon className="size-[1.125rem] text-purple-500" />
|
||||
<span>{run.queue.name}</span>
|
||||
</span>
|
||||
}
|
||||
content={`This is a custom queue you added in your code.`}
|
||||
disableHoverableContent
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
{showRegion && (
|
||||
<TableCell to={path}>
|
||||
{run.region ? (
|
||||
<RegionLabel
|
||||
region={regionByMasterQueue.get(run.region) ?? { name: run.region }}
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell to={path}>
|
||||
{run.isTest ? (
|
||||
<CheckIcon className="size-4 text-text-dimmed group-hover/table-row:text-text-bright" />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{run.delayUntil ? <DateTime date={run.delayUntil} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{run.ttl ?? "–"}</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1" className="pr-16">
|
||||
<div className="flex gap-1">
|
||||
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || "–"}
|
||||
</div>
|
||||
</TableCell>
|
||||
{visibleColumns.map((col) => (
|
||||
<ColumnCell
|
||||
key={columnKey(col)}
|
||||
column={col}
|
||||
ctx={{ run, path, regionByMasterQueue, childrenStatusesBasePath, sources }}
|
||||
/>
|
||||
))}
|
||||
<RunActionsCell
|
||||
run={run}
|
||||
path={path}
|
||||
@@ -530,7 +783,7 @@ export function TaskRunsTable({
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={showRegion ? 16 : 15}
|
||||
colSpan={totalColSpan}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-background-dimmed"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
@@ -707,12 +960,11 @@ function NoRuns({ title }: { title: string }) {
|
||||
function BlankState({
|
||||
isLoading,
|
||||
filters,
|
||||
showRegion,
|
||||
}: Pick<RunsTableProps, "isLoading" | "filters"> & { showRegion: boolean }) {
|
||||
colSpan,
|
||||
}: Pick<RunsTableProps, "isLoading" | "filters"> & { colSpan: number }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const colSpan = showRegion ? 16 : 15;
|
||||
if (isLoading) return <TableBlankRow colSpan={colSpan} />;
|
||||
|
||||
const { tasks, from, to, ...otherFilters } = filters;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
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<SmartColumnSource, readonly RunSelectField[]> = {
|
||||
payload: ["payload", "payloadType"],
|
||||
metadata: ["metadata", "metadataType"],
|
||||
output: ["output", "outputType"],
|
||||
};
|
||||
|
||||
/**
|
||||
* The search params the column layout lives in. Exported so callers that reason about the
|
||||
* runs URL as a whole (e.g. summarising a favorite's filters) can tell layout from filters.
|
||||
*/
|
||||
export const RUN_COLUMN_SEARCH_PARAMS = ["cols", "sc", "hide"] as const;
|
||||
|
||||
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<string, boolean>)[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<RunColumnId>();
|
||||
const seenSmart = new Set<number>();
|
||||
|
||||
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<RunColumnId>,
|
||||
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<number, string>();
|
||||
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<SmartColumnSource>();
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Long text renders in a fixed-width box, not a max-width one. MiddleTruncate measures its
|
||||
* parent, and a runs table column is auto-width: a max-width box narrows as the text is
|
||||
* elided, which shrinks the column, which re-triggers truncation, and so on -- the text
|
||||
* visibly ate itself a character at a time and never settled. A definite width can't be
|
||||
* influenced by its own content, so the measurement converges on the first pass.
|
||||
*/
|
||||
const TEXT_CELL_WIDTH = "w-[600px]";
|
||||
/**
|
||||
* Whether a value is long enough to need the fixed box, decided from the raw string so the
|
||||
* choice never depends on layout (which is what made the loop possible). ~600px of 13px text.
|
||||
*/
|
||||
const TEXT_CELL_CHAR_BUDGET = 100;
|
||||
/** Long values are common enough that an instant tooltip would fire while just scanning rows. */
|
||||
const TEXT_CELL_TOOLTIP_DELAY_MS = 500;
|
||||
/** A whole payload string can be arbitrarily long, so the tooltip is capped and scrolls. */
|
||||
const TEXT_CELL_TOOLTIP_CLASS =
|
||||
"block max-w-sm max-h-64 overflow-y-auto whitespace-pre-wrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control";
|
||||
|
||||
function renderSmartValue(
|
||||
value: unknown,
|
||||
displayAs: SmartColumnDef["displayAs"],
|
||||
truncate: boolean
|
||||
): 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 <Badge variant="extra-small">{stringifySmartValue(value)}</Badge>;
|
||||
default: {
|
||||
const text = stringifySmartValue(value);
|
||||
if (!truncate || text.length <= TEXT_CELL_CHAR_BUDGET) return text;
|
||||
return (
|
||||
<span className={cn("block", TEXT_CELL_WIDTH)}>
|
||||
<MiddleTruncate
|
||||
text={text}
|
||||
tooltipDelay={TEXT_CELL_TOOLTIP_DELAY_MS}
|
||||
tooltipContentClassName={TEXT_CELL_TOOLTIP_CLASS}
|
||||
initialCharBudget={TEXT_CELL_CHAR_BUDGET}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
truncate = false,
|
||||
}: {
|
||||
cell: SmartCellValue;
|
||||
def: SmartColumnDef;
|
||||
provisional: boolean;
|
||||
/** Middle-truncate long text to a fixed cap. On for the table; the preview scrolls instead. */
|
||||
truncate?: boolean;
|
||||
}) {
|
||||
if (cell.state === "offloaded") {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
disableHoverableContent
|
||||
button={
|
||||
<span className="border-b border-dotted border-amber-500/60 text-amber-500">
|
||||
Too large
|
||||
</span>
|
||||
}
|
||||
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 <span className="text-text-dimmed">–</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn(provisional && "border-b border-dotted border-text-dimmed/50")}>
|
||||
{renderSmartValue(cell.value, def.displayAs, truncate)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string | number, unknown>)[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;
|
||||
}
|
||||
@@ -184,6 +184,7 @@ export function ScheduleInspector({
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header3 className="pb-1 pl-3">Last 5 runs</Header3>
|
||||
<TaskRunsTable
|
||||
enableSmartColumns={false}
|
||||
total={schedule.runs.length}
|
||||
hasFilters={false}
|
||||
filters={{
|
||||
|
||||
@@ -183,6 +183,7 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
return this.trace("call", async (span) => {
|
||||
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"],
|
||||
|
||||
@@ -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<NextRunList | undefined> {
|
||||
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) {
|
||||
|
||||
@@ -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/", ""),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
+11
-5
@@ -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() {
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={null}>
|
||||
<TypedAwait resolve={runList} errorElement={null}>
|
||||
{(list) => (list ? <ListPagination list={list} /> : null)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
<>
|
||||
<RunsDisplayOptions sampleFilters={{ tasks: agent.slug, rootOnly: "false" }} />
|
||||
<Suspense fallback={null}>
|
||||
<TypedAwait resolve={runList} errorElement={null}>
|
||||
{(list) => (list ? <ListPagination list={list} /> : null)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabContainer>
|
||||
|
||||
+9
@@ -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";
|
||||
@@ -270,6 +272,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
columns: getRunColumnsForSelect(request),
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
@@ -536,6 +539,12 @@ function ErrorGroupDetail({
|
||||
>
|
||||
Bulk replay…
|
||||
</PermissionLink>
|
||||
<RunsDisplayOptions
|
||||
sampleFilters={{
|
||||
errorId: ErrorId.toFriendlyId(fingerprint),
|
||||
rootOnly: "false",
|
||||
}}
|
||||
/>
|
||||
<ListPagination list={runList} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+5
-1
@@ -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,
|
||||
@@ -345,7 +348,7 @@ function RunsList({
|
||||
hasFilters={list.hasFilters}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<div className="flex items-center justify-end gap-x-1.5">
|
||||
{showNewRunsBanner && (
|
||||
<span className="flex duration-150 animate-in fade-in-0">
|
||||
<Button
|
||||
@@ -401,6 +404,7 @@ function RunsList({
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
<RunsDisplayOptions />
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -245,6 +251,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,
|
||||
|
||||
+4
@@ -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,
|
||||
@@ -220,6 +222,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
cursor,
|
||||
direction,
|
||||
includeHasAnyRuns: true,
|
||||
columns: getRunColumnsForSelect(request),
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
@@ -370,6 +373,7 @@ export default function Page() {
|
||||
onClick={() => showNewRunsRef.current()}
|
||||
/>
|
||||
) : null}
|
||||
<RunsDisplayOptions sampleFilters={{ tasks: task.slug, rootOnly: "false" }} />
|
||||
<Suspense fallback={null}>
|
||||
<TypedAwait resolve={runList} errorElement={null}>
|
||||
{(list) => (list ? <ListPagination list={list} /> : null)}
|
||||
|
||||
+4
@@ -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}
|
||||
<RunsDisplayOptions sampleFilters={{ tasks: task.slug, rootOnly: "false" }} />
|
||||
<Suspense fallback={null}>
|
||||
<TypedAwait resolve={runList} errorElement={null}>
|
||||
{(list) => (list ? <ListPagination list={list} /> : null)}
|
||||
|
||||
+1
@@ -136,6 +136,7 @@ export default function Page() {
|
||||
<InfoIconTooltip content="These runs have been blocked by this waitpoint." />
|
||||
</div>
|
||||
<TaskRunsTable
|
||||
enableSmartColumns={false}
|
||||
total={waitpoint.connectedRuns.length}
|
||||
hasFilters={false}
|
||||
filters={{
|
||||
|
||||
+1
@@ -487,6 +487,7 @@ function WebhookContentArea({
|
||||
list ? (
|
||||
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TaskRunsTable
|
||||
enableSmartColumns={false}
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
|
||||
+7
@@ -7,6 +7,8 @@ import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstan
|
||||
import { loadProjectEnvironmentFromRequest } from "~/services/loadProjectEnvironmentFromRequest.server";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { runIdsQueryParam } from "~/utils/searchParams";
|
||||
import { deriveRunSelect } from "~/components/runs/v3/runColumns";
|
||||
import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
runIds: runIdsQueryParam,
|
||||
@@ -36,6 +38,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
"runsList"
|
||||
);
|
||||
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
|
||||
const columns = getRunColumnsForSelect(request);
|
||||
|
||||
const [runs, newRunsResult] = await Promise.all([
|
||||
runIds.length > 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))
|
||||
|
||||
+78
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { ClockIcon } from "~/assets/icons/ClockIcon";
|
||||
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
|
||||
import { AWS, DigitalOcean } from "~/assets/icons/CloudProviderIcon";
|
||||
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
|
||||
import { ColumnsIcon } from "~/assets/icons/ColumnsIcon";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import {
|
||||
CheckingConnectionIcon,
|
||||
@@ -104,6 +105,7 @@ import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
|
||||
import { QueuesIcon } from "~/assets/icons/QueuesIcon";
|
||||
import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon";
|
||||
import { FlagEurope, FlagUSA } from "~/assets/icons/RegionIcons";
|
||||
import { ResetIcon } from "~/assets/icons/ResetIcon";
|
||||
import { RightSideMenuIcon } from "~/assets/icons/RightSideMenuIcon";
|
||||
import { RolesIcon } from "~/assets/icons/RolesIcon";
|
||||
import { RunFunctionIcon } from "~/assets/icons/RunFunctionIcon";
|
||||
@@ -117,6 +119,7 @@ import { SideMenuRightClosedIcon } from "~/assets/icons/SideMenuRightClosed";
|
||||
import { SlackIcon } from "~/assets/icons/SlackIcon";
|
||||
import { SlackMonoIcon } from "~/assets/icons/SlackMonoIcon";
|
||||
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
|
||||
import { SmartColumnIcon } from "~/assets/icons/SmartColumnIcon";
|
||||
import { SnakedArrowIcon } from "~/assets/icons/SnakedArrowIcon";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { StarIcon } from "~/assets/icons/StarIcon";
|
||||
@@ -181,6 +184,7 @@ const icons: IconEntry[] = [
|
||||
{ name: "ClockIcon", render: simple(ClockIcon) },
|
||||
{ name: "ClockRotateLeftIcon", render: simple(ClockRotateLeftIcon) },
|
||||
{ name: "CodeSquareIcon", render: simple(CodeSquareIcon) },
|
||||
{ name: "ColumnsIcon", render: simple(ColumnsIcon) },
|
||||
{ name: "ConcurrencyIcon", render: simple(ConcurrencyIcon) },
|
||||
{ name: "ConnectedIcon", render: simple(ConnectedIcon) },
|
||||
{ name: "CubeSparkleIcon", render: simple(CubeSparkleIcon) },
|
||||
@@ -248,6 +252,7 @@ const icons: IconEntry[] = [
|
||||
{ name: "QuestionMarkIcon", render: simple(QuestionMarkIcon) },
|
||||
{ name: "QueuesIcon", render: simple(QueuesIcon) },
|
||||
{ name: "RadarPulseIcon", render: simple(RadarPulseIcon) },
|
||||
{ name: "ResetIcon", render: simple(ResetIcon) },
|
||||
{ name: "RightSideMenuIcon", render: simple(RightSideMenuIcon) },
|
||||
{ name: "RolesIcon", render: simple(RolesIcon) },
|
||||
{ name: "RunFunctionIcon", render: simple(RunFunctionIcon) },
|
||||
@@ -264,6 +269,7 @@ const icons: IconEntry[] = [
|
||||
{ name: "SlackIcon", render: simple(SlackIcon) },
|
||||
{ name: "SlackMonoIcon", render: simple(SlackMonoIcon) },
|
||||
{ name: "SlidersIcon", render: simple(SlidersIcon) },
|
||||
{ name: "SmartColumnIcon", render: simple(SmartColumnIcon) },
|
||||
{ name: "SnakedArrowIcon", render: simple(SnakedArrowIcon) },
|
||||
{ name: "SparkleListIcon", render: simple(SparkleListIcon) },
|
||||
{ name: "StarIcon", render: simple(StarIcon) },
|
||||
|
||||
@@ -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<ListedRun>(
|
||||
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<ListedRun[]>
|
||||
);
|
||||
|
||||
// ClickHouse is slightly delayed, so we're going to do in-memory status filtering too
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user