feat(webapp): runs live updating
## Summary The Runs list now updates live without requiring a page refresh. Status changes and other run fields are updated in place while runs are executing. When new runs matching the current filters are created, a "New runs created" refresh button appears above the list. Root runs now show a live child-run status breakdown directly in the status tooltip. ### List live update - Visible runs update in place while they are still running. - A "New runs created" refresh button appears when new matching runs are detected. - Polling stops when all visible runs have finished and a refresh button is already shown. - Polling pauses when the browser tab is not visible. - Runs list status updates and new-run detection share a single runs/live polling path. ### Child-status tooltip - Root run tooltips now display a breakdown of child run statuses. - Child statuses are loaded when the tooltip opens (after a 400ms hover delay). - The tooltip stays up to date while child runs are still changing state. - Handles cases where child runs continue running after their parent run has completed, or have not yet been created. ### Supporting changes - Added hidden-tab awareness to polling. - Added safeguards around polling inputs (`runIds` deduping and limits). ## Test plan - [x] pnpm run typecheck --filter webapp passes - [x] cd apps/webapp && pnpm run test ./test/presenters/mapRunToLiveFields.test.ts --run passes - [x] cd apps/webapp && pnpm run test ./test/runsRepository.part2.test.ts --run -t "hasNewRuns" passes ### Manual smoke: - [x] Active runs update without a page refresh. - [x] A new matching run shows the refresh banner and the banner actions work as expected. - [x] Root run tooltips show live child-status updates and stop polling once child runs settle. --------- Co-authored-by: Ekaterina Bulatova <kathiekiwi@Ekaterinas-MacBook-Pro.local>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Root run status cells on the runs table show a tooltip with a breakdown of child run statuses, aggregated in ClickHouse (roots resolved in Postgres by friendly ID).
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
The runs index live-reloads visible run statuses and shows a "new runs created" refresh banner. Polling pauses while the browser tab is hidden.
|
||||
@@ -318,12 +318,12 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
|
||||
type ButtonPropsType = Pick<
|
||||
JSX.IntrinsicElements["button"],
|
||||
"type" | "disabled" | "onClick" | "name" | "value" | "form" | "autoFocus"
|
||||
"type" | "disabled" | "onClick" | "name" | "value" | "form" | "autoFocus" | "aria-label"
|
||||
> &
|
||||
React.ComponentProps<typeof ButtonContent>;
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
({ type, disabled, autoFocus, onClick, ...props }, ref) => {
|
||||
({ type, disabled, autoFocus, onClick, "aria-label": ariaLabel, ...props }, ref) => {
|
||||
const innerRef = useRef<HTMLButtonElement>(null);
|
||||
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
|
||||
|
||||
@@ -352,6 +352,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
ref={innerRef}
|
||||
form={props.form}
|
||||
autoFocus={autoFocus}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<ButtonContent
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function PulsingDot({
|
||||
className,
|
||||
ringClassName,
|
||||
dotClassName,
|
||||
}: {
|
||||
className?: string;
|
||||
ringClassName?: string;
|
||||
dotClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("relative flex size-2", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute h-full w-full animate-ping rounded-full border border-blue-500 opacity-100 duration-1000",
|
||||
ringClassName
|
||||
)}
|
||||
/>
|
||||
<span className={cn("size-2 rounded-full bg-blue-500", dotClassName)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,7 @@ function SimpleTooltip({
|
||||
sideOffset,
|
||||
open,
|
||||
onOpenChange,
|
||||
delayDuration,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
@@ -80,12 +81,13 @@ function SimpleTooltip({
|
||||
sideOffset?: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
delayDuration?: number;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange}>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange} delayDuration={delayDuration}>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
type={asChild ? undefined : "button"}
|
||||
tabIndex={-1}
|
||||
className={cn(!asChild && "h-fit", buttonClassName)}
|
||||
style={buttonStyle}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import type { NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import type { loader as childStatusesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import {
|
||||
allTaskRunStatuses,
|
||||
descriptionForTaskRunStatus,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
|
||||
const TOOLTIP_OPEN_DELAY_MS = 400;
|
||||
const TOOLTIP_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
type ChildStatusEntry = { status: NextRunListItem["status"]; count: number };
|
||||
|
||||
// Compare status/count pairs so unchanged polling responses don't
|
||||
// re-render or re-animate the tooltip.
|
||||
function childStatusesKey(statuses: ChildStatusEntry[]) {
|
||||
return [...statuses]
|
||||
.sort((a, b) => a.status.localeCompare(b.status))
|
||||
.map((entry) => `${entry.status}:${entry.count}`)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function areChildStatusesEqual(previous: ChildStatusEntry[] | undefined, next: ChildStatusEntry[]) {
|
||||
if (previous === undefined) return false;
|
||||
return childStatusesKey(previous) === childStatusesKey(next);
|
||||
}
|
||||
|
||||
function hasActiveChildStatuses(statuses: ChildStatusEntry[] | undefined) {
|
||||
if (statuses === undefined) return false;
|
||||
|
||||
return statuses.some((entry) => entry.count > 0 && !isFinalRunStatus(entry.status));
|
||||
}
|
||||
|
||||
function shouldPollWhileTooltipOpen(
|
||||
statuses: ChildStatusEntry[] | undefined,
|
||||
rootHasFinished: boolean
|
||||
) {
|
||||
if (statuses === undefined) return true;
|
||||
// Empty child statuses while the root is still running can mean
|
||||
// children have not been created yet, so keep polling.
|
||||
if (statuses.length === 0) return !rootHasFinished;
|
||||
|
||||
// All current children may be final while the root is still running — more
|
||||
// dependents can still be created.
|
||||
return hasActiveChildStatuses(statuses) || !rootHasFinished;
|
||||
}
|
||||
|
||||
function ChildStatusBreakdown({
|
||||
orderedChildStatuses,
|
||||
}: {
|
||||
orderedChildStatuses: { status: NextRunListItem["status"]; count: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-[10rem] flex-col gap-1 p-1">
|
||||
<p className="mb-1 text-xs text-text-dimmed">Child run statuses</p>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{orderedChildStatuses.map((entry) => (
|
||||
<motion.div
|
||||
key={entry.status}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<TaskRunStatusCombo status={entry.status} />
|
||||
<motion.span
|
||||
key={entry.count}
|
||||
layout
|
||||
initial={{ opacity: 0.6, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="text-xs tabular-nums text-text-bright"
|
||||
>
|
||||
{entry.count}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useChildRunStatusesTooltip({
|
||||
friendlyId,
|
||||
hasFinished,
|
||||
childrenStatusesBasePath,
|
||||
}: {
|
||||
friendlyId: string;
|
||||
hasFinished: boolean;
|
||||
childrenStatusesBasePath: string;
|
||||
}) {
|
||||
const fetcher = useFetcher<typeof childStatusesLoader>({
|
||||
key: `child-statuses-${friendlyId}`,
|
||||
});
|
||||
const fetcherStateRef = useRef(fetcher.state);
|
||||
fetcherStateRef.current = fetcher.state;
|
||||
|
||||
const [childStatuses, setChildStatuses] = useState<ChildStatusEntry[] | undefined>();
|
||||
const isOpenRef = useRef(false);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const prevHasFinishedRef = useRef(hasFinished);
|
||||
|
||||
const childrenStatusesUrl = useMemo(
|
||||
() => `${childrenStatusesBasePath}/children-statuses?runIds=${encodeURIComponent(friendlyId)}`,
|
||||
[childrenStatusesBasePath, friendlyId]
|
||||
);
|
||||
|
||||
const loadChildStatuses = useCallback(() => {
|
||||
if (fetcherStateRef.current !== "idle") return;
|
||||
fetcher.load(childrenStatusesUrl);
|
||||
}, [childrenStatusesUrl, fetcher]);
|
||||
|
||||
// Keep the latest loader callback available to the polling interval
|
||||
// without recreating the interval on every render.
|
||||
const loadChildStatusesRef = useRef(loadChildStatuses);
|
||||
loadChildStatusesRef.current = loadChildStatuses;
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) return;
|
||||
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
loadChildStatusesRef.current();
|
||||
}, TOOLTIP_POLL_INTERVAL_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data?.runs) return;
|
||||
|
||||
const entry = fetcher.data.runs.find((run) => run.friendlyId === friendlyId);
|
||||
if (!entry) return;
|
||||
|
||||
setChildStatuses((previous) =>
|
||||
areChildStatusesEqual(previous, entry.statuses) ? previous : entry.statuses
|
||||
);
|
||||
|
||||
if (isOpenRef.current && !shouldPollWhileTooltipOpen(entry.statuses, hasFinished)) {
|
||||
stopPolling();
|
||||
}
|
||||
}, [fetcher.data, friendlyId, hasFinished, stopPolling]);
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
isOpenRef.current = open;
|
||||
if (open) {
|
||||
loadChildStatuses();
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
},
|
||||
[loadChildStatuses, startPolling, stopPolling]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
prevHasFinishedRef.current = hasFinished;
|
||||
stopPolling();
|
||||
setChildStatuses(undefined);
|
||||
if (isOpenRef.current) {
|
||||
loadChildStatuses();
|
||||
startPolling();
|
||||
}
|
||||
// Only reset when the hovered run changes, not when hasFinished toggles.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- friendlyId
|
||||
}, [friendlyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpenRef.current) return;
|
||||
if (prevHasFinishedRef.current === hasFinished) return;
|
||||
|
||||
prevHasFinishedRef.current = hasFinished;
|
||||
loadChildStatuses();
|
||||
}, [hasFinished, loadChildStatuses]);
|
||||
|
||||
useEffect(() => () => stopPolling(), [stopPolling]);
|
||||
|
||||
return {
|
||||
childStatuses,
|
||||
onOpenChange,
|
||||
};
|
||||
}
|
||||
|
||||
export function RunStatusCellTooltip({
|
||||
friendlyId,
|
||||
status,
|
||||
hasFinished,
|
||||
childrenStatusesBasePath,
|
||||
}: {
|
||||
friendlyId: string;
|
||||
status: NextRunListItem["status"];
|
||||
hasFinished: boolean;
|
||||
childrenStatusesBasePath: string;
|
||||
}) {
|
||||
const { childStatuses, onOpenChange } = useChildRunStatusesTooltip({
|
||||
friendlyId,
|
||||
hasFinished,
|
||||
childrenStatusesBasePath,
|
||||
});
|
||||
|
||||
const orderedChildStatuses = useMemo(() => {
|
||||
const childStatusesMap = new Map(
|
||||
(childStatuses ?? []).map((entry) => [entry.status, entry.count])
|
||||
);
|
||||
|
||||
return allTaskRunStatuses
|
||||
.map((s) => ({
|
||||
status: s,
|
||||
count: childStatusesMap.get(s) ?? 0,
|
||||
}))
|
||||
.filter((entry) => entry.count > 0);
|
||||
}, [childStatuses]);
|
||||
|
||||
const hasChildStatuses = orderedChildStatuses.length > 0;
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
delayDuration={TOOLTIP_OPEN_DELAY_MS}
|
||||
onOpenChange={onOpenChange}
|
||||
content={
|
||||
hasChildStatuses ? (
|
||||
<ChildStatusBreakdown orderedChildStatuses={orderedChildStatuses} />
|
||||
) : (
|
||||
descriptionForTaskRunStatus(status)
|
||||
)
|
||||
}
|
||||
disableHoverableContent
|
||||
button={
|
||||
<span className="inline-flex min-w-full items-center">
|
||||
<TaskRunStatusCombo status={status} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
filterableTaskRunStatuses,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { RunStatusCellTooltip } from "./RunStatusCellTooltip";
|
||||
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
@@ -74,6 +75,7 @@ type RunsTableProps = {
|
||||
variant?: TableVariant;
|
||||
disableAdjacentRows?: boolean;
|
||||
additionalTableState?: Record<string, string>;
|
||||
childrenStatusesBasePath?: string;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -87,6 +89,7 @@ export function TaskRunsTable({
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
additionalTableState,
|
||||
childrenStatusesBasePath,
|
||||
}: RunsTableProps) {
|
||||
const regions = useRegions();
|
||||
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
|
||||
@@ -371,11 +374,20 @@ export function TaskRunsTable({
|
||||
</TableCell>
|
||||
<TableCell to={path}>{run.version ?? "–"}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<SimpleTooltip
|
||||
content={descriptionForTaskRunStatus(run.status)}
|
||||
disableHoverableContent
|
||||
button={<TaskRunStatusCombo status={run.status} />}
|
||||
/>
|
||||
{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} /> : "–"}
|
||||
|
||||
@@ -6,6 +6,8 @@ type UseIntervalOptions = {
|
||||
onLoad?: boolean;
|
||||
onFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Skip interval ticks while the document tab is hidden */
|
||||
pauseWhenHidden?: boolean;
|
||||
callback: () => void;
|
||||
};
|
||||
|
||||
@@ -14,6 +16,7 @@ export function useInterval({
|
||||
onLoad = true,
|
||||
onFocus = true,
|
||||
disabled = false,
|
||||
pauseWhenHidden = false,
|
||||
callback,
|
||||
}: UseIntervalOptions) {
|
||||
// Always keep the latest callback in a ref so the effects below
|
||||
@@ -28,11 +31,14 @@ export function useInterval({
|
||||
if (!interval || interval <= 0 || disabled) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (pauseWhenHidden && document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
latestCallback.current();
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [interval, disabled]);
|
||||
}, [interval, disabled, pauseWhenHidden]);
|
||||
|
||||
// On focus
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
|
||||
import type { ListedRun } from "~/services/runsRepository/runsRepository.server";
|
||||
|
||||
export function mapRunToLiveFields(run: ListedRun) {
|
||||
const hasFinished = isFinalRunStatus(run.status);
|
||||
const startedAt = run.startedAt ?? run.lockedAt;
|
||||
|
||||
return {
|
||||
friendlyId: run.friendlyId,
|
||||
status: run.status,
|
||||
updatedAt: run.updatedAt.toISOString(),
|
||||
startedAt: startedAt?.toISOString(),
|
||||
finishedAt: hasFinished ? run.completedAt?.toISOString() ?? run.updatedAt.toISOString() : undefined,
|
||||
hasFinished,
|
||||
isCancellable: isCancellableRunStatus(run.status),
|
||||
isPending: isPendingRunStatus(run.status),
|
||||
usageDurationMs: Number(run.usageDurationMs),
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
};
|
||||
}
|
||||
+51
-5
@@ -1,5 +1,5 @@
|
||||
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction, useNavigation } from "@remix-run/react";
|
||||
import { type MetaFunction, useNavigation, useRevalidator } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
@@ -14,11 +14,12 @@ import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence"
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PulsingDot } from "~/components/primitives/PulsingDot";
|
||||
import {
|
||||
RESIZABLE_PANEL_ANIMATION,
|
||||
ResizableHandle,
|
||||
@@ -64,6 +65,7 @@ import { throwNotFound } from "~/utils/httpErrors";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { useRunsLiveReload } from "./useRunsLiveReload";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -89,7 +91,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const filters = await getRunFiltersFromRequest(request);
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new NextRunListPresenter($replica, clickhouse);
|
||||
const list = presenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
@@ -203,12 +208,36 @@ function RunsList({
|
||||
rootOnlyDefault: boolean;
|
||||
filters: TaskRunListSearchFilters;
|
||||
}) {
|
||||
const revalidator = useRevalidator();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { has, replace } = useSearchParams();
|
||||
const { visibleRuns, showNewRunsBanner, newRunsCount, dismissNewRuns, childrenStatusesBasePath } =
|
||||
useRunsLiveReload({
|
||||
runs: list.runs,
|
||||
hasAnyRuns: list.hasAnyRuns,
|
||||
isLoading,
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
environmentSlug: environment.slug,
|
||||
});
|
||||
|
||||
const onClickShowNewRuns = () => {
|
||||
const isPaginated = has("cursor") || has("direction");
|
||||
dismissNewRuns();
|
||||
if (isPaginated) {
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
revalidator.revalidate();
|
||||
};
|
||||
|
||||
// Shortcut keys for bulk actions
|
||||
useShortcutKeys({
|
||||
@@ -265,6 +294,22 @@ function RunsList({
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
{showNewRunsBanner && (
|
||||
<span className="flex duration-150 animate-in fade-in-0">
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
className="text-text-bright"
|
||||
onClick={onClickShowNewRuns}
|
||||
LeadingIcon={<PulsingDot className="h-2 w-2" />}
|
||||
tooltip="Refresh to see new runs"
|
||||
aria-label="New runs created. Refresh to see new runs."
|
||||
>
|
||||
{newRunsCount >= 100
|
||||
? "99+ new runs"
|
||||
: `${newRunsCount} new ${newRunsCount === 1 ? "run" : "runs"}`}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
{!isShowingBulkActionInspector && (
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
@@ -303,10 +348,11 @@ function RunsList({
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
total={visibleRuns.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
runs={visibleRuns}
|
||||
childrenStatusesBasePath={childrenStatusesBasePath}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { useInterval } from "~/hooks/useInterval";
|
||||
import type { NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import type { loader as liveRunsLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live";
|
||||
|
||||
const RUNS_SEARCH_PARAMS_TO_REMOVE = ["cursor", "direction", "bulkInspector", "action", "mode"];
|
||||
const RUNS_POLL_INTERVAL_MS = 3000;
|
||||
/** Check for new runs every N poll ticks (~6s at 3s interval). */
|
||||
const NEW_RUNS_EVERY_N_POLL_TICKS = 2;
|
||||
|
||||
type ListedRun = NextRunListItem;
|
||||
type LiveRunFields = Awaited<ReturnType<typeof liveRunsLoader>>["runs"][number];
|
||||
type LivePollFetcherData = Awaited<ReturnType<typeof liveRunsLoader>> | undefined;
|
||||
|
||||
function hasNewRunsCountFields(
|
||||
data: LivePollFetcherData
|
||||
): data is NonNullable<LivePollFetcherData> & { count: number; since: number } {
|
||||
return data !== undefined && "count" in data && "since" in data;
|
||||
}
|
||||
|
||||
function maxCreatedAtMs(runs: ListedRun[]): number | undefined {
|
||||
if (runs.length === 0) return undefined;
|
||||
|
||||
return runs.reduce<number>((maxTimestamp, run) => {
|
||||
const runTimestamp = new Date(run.createdAt).getTime();
|
||||
return Math.max(maxTimestamp, runTimestamp);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function filterParamsWithoutPagination(search: string) {
|
||||
const params = new URLSearchParams(search);
|
||||
for (const key of RUNS_SEARCH_PARAMS_TO_REMOVE) {
|
||||
params.delete(key);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function getRunsSearchKeyWithoutPagination(search: string) {
|
||||
return filterParamsWithoutPagination(search).toString();
|
||||
}
|
||||
|
||||
function isNewRunsCheckTick(tick: number) {
|
||||
return tick === 1 || tick % NEW_RUNS_EVERY_N_POLL_TICKS === 0;
|
||||
}
|
||||
|
||||
function appendNewRunsSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
{ locationSearch, since }: { locationSearch: string; since: number }
|
||||
) {
|
||||
const filterParams = filterParamsWithoutPagination(locationSearch);
|
||||
for (const [key, value] of filterParams) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
searchParams.set("includeNewRuns", "true");
|
||||
searchParams.set("since", String(since));
|
||||
}
|
||||
|
||||
function patchVisibleRunsWithLiveUpdates(currentRuns: ListedRun[], liveRuns: LiveRunFields[]) {
|
||||
const updatesById = new Map(liveRuns.map((run) => [run.friendlyId, run]));
|
||||
|
||||
return currentRuns.map((run) => {
|
||||
const update = updatesById.get(run.friendlyId);
|
||||
if (!update) return run;
|
||||
|
||||
return {
|
||||
...run,
|
||||
status: update.status,
|
||||
updatedAt: update.updatedAt,
|
||||
startedAt: update.startedAt,
|
||||
finishedAt: update.finishedAt,
|
||||
hasFinished: update.hasFinished,
|
||||
isCancellable: update.isCancellable,
|
||||
isPending: update.isPending,
|
||||
usageDurationMs: update.usageDurationMs,
|
||||
costInCents: update.costInCents,
|
||||
baseCostInCents: update.baseCostInCents,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useNewRunsDetection({
|
||||
runs,
|
||||
hasAnyRuns,
|
||||
isLoading,
|
||||
}: {
|
||||
runs: ListedRun[];
|
||||
hasAnyRuns: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const pollTickRef = useRef(0);
|
||||
const [knownNewestRunMs, setKnownNewestRunMs] = useState(() => maxCreatedAtMs(runs) ?? Date.now());
|
||||
const [newRunsCount, setNewRunsCount] = useState(0);
|
||||
|
||||
const shouldPollForNewRuns = hasAnyRuns && !isLoading && newRunsCount < 100;
|
||||
|
||||
// Re-baseline the cutoff and clear banner/throttle state. The parent calls
|
||||
// this from its single "list context changed" reset path.
|
||||
const resetNewRunsTracking = useCallback(() => {
|
||||
setKnownNewestRunMs(maxCreatedAtMs(runs) ?? Date.now());
|
||||
setNewRunsCount(0);
|
||||
pollTickRef.current = 0;
|
||||
}, [runs]);
|
||||
|
||||
const dismissNewRuns = useCallback(() => {
|
||||
setNewRunsCount(0);
|
||||
setKnownNewestRunMs(Date.now());
|
||||
pollTickRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const checkNewRunsOnTick = useCallback(() => {
|
||||
pollTickRef.current += 1;
|
||||
return shouldPollForNewRuns && isNewRunsCheckTick(pollTickRef.current);
|
||||
}, [shouldPollForNewRuns]);
|
||||
|
||||
const showNewRunsBanner = newRunsCount > 0;
|
||||
|
||||
return {
|
||||
knownNewestRunMs,
|
||||
newRunsCount,
|
||||
setNewRunsCount,
|
||||
shouldPollForNewRuns,
|
||||
showNewRunsBanner,
|
||||
dismissNewRuns,
|
||||
checkNewRunsOnTick,
|
||||
resetNewRunsTracking,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRunsLiveReload({
|
||||
runs,
|
||||
hasAnyRuns,
|
||||
isLoading,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
runs: ListedRun[];
|
||||
hasAnyRuns: boolean;
|
||||
isLoading: boolean;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const runsPollFetcher = useTypedFetcher<typeof liveRunsLoader>();
|
||||
const runsPollFetcherStateRef = useRef(runsPollFetcher.state);
|
||||
runsPollFetcherStateRef.current = runsPollFetcher.state;
|
||||
|
||||
const [visibleRuns, setVisibleRuns] = useState(runs);
|
||||
|
||||
const searchKeyWithoutPagination = useMemo(
|
||||
() => getRunsSearchKeyWithoutPagination(location.search),
|
||||
[location.search]
|
||||
);
|
||||
|
||||
const {
|
||||
knownNewestRunMs,
|
||||
newRunsCount,
|
||||
setNewRunsCount,
|
||||
shouldPollForNewRuns,
|
||||
showNewRunsBanner,
|
||||
dismissNewRuns,
|
||||
checkNewRunsOnTick,
|
||||
resetNewRunsTracking,
|
||||
} = useNewRunsDetection({
|
||||
runs,
|
||||
hasAnyRuns,
|
||||
isLoading,
|
||||
});
|
||||
|
||||
// Single reset path: new loader data or changed filters re-baseline both the
|
||||
// visible rows and new-run tracking.
|
||||
useEffect(() => {
|
||||
setVisibleRuns(runs);
|
||||
resetNewRunsTracking();
|
||||
}, [runs, searchKeyWithoutPagination, resetNewRunsTracking]);
|
||||
|
||||
// Patch visible rows from the live response. Keyed to the response alone so a
|
||||
// loader refresh never re-applies a stale poll payload over fresh rows.
|
||||
useEffect(() => {
|
||||
const data = runsPollFetcher.data;
|
||||
if (!data?.runs.length) return;
|
||||
|
||||
setVisibleRuns((currentRuns) => patchVisibleRunsWithLiveUpdates(currentRuns, data.runs));
|
||||
}, [runsPollFetcher.data]);
|
||||
|
||||
// Update new-runs count from the poll response. Re-evaluates when the cutoff
|
||||
// changes, even if the response object itself is unchanged.
|
||||
useEffect(() => {
|
||||
const data = runsPollFetcher.data;
|
||||
if (!hasNewRunsCountFields(data)) return;
|
||||
|
||||
if (data.since === knownNewestRunMs) {
|
||||
setNewRunsCount(data.count);
|
||||
}
|
||||
}, [runsPollFetcher.data, knownNewestRunMs, setNewRunsCount]);
|
||||
|
||||
const activeRunIdsParam = useMemo(
|
||||
() =>
|
||||
visibleRuns
|
||||
.filter((run) => !run.hasFinished)
|
||||
.map((run) => run.friendlyId)
|
||||
.join(","),
|
||||
[visibleRuns]
|
||||
);
|
||||
const hasActiveRuns = activeRunIdsParam.length > 0;
|
||||
|
||||
const runsResourcesBasePath = useMemo(
|
||||
() =>
|
||||
`/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/runs`,
|
||||
[organizationSlug, projectSlug, environmentSlug]
|
||||
);
|
||||
|
||||
const loadRunsPoll = useCallback(
|
||||
(checkForNewRuns: boolean) => {
|
||||
if (runsPollFetcherStateRef.current !== "idle") return;
|
||||
|
||||
if (!hasActiveRuns && !checkForNewRuns) return;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (hasActiveRuns) {
|
||||
searchParams.set("runIds", activeRunIdsParam);
|
||||
}
|
||||
|
||||
if (checkForNewRuns) {
|
||||
appendNewRunsSearchParams(searchParams, {
|
||||
locationSearch: location.search,
|
||||
since: knownNewestRunMs,
|
||||
});
|
||||
}
|
||||
|
||||
runsPollFetcher.load(`${runsResourcesBasePath}/live?${searchParams.toString()}`);
|
||||
},
|
||||
[
|
||||
activeRunIdsParam,
|
||||
hasActiveRuns,
|
||||
location.search,
|
||||
knownNewestRunMs,
|
||||
runsPollFetcher,
|
||||
runsResourcesBasePath,
|
||||
]
|
||||
);
|
||||
|
||||
const shouldPoll = !isLoading && (hasActiveRuns || shouldPollForNewRuns);
|
||||
|
||||
useInterval({
|
||||
interval: RUNS_POLL_INTERVAL_MS,
|
||||
onLoad: true,
|
||||
pauseWhenHidden: true,
|
||||
disabled: !shouldPoll,
|
||||
callback: () => {
|
||||
loadRunsPoll(checkNewRunsOnTick());
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
visibleRuns,
|
||||
showNewRunsBanner,
|
||||
newRunsCount,
|
||||
dismissNewRuns,
|
||||
childrenStatusesBasePath: runsResourcesBasePath,
|
||||
};
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type TaskRunStatus } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { loadProjectEnvironmentFromRequest } from "~/services/loadProjectEnvironmentFromRequest.server";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { runIdsQueryParam } from "~/utils/searchParams";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
runIds: runIdsQueryParam,
|
||||
});
|
||||
|
||||
const ROOT_CREATED_AT_SAFETY_MARGIN_MS = 5 * 60 * 1000;
|
||||
|
||||
type RootRun = { id: string; friendlyId: string; createdAt: Date };
|
||||
type ChildStatusEntry = { status: TaskRunStatus; count: number };
|
||||
type GroupedChildStatus = {
|
||||
rootRunId: string;
|
||||
status: TaskRunStatus;
|
||||
count: number;
|
||||
};
|
||||
|
||||
function mapGroupedStatusesToFriendlyIds(
|
||||
grouped: GroupedChildStatus[],
|
||||
roots: RootRun[]
|
||||
): Map<string, ChildStatusEntry[]> {
|
||||
const rootFriendlyIdById = new Map(roots.map((run) => [run.id, run.friendlyId]));
|
||||
const statusesByFriendlyId = new Map<string, ChildStatusEntry[]>();
|
||||
|
||||
for (const item of grouped) {
|
||||
const friendlyId = rootFriendlyIdById.get(item.rootRunId);
|
||||
if (!friendlyId) continue;
|
||||
|
||||
const existing = statusesByFriendlyId.get(friendlyId) ?? [];
|
||||
existing.push({
|
||||
status: item.status,
|
||||
count: item.count,
|
||||
});
|
||||
statusesByFriendlyId.set(friendlyId, existing);
|
||||
}
|
||||
|
||||
return statusesByFriendlyId;
|
||||
}
|
||||
|
||||
function childrenStatusesResponseForRunIds(
|
||||
runIds: string[],
|
||||
statusesByFriendlyId: Map<string, ChildStatusEntry[]>
|
||||
) {
|
||||
return {
|
||||
runs: runIds.map((friendlyId) => ({
|
||||
friendlyId,
|
||||
statuses: (statusesByFriendlyId.get(friendlyId) ?? []).filter((entry) => entry.count > 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const { runIds } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
|
||||
|
||||
if (runIds.length === 0) {
|
||||
return { runs: [] };
|
||||
}
|
||||
|
||||
const { project, environment } = await loadProjectEnvironmentFromRequest(request, params);
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
|
||||
|
||||
const { runs: roots } = await runsRepository.listRuns({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
runId: runIds,
|
||||
page: { size: 100 },
|
||||
});
|
||||
|
||||
if (roots.length === 0) {
|
||||
return { runs: [] };
|
||||
}
|
||||
|
||||
const earliestRootCreatedAtMs = Math.min(...roots.map((run) => run.createdAt.getTime()));
|
||||
const sinceMs = Math.max(0, earliestRootCreatedAtMs - ROOT_CREATED_AT_SAFETY_MARGIN_MS);
|
||||
|
||||
const [queryError, groupedRows] = await clickhouse.taskRuns.getChildRunStatusCounts({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
rootRunIds: roots.map((run) => run.id),
|
||||
since: sinceMs,
|
||||
});
|
||||
|
||||
if (queryError) {
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
const grouped: GroupedChildStatus[] = groupedRows.map((row) => ({
|
||||
rootRunId: row.root_run_id,
|
||||
status: row.status as TaskRunStatus,
|
||||
count: row.count,
|
||||
}));
|
||||
|
||||
const statusesByFriendlyId = mapGroupedStatusesToFriendlyIds(grouped, roots);
|
||||
|
||||
return childrenStatusesResponseForRunIds(runIds, statusesByFriendlyId);
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { mapRunToLiveFields } from "~/presenters/v3/mapRunToLiveFields.server";
|
||||
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 { runIdsQueryParam } from "~/utils/searchParams";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
runIds: runIdsQueryParam,
|
||||
includeNewRuns: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value === "true"),
|
||||
since: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const { runIds, includeNewRuns, since } = SearchParamsSchema.parse(
|
||||
Object.fromEntries(url.searchParams)
|
||||
);
|
||||
|
||||
const newRunsSince =
|
||||
includeNewRuns && since !== undefined ? since : undefined;
|
||||
|
||||
if (runIds.length === 0 && newRunsSince === undefined) {
|
||||
return { runs: [] };
|
||||
}
|
||||
|
||||
const { project, environment } = await loadProjectEnvironmentFromRequest(request, params);
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
|
||||
|
||||
const [runs, newRunsResult] = await Promise.all([
|
||||
runIds.length > 0
|
||||
? runsRepository
|
||||
.listRuns({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
runId: runIds,
|
||||
page: { size: 100 },
|
||||
})
|
||||
.then(({ runs: listedRuns }) => listedRuns.map(mapRunToLiveFields))
|
||||
: Promise.resolve([]),
|
||||
newRunsSince !== undefined
|
||||
? (async () => {
|
||||
const filters = await getRunFiltersFromRequest(request);
|
||||
|
||||
if (filters.to !== undefined && filters.to <= newRunsSince) {
|
||||
return { count: 0, since: newRunsSince };
|
||||
}
|
||||
|
||||
const newRunIds = await runsRepository.listRunIds({
|
||||
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: Math.max(filters.from ?? 0, newRunsSince + 1),
|
||||
to: filters.to,
|
||||
rootOnly: filters.rootOnly,
|
||||
batchId: filters.batchId,
|
||||
runId: filters.runId,
|
||||
bulkId: filters.bulkId,
|
||||
queues: filters.queues,
|
||||
machines: filters.machines,
|
||||
errorId: filters.errorId,
|
||||
page: { size: 100 },
|
||||
});
|
||||
|
||||
return { count: newRunIds.length, since: newRunsSince };
|
||||
})()
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
if (newRunsResult) {
|
||||
return { runs, ...newRunsResult };
|
||||
}
|
||||
|
||||
return { runs };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type Params } from "@remix-run/react";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export async function loadProjectEnvironmentFromRequest(
|
||||
request: Request,
|
||||
params: Params<string>
|
||||
) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||||
}
|
||||
|
||||
return { userId, project, environment };
|
||||
}
|
||||
@@ -1,6 +1,23 @@
|
||||
import { ZodType } from "zod";
|
||||
import { z, ZodType } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
/**
|
||||
* Parses a comma-separated `runIds` query param into a trimmed, de-duplicated
|
||||
* list of run friendly IDs, capped at 100. Shared by the runs `/live` and
|
||||
* `/children-statuses` resource routes.
|
||||
*/
|
||||
export const runIdsQueryParam = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const ids =
|
||||
value
|
||||
?.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
return [...new Set(ids)].slice(0, 100);
|
||||
});
|
||||
|
||||
export function objectToSearchParams(
|
||||
obj:
|
||||
| undefined
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapRunToLiveFields } from "~/presenters/v3/mapRunToLiveFields.server";
|
||||
|
||||
describe("mapRunToLiveFields", () => {
|
||||
it("maps an executing run with lockedAt fallback and non-final flags", () => {
|
||||
const updatedAt = new Date("2026-05-07T10:00:00.000Z");
|
||||
const lockedAt = new Date("2026-05-07T09:59:50.000Z");
|
||||
|
||||
const result = mapRunToLiveFields({
|
||||
friendlyId: "run_123",
|
||||
status: "EXECUTING",
|
||||
updatedAt,
|
||||
startedAt: null,
|
||||
lockedAt,
|
||||
completedAt: null,
|
||||
usageDurationMs: BigInt(2500),
|
||||
costInCents: 10,
|
||||
baseCostInCents: 5,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
friendlyId: "run_123",
|
||||
status: "EXECUTING",
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
startedAt: lockedAt.toISOString(),
|
||||
finishedAt: undefined,
|
||||
hasFinished: false,
|
||||
isCancellable: true,
|
||||
isPending: false,
|
||||
usageDurationMs: 2500,
|
||||
costInCents: 10,
|
||||
baseCostInCents: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a final run and prefers completedAt for finishedAt", () => {
|
||||
const updatedAt = new Date("2026-05-07T10:00:00.000Z");
|
||||
const startedAt = new Date("2026-05-07T09:59:00.000Z");
|
||||
const completedAt = new Date("2026-05-07T09:59:30.000Z");
|
||||
|
||||
const result = mapRunToLiveFields({
|
||||
friendlyId: "run_456",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
updatedAt,
|
||||
startedAt,
|
||||
lockedAt: null,
|
||||
completedAt,
|
||||
usageDurationMs: 1200,
|
||||
costInCents: 20,
|
||||
baseCostInCents: 7,
|
||||
});
|
||||
|
||||
expect(result.finishedAt).toBe(completedAt.toISOString());
|
||||
expect(result.startedAt).toBe(startedAt.toISOString());
|
||||
expect(result.hasFinished).toBe(true);
|
||||
expect(result.isCancellable).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to updatedAt when a final run has no completedAt", () => {
|
||||
const updatedAt = new Date("2026-05-07T10:00:00.000Z");
|
||||
|
||||
const result = mapRunToLiveFields({
|
||||
friendlyId: "run_789",
|
||||
status: "CRASHED",
|
||||
updatedAt,
|
||||
startedAt: null,
|
||||
lockedAt: null,
|
||||
completedAt: null,
|
||||
usageDurationMs: 0,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
});
|
||||
|
||||
expect(result.finishedAt).toBe(updatedAt.toISOString());
|
||||
expect(result.hasFinished).toBe(true);
|
||||
expect(result.isPending).toBe(false);
|
||||
expect(result.isCancellable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -789,4 +789,107 @@ describe("RunsRepository (part 2/2)", () => {
|
||||
expect(secondPage.pagination.previousCursor).toBeTruthy();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
containerTest(
|
||||
"should count new runs with listRunIds",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title: "test",
|
||||
slug: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "test",
|
||||
slug: "test",
|
||||
organizationId: organization.id,
|
||||
externalRef: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "test",
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: "test",
|
||||
pkApiKey: "test",
|
||||
shortcode: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const taskRun = await prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: "run_has_new",
|
||||
taskIdentifier: "my-task",
|
||||
payload: JSON.stringify({ foo: "bar" }),
|
||||
traceId: "1234",
|
||||
spanId: "1234",
|
||||
queue: "test",
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
|
||||
await setTimeout(1000);
|
||||
|
||||
const runsRepository = new RunsRepository({
|
||||
prisma,
|
||||
clickhouse,
|
||||
});
|
||||
|
||||
const baseOptions = {
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
organizationId: organization.id,
|
||||
};
|
||||
|
||||
const createdAtMs = taskRun.createdAt.getTime();
|
||||
|
||||
const newRunIdsBefore = await runsRepository.listRunIds({
|
||||
...baseOptions,
|
||||
from: createdAtMs - 1,
|
||||
page: { size: 100 },
|
||||
});
|
||||
expect(newRunIdsBefore.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const newRunIdsAfter = await runsRepository.listRunIds({
|
||||
...baseOptions,
|
||||
from: createdAtMs + 60_000,
|
||||
page: { size: 100 },
|
||||
});
|
||||
expect(newRunIdsAfter).toHaveLength(0);
|
||||
|
||||
const fromBeforeRun = createdAtMs - 1;
|
||||
|
||||
const matchingTaskIds = await runsRepository.listRunIds({
|
||||
...baseOptions,
|
||||
from: fromBeforeRun,
|
||||
tasks: ["my-task"],
|
||||
page: { size: 100 },
|
||||
});
|
||||
expect(matchingTaskIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const otherTaskIds = await runsRepository.listRunIds({
|
||||
...baseOptions,
|
||||
from: fromBeforeRun,
|
||||
tasks: ["other-task"],
|
||||
page: { size: 100 },
|
||||
});
|
||||
expect(otherTaskIds).toHaveLength(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getTaskRunsQueryBuilder,
|
||||
getTaskActivityQueryBuilder,
|
||||
getCurrentRunningStats,
|
||||
getChildRunStatusCounts,
|
||||
getAverageDurations,
|
||||
getTaskUsageByOrganization,
|
||||
getTaskRunsCountQueryBuilder,
|
||||
@@ -228,6 +229,7 @@ export class ClickHouse {
|
||||
pendingVersionIdsQueryBuilder: getPendingVersionIdsQueryBuilder(this.reader),
|
||||
getTaskActivity: getTaskActivityQueryBuilder(this.reader),
|
||||
getCurrentRunningStats: getCurrentRunningStats(this.reader),
|
||||
getChildRunStatusCounts: getChildRunStatusCounts(this.reader),
|
||||
getAverageDurations: getAverageDurations(this.reader),
|
||||
getTaskUsageByOrganization: getTaskUsageByOrganization(this.reader),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import { clickhouseTest } from "@internal/testcontainers";
|
||||
import { z } from "zod";
|
||||
import { ClickhouseClient } from "./client/client.js";
|
||||
import {
|
||||
TASK_RUN_INDEX,
|
||||
getChildRunStatusCounts,
|
||||
getTaskRunsQueryBuilder,
|
||||
insertRawTaskRunPayloadsCompactArrays,
|
||||
insertTaskRunsCompactArrays,
|
||||
@@ -416,6 +418,311 @@ describe("Task Runs V2", () => {
|
||||
}
|
||||
);
|
||||
|
||||
clickhouseTest(
|
||||
"should aggregate child status counts with FINAL and ignore deleted rows",
|
||||
async ({ clickhouseContainer }) => {
|
||||
const client = new ClickhouseClient({
|
||||
name: "test",
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
});
|
||||
|
||||
const insert = insertTaskRunsCompactArrays(client, {
|
||||
async_insert: 0, // turn off async insert for this test
|
||||
});
|
||||
|
||||
const baseCreatedAt = new Date("2025-05-01T12:00:00.000Z").getTime();
|
||||
const oldCreatedAt = new Date("2025-04-15T12:00:00.000Z").getTime();
|
||||
const since = baseCreatedAt - 60_000;
|
||||
|
||||
const rootRun: TaskRunInsertArray = [
|
||||
"env_agg", // environment_id
|
||||
"org_agg", // organization_id
|
||||
"project_agg", // project_id
|
||||
"root_run_1", // run_id
|
||||
baseCreatedAt, // updated_at
|
||||
baseCreatedAt, // created_at
|
||||
"EXECUTING", // status
|
||||
"DEVELOPMENT", // environment_type
|
||||
"run_root_1", // friendly_id
|
||||
1, // attempt
|
||||
"V2", // engine
|
||||
"root-task", // task_identifier
|
||||
"task/root-task", // queue
|
||||
"", // schedule_id
|
||||
"", // batch_id
|
||||
null, // completed_at
|
||||
baseCreatedAt, // started_at
|
||||
null, // executed_at
|
||||
null, // delay_until
|
||||
baseCreatedAt, // queued_at
|
||||
null, // expired_at
|
||||
0, // usage_duration_ms
|
||||
0, // cost_in_cents
|
||||
0, // base_cost_in_cents
|
||||
{ data: null }, // output
|
||||
{ data: null }, // error
|
||||
"", // error_fingerprint
|
||||
[], // tags
|
||||
"", // task_version
|
||||
"", // sdk_version
|
||||
"", // cli_version
|
||||
"", // machine_preset
|
||||
"", // root_run_id
|
||||
"", // parent_run_id
|
||||
0, // depth
|
||||
"span_root", // span_id
|
||||
"trace_root", // trace_id
|
||||
"", // idempotency_key
|
||||
"", // idempotency_key_user
|
||||
"", // idempotency_key_scope
|
||||
"", // expiration_ttl
|
||||
true, // is_test
|
||||
"1", // _version
|
||||
0, // _is_deleted
|
||||
"", // concurrency_key
|
||||
[], // bulk_action_group_ids
|
||||
"", // worker_queue
|
||||
null, // max_duration_in_seconds
|
||||
"", // trigger_source
|
||||
"", // root_trigger_source
|
||||
"", // task_kind
|
||||
null, // is_warm_start
|
||||
];
|
||||
|
||||
const childA_v1: TaskRunInsertArray = [
|
||||
"env_agg",
|
||||
"org_agg",
|
||||
"project_agg",
|
||||
"child_a",
|
||||
baseCreatedAt + 1_000,
|
||||
baseCreatedAt + 1_000,
|
||||
"PENDING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_a",
|
||||
1,
|
||||
"V2",
|
||||
"child-task",
|
||||
"task/child-task",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
baseCreatedAt + 1_000,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
{ data: null },
|
||||
{ data: null },
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"root_run_1",
|
||||
"root_run_1",
|
||||
1,
|
||||
"span_child_a",
|
||||
"trace_root",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
"1",
|
||||
0,
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
];
|
||||
|
||||
const childA_v2: TaskRunInsertArray = [
|
||||
...childA_v1,
|
||||
];
|
||||
childA_v2[TASK_RUN_INDEX.status] = "COMPLETED_SUCCESSFULLY";
|
||||
childA_v2[TASK_RUN_INDEX._version] = "2";
|
||||
|
||||
const childB: TaskRunInsertArray = [
|
||||
"env_agg",
|
||||
"org_agg",
|
||||
"project_agg",
|
||||
"child_b",
|
||||
baseCreatedAt + 2_000,
|
||||
baseCreatedAt + 2_000,
|
||||
"EXECUTING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_b",
|
||||
1,
|
||||
"V2",
|
||||
"child-task",
|
||||
"task/child-task",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
baseCreatedAt + 2_000,
|
||||
null,
|
||||
null,
|
||||
baseCreatedAt + 2_000,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
{ data: null },
|
||||
{ data: null },
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"root_run_1",
|
||||
"root_run_1",
|
||||
1,
|
||||
"span_child_b",
|
||||
"trace_root",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
"1",
|
||||
0,
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
];
|
||||
|
||||
const childDeleted_v1: TaskRunInsertArray = [
|
||||
"env_agg",
|
||||
"org_agg",
|
||||
"project_agg",
|
||||
"child_deleted",
|
||||
baseCreatedAt + 3_000,
|
||||
baseCreatedAt + 3_000,
|
||||
"PENDING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_deleted",
|
||||
1,
|
||||
"V2",
|
||||
"child-task",
|
||||
"task/child-task",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
baseCreatedAt + 3_000,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
{ data: null },
|
||||
{ data: null },
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"root_run_1",
|
||||
"root_run_1",
|
||||
1,
|
||||
"span_child_deleted",
|
||||
"trace_root",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
"1",
|
||||
0,
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
];
|
||||
|
||||
const childDeleted_v2: TaskRunInsertArray = [
|
||||
...childDeleted_v1,
|
||||
];
|
||||
childDeleted_v2[TASK_RUN_INDEX._version] = "2";
|
||||
childDeleted_v2[TASK_RUN_INDEX._is_deleted] = 1;
|
||||
|
||||
const childWrongRoot: TaskRunInsertArray = [
|
||||
...childB,
|
||||
];
|
||||
childWrongRoot[TASK_RUN_INDEX.run_id] = "child_wrong_root";
|
||||
childWrongRoot[TASK_RUN_INDEX.friendly_id] = "run_child_wrong_root";
|
||||
childWrongRoot[TASK_RUN_INDEX.root_run_id] = "other_root";
|
||||
childWrongRoot[TASK_RUN_INDEX.parent_run_id] = "other_root";
|
||||
childWrongRoot[TASK_RUN_INDEX.span_id] = "span_child_wrong_root";
|
||||
|
||||
const childOld: TaskRunInsertArray = [
|
||||
...childB,
|
||||
];
|
||||
childOld[TASK_RUN_INDEX.run_id] = "child_old";
|
||||
childOld[TASK_RUN_INDEX.created_at] = oldCreatedAt;
|
||||
childOld[TASK_RUN_INDEX.updated_at] = oldCreatedAt;
|
||||
childOld[TASK_RUN_INDEX.started_at] = oldCreatedAt;
|
||||
childOld[TASK_RUN_INDEX.queued_at] = oldCreatedAt;
|
||||
childOld[TASK_RUN_INDEX.friendly_id] = "run_child_old";
|
||||
childOld[TASK_RUN_INDEX.span_id] = "span_child_old";
|
||||
|
||||
const [insertError] = await insert([
|
||||
rootRun,
|
||||
childA_v1,
|
||||
childA_v2,
|
||||
childB,
|
||||
childDeleted_v1,
|
||||
childDeleted_v2,
|
||||
childWrongRoot,
|
||||
childOld,
|
||||
]);
|
||||
|
||||
expect(insertError).toBeNull();
|
||||
|
||||
const [queryError, result] = await getChildRunStatusCounts(client)({
|
||||
organizationId: "org_agg",
|
||||
projectId: "project_agg",
|
||||
environmentId: "env_agg",
|
||||
rootRunIds: ["root_run_1"],
|
||||
since,
|
||||
});
|
||||
|
||||
expect(queryError).toBeNull();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
root_run_id: "root_run_1",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
root_run_id: "root_run_1",
|
||||
status: "EXECUTING",
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
clickhouseTest(
|
||||
"should be able to insert payloads with a duplicate path",
|
||||
async ({ clickhouseContainer }) => {
|
||||
|
||||
@@ -514,6 +514,51 @@ export function getCurrentRunningStats(ch: ClickhouseReader, settings?: ClickHou
|
||||
});
|
||||
}
|
||||
|
||||
export const ChildRunStatusCountsQueryResult = z.object({
|
||||
root_run_id: z.string(),
|
||||
status: z.string(),
|
||||
count: z.number().int(),
|
||||
});
|
||||
|
||||
export type ChildRunStatusCountsQueryResult = z.infer<typeof ChildRunStatusCountsQueryResult>;
|
||||
|
||||
export const ChildRunStatusCountsQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
rootRunIds: z.array(z.string()).min(1),
|
||||
since: z.number().int(),
|
||||
});
|
||||
|
||||
export function getChildRunStatusCounts(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getChildRunStatusCounts",
|
||||
query: `
|
||||
SELECT
|
||||
root_run_id,
|
||||
status,
|
||||
count() as count
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
AND root_run_id IN {rootRunIds: Array(String)}
|
||||
AND created_at >= fromUnixTimestamp64Milli({since: Int64})
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
root_run_id,
|
||||
status
|
||||
ORDER BY
|
||||
root_run_id ASC,
|
||||
status ASC
|
||||
`,
|
||||
schema: ChildRunStatusCountsQueryResult,
|
||||
params: ChildRunStatusCountsQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const AverageDurationsQueryResult = z.object({
|
||||
task_identifier: z.string(),
|
||||
duration: z.number(),
|
||||
|
||||
Reference in New Issue
Block a user