From 3930a5ef7ffbfd0872d47ccb58e40f4fa89dcaa0 Mon Sep 17 00:00:00 2001 From: Ekaterina Bulatova Date: Sat, 30 May 2026 00:34:55 +0200 Subject: [PATCH 1/2] feat(webapp): live-reload runs index status and new-runs banner Poll runs/live every 3s for unfinished visible runs and patch status in place. Detect newer runs matching filters (~6s) and show a pulsing refresh banner. Pause polling when the tab is hidden or the banner is shown. --- .server-changes/runs-list-live-reload.md | 6 + .../app/components/primitives/Buttons.tsx | 5 +- .../app/components/primitives/PulsingDot.tsx | 23 ++ apps/webapp/app/hooks/useInterval.ts | 8 +- .../v3/mapRunToLiveFields.server.ts | 33 +++ .../route.tsx | 47 ++- .../useRunsLiveReload.ts | 276 ++++++++++++++++++ ...s.$projectParam.env.$envParam.runs.live.ts | 117 ++++++++ ...oadProjectEnvironmentFromRequest.server.ts | 25 ++ .../clickhouseRunsRepository.server.ts | 18 ++ .../runsRepository/runsRepository.server.ts | 16 + .../presenters/mapRunToLiveFields.test.ts | 79 +++++ apps/webapp/test/runsRepository.part2.test.ts | 103 +++++++ 13 files changed, 749 insertions(+), 7 deletions(-) create mode 100644 .server-changes/runs-list-live-reload.md create mode 100644 apps/webapp/app/components/primitives/PulsingDot.tsx create mode 100644 apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts create mode 100644 apps/webapp/app/services/loadProjectEnvironmentFromRequest.server.ts create mode 100644 apps/webapp/test/presenters/mapRunToLiveFields.test.ts diff --git a/.server-changes/runs-list-live-reload.md b/.server-changes/runs-list-live-reload.md new file mode 100644 index 00000000000..2dfd4869877 --- /dev/null +++ b/.server-changes/runs-list-live-reload.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Runs index live-reloads visible run status, shows a new-runs refresh banner, and child-status tooltips on root rows. diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 600ff9da325..0f9f4e9a4d7 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -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; export const Button = forwardRef( - ({ type, disabled, autoFocus, onClick, ...props }, ref) => { + ({ type, disabled, autoFocus, onClick, "aria-label": ariaLabel, ...props }, ref) => { const innerRef = useRef(null); useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement); @@ -352,6 +352,7 @@ export const Button = forwardRef( ref={innerRef} form={props.form} autoFocus={autoFocus} + aria-label={ariaLabel} > + + + + ); +} diff --git a/apps/webapp/app/hooks/useInterval.ts b/apps/webapp/app/hooks/useInterval.ts index 4d5413e977e..b59884d455a 100644 --- a/apps/webapp/app/hooks/useInterval.ts +++ b/apps/webapp/app/hooks/useInterval.ts @@ -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(() => { diff --git a/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts b/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts new file mode 100644 index 00000000000..841812e8703 --- /dev/null +++ b/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts @@ -0,0 +1,33 @@ +import { type TaskRunStatus } from "@trigger.dev/database"; +import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus"; + +type LiveRunRow = { + friendlyId: string; + status: TaskRunStatus; + updatedAt: Date; + startedAt: Date | null; + lockedAt: Date | null; + completedAt: Date | null; + usageDurationMs: bigint | number; + costInCents: number; + baseCostInCents: number; +}; + +export function mapRunToLiveFields(run: LiveRunRow) { + 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, + }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index cc41f738a29..75add81fbab 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -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 [ @@ -203,12 +205,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, 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 +291,18 @@ function RunsList({ rootOnlyDefault={rootOnlyDefault} />
+ {showNewRunsBanner && ( + + )} {!isShowingBulkActionInspector && ( >["runs"][number]; +type LivePollFetcherData = Awaited> | undefined; + +function hasNewRunsPollFields( + data: LivePollFetcherData +): data is NonNullable & { hasNew: boolean; since: number } { + return data !== undefined && "hasNew" in data && "since" in data; +} + +function maxCreatedAtMs(runs: ListedRun[]): number | undefined { + if (runs.length === 0) return undefined; + + return runs.reduce((maxTimestamp, run) => { + const runTimestamp = new Date(run.createdAt).getTime(); + return Math.max(maxTimestamp, runTimestamp); + }, 0); +} + +function getRunsSearchKeyWithoutPagination(search: string) { + const params = new URLSearchParams(search); + for (const key of RUNS_SEARCH_PARAMS_TO_REMOVE) { + params.delete(key); + } + return params.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 = new URLSearchParams(locationSearch); + for (const key of RUNS_SEARCH_PARAMS_TO_REMOVE) { + filterParams.delete(key); + } + for (const [key, value] of filterParams) { + searchParams.set(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, + visibleRunsCount, +}: { + runs: ListedRun[]; + hasAnyRuns: boolean; + isLoading: boolean; + visibleRunsCount: number; +}) { + const pollTickRef = useRef(0); + const [knownNewestRunMs, setKnownNewestRunMs] = useState(() => maxCreatedAtMs(runs) ?? Date.now()); + const [pendingNewRuns, setPendingNewRuns] = useState(false); + + const shouldPollForNewRuns = + hasAnyRuns && !isLoading && visibleRunsCount > 0 && !pendingNewRuns; + + // 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()); + setPendingNewRuns(false); + pollTickRef.current = 0; + }, [runs]); + + const markNewRunsPending = useCallback(() => { + setPendingNewRuns(true); + }, []); + + const dismissNewRuns = useCallback(() => { + setPendingNewRuns(false); + setKnownNewestRunMs(Date.now()); + pollTickRef.current = 0; + }, []); + + const checkNewRunsOnTick = useCallback(() => { + pollTickRef.current += 1; + return shouldPollForNewRuns && isNewRunsCheckTick(pollTickRef.current); + }, [shouldPollForNewRuns]); + + const showNewRunsBanner = pendingNewRuns && visibleRunsCount > 0; + + return { + knownNewestRunMs, + pendingNewRuns, + shouldPollForNewRuns, + showNewRunsBanner, + dismissNewRuns, + checkNewRunsOnTick, + markNewRunsPending, + 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(); + const runsPollFetcherStateRef = useRef(runsPollFetcher.state); + runsPollFetcherStateRef.current = runsPollFetcher.state; + + const [visibleRuns, setVisibleRuns] = useState(runs); + + const searchKeyWithoutPagination = useMemo( + () => getRunsSearchKeyWithoutPagination(location.search), + [location.search] + ); + + const { + knownNewestRunMs, + pendingNewRuns, + shouldPollForNewRuns, + showNewRunsBanner, + dismissNewRuns, + checkNewRunsOnTick, + markNewRunsPending, + resetNewRunsTracking, + } = useNewRunsDetection({ + runs, + hasAnyRuns, + isLoading, + visibleRunsCount: visibleRuns.length, + }); + + // 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]); + + // Flag new runs from the same response. Re-evaluates when the cutoff or banner + // state changes, even if the response object itself is unchanged. + useEffect(() => { + const data = runsPollFetcher.data; + if (!hasNewRunsPollFields(data)) return; + + if ( + data.since === knownNewestRunMs && + data.hasNew === true && + !pendingNewRuns && + visibleRuns.length > 0 + ) { + markNewRunsPending(); + } + }, [runsPollFetcher.data, knownNewestRunMs, pendingNewRuns, visibleRuns.length, markNewRunsPending]); + + 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, + dismissNewRuns, + childrenStatusesBasePath: runsResourcesBasePath, + }; +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts new file mode 100644 index 00000000000..214132a7bfd --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts @@ -0,0 +1,117 @@ +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"; + +const SearchParamsSchema = z.object({ + runIds: z + .string() + .optional() + .transform((value) => { + const ids = + value + ?.split(",") + .map((id) => id.trim()) + .filter(Boolean) ?? []; + return [...new Set(ids)].slice(0, 100); + }), + 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 [runs, hasNewResult] = await Promise.all([ + runIds.length > 0 + ? $replica.taskRun + .findMany({ + select: { + friendlyId: true, + status: true, + updatedAt: true, + startedAt: true, + lockedAt: true, + completedAt: true, + usageDurationMs: true, + costInCents: true, + baseCostInCents: true, + }, + where: { + projectId: project.id, + runtimeEnvironmentId: environment.id, + friendlyId: { + in: runIds, + }, + }, + }) + .then((rows) => rows.map(mapRunToLiveFields)) + : Promise.resolve([]), + newRunsSince !== undefined + ? (async () => { + const filters = await getRunFiltersFromRequest(request); + + if (filters.to !== undefined && filters.to <= newRunsSince) { + return { hasNew: false as const, since: newRunsSince }; + } + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + const runsRepository = new RunsRepository({ + clickhouse, + prisma: $replica, + }); + + const hasNew = await runsRepository.hasNewRuns({ + 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, + }); + + return { hasNew, since: newRunsSince }; + })() + : Promise.resolve(undefined), + ]); + + if (hasNewResult) { + return { runs, ...hasNewResult }; + } + + return { runs }; +} diff --git a/apps/webapp/app/services/loadProjectEnvironmentFromRequest.server.ts b/apps/webapp/app/services/loadProjectEnvironmentFromRequest.server.ts new file mode 100644 index 00000000000..76c39caba67 --- /dev/null +++ b/apps/webapp/app/services/loadProjectEnvironmentFromRequest.server.ts @@ -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 +) { + 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 }; +} diff --git a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts index 7a59179c55e..5e71d9505b1 100644 --- a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts @@ -190,6 +190,24 @@ export class ClickHouseRunsRepository implements IRunsRepository { return result[0].count; } + async hasNewRuns(options: RunListInputOptions) { + const queryBuilder = this.options.clickhouse.taskRuns.queryBuilder(); + applyRunFiltersToQueryBuilder( + queryBuilder, + await convertRunListInputOptionsToFilterRunsOptions(options, this.options.prisma) + ); + + queryBuilder.limit(1); + + const [queryError, result] = await queryBuilder.execute(); + + if (queryError) { + throw queryError; + } + + return result.length > 0; + } + async listTags(options: TagListOptions) { const queryBuilder = this.options.clickhouse.taskRuns .tagQueryBuilder() diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 9a0a4a19746..c8b282c4910 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -140,6 +140,7 @@ export interface IRunsRepository { }; }>; countRuns(options: RunListInputOptions): Promise; + hasNewRuns(options: RunListInputOptions): Promise; listTags(options: TagListOptions): Promise; } @@ -220,6 +221,21 @@ export class RunsRepository implements IRunsRepository { ); } + async hasNewRuns(options: RunListInputOptions): Promise { + return startActiveSpan( + "runsRepository.hasNewRuns", + async () => this.clickHouseRunsRepository.hasNewRuns(options), + { + attributes: { + "repository.name": "clickhouse", + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + async listTags(options: TagListOptions): Promise { return startActiveSpan( "runsRepository.listTags", diff --git a/apps/webapp/test/presenters/mapRunToLiveFields.test.ts b/apps/webapp/test/presenters/mapRunToLiveFields.test.ts new file mode 100644 index 00000000000..954b502c18e --- /dev/null +++ b/apps/webapp/test/presenters/mapRunToLiveFields.test.ts @@ -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); + }); +}); diff --git a/apps/webapp/test/runsRepository.part2.test.ts b/apps/webapp/test/runsRepository.part2.test.ts index 5fbe80ce52e..49133cb75f2 100644 --- a/apps/webapp/test/runsRepository.part2.test.ts +++ b/apps/webapp/test/runsRepository.part2.test.ts @@ -789,4 +789,107 @@ describe("RunsRepository (part 2/2)", () => { expect(secondPage.pagination.previousCursor).toBeTruthy(); } ); + + containerTest( + "should detect new runs with hasNewRuns", + 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(); + + await expect( + runsRepository.hasNewRuns({ + ...baseOptions, + from: createdAtMs - 1, + }) + ).resolves.toBe(true); + + await expect( + runsRepository.hasNewRuns({ + ...baseOptions, + from: createdAtMs + 60_000, + }) + ).resolves.toBe(false); + + const fromBeforeRun = createdAtMs - 1; + + await expect( + runsRepository.hasNewRuns({ + ...baseOptions, + from: fromBeforeRun, + tasks: ["my-task"], + }) + ).resolves.toBe(true); + + await expect( + runsRepository.hasNewRuns({ + ...baseOptions, + from: fromBeforeRun, + tasks: ["other-task"], + }) + ).resolves.toBe(false); + } + ); }); \ No newline at end of file From d454f49562644621f27295b3e3599e91a5964b3c Mon Sep 17 00:00:00 2001 From: Ekaterina Bulatova Date: Sat, 30 May 2026 00:35:09 +0200 Subject: [PATCH 2/2] feat(webapp): live child-status breakdown in root run tooltips Add runs/children-statuses resource route with PG groupBy per root. Show breakdown on root rows after a 400ms hover delay; fetch when the tooltip opens; poll every 3s while open until children settle. --- .../app/components/primitives/Tooltip.tsx | 6 +- .../runs/v3/RunStatusCellTooltip.tsx | 247 ++++++++++++++++++ .../app/components/runs/v3/TaskRunsTable.tsx | 18 +- ...am.env.$envParam.runs.children-statuses.ts | 109 ++++++++ 4 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 apps/webapp/app/components/runs/v3/RunStatusCellTooltip.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses.ts diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index c03492abcfd..92f95babce0 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -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 ( - + 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 ( +
+ + {orderedChildStatuses.map((entry) => ( + + + + {entry.count} + + + ))} + +
+ ); +} + +function useChildRunStatusesTooltip({ + friendlyId, + hasFinished, + childrenStatusesBasePath, +}: { + friendlyId: string; + hasFinished: boolean; + childrenStatusesBasePath: string; +}) { + const fetcher = useFetcher({ + key: `child-statuses-${friendlyId}`, + }); + const fetcherStateRef = useRef(fetcher.state); + fetcherStateRef.current = fetcher.state; + + const [childStatuses, setChildStatuses] = useState(); + const isOpenRef = useRef(false); + const pollIntervalRef = useRef>(); + 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 filterableTaskRunStatuses + .map((s) => ({ + status: s, + count: childStatusesMap.get(s) ?? 0, + })) + .filter((entry) => entry.count > 0); + }, [childStatuses]); + + const hasChildStatuses = orderedChildStatuses.length > 0; + + return ( + Loading child runs… + ) : hasChildStatuses ? ( + + ) : ( + descriptionForTaskRunStatus(status) + ) + } + disableHoverableContent + button={ + + + + } + /> + ); +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 5e645dab877..d222ca0ff56 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -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; + 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,16 @@ export function TaskRunsTable({ {run.version ?? "–"} - } - /> + {run.rootTaskRunId === null && childrenStatusesBasePath ? ( + + ) : ( + + )} {run.startedAt ? : "–"} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses.ts new file mode 100644 index 00000000000..e0d3a090486 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses.ts @@ -0,0 +1,109 @@ +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 { loadProjectEnvironmentFromRequest } from "~/services/loadProjectEnvironmentFromRequest.server"; + +const SearchParamsSchema = z.object({ + runIds: z + .string() + .optional() + .transform((value) => { + const ids = + value + ?.split(",") + .map((id) => id.trim()) + .filter(Boolean) ?? []; + return [...new Set(ids)].slice(0, 100); + }), +}); + +type RootRun = { id: string; friendlyId: string }; +type ChildStatusEntry = { status: TaskRunStatus; count: number }; +type GroupedChildStatus = { + rootTaskRunId: string | null; + status: TaskRunStatus; + _count: { _all: number }; +}; + +function mapGroupedStatusesToFriendlyIds( + grouped: GroupedChildStatus[], + roots: RootRun[] +): Map { + const rootFriendlyIdById = new Map(roots.map((run) => [run.id, run.friendlyId])); + const statusesByFriendlyId = new Map(); + + for (const item of grouped) { + if (!item.rootTaskRunId) continue; + const friendlyId = rootFriendlyIdById.get(item.rootTaskRunId); + if (!friendlyId) continue; + + const existing = statusesByFriendlyId.get(friendlyId) ?? []; + existing.push({ + status: item.status, + count: item._count._all, + }); + statusesByFriendlyId.set(friendlyId, existing); + } + + return statusesByFriendlyId; +} + +function childrenStatusesResponseForRunIds( + runIds: string[], + statusesByFriendlyId: Map +) { + 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 roots = await $replica.taskRun.findMany({ + select: { + id: true, + friendlyId: true, + }, + where: { + projectId: project.id, + runtimeEnvironmentId: environment.id, + friendlyId: { + in: runIds, + }, + }, + }); + + if (roots.length === 0) { + return { runs: [] }; + } + + const grouped = await $replica.taskRun.groupBy({ + by: ["rootTaskRunId", "status"], + where: { + projectId: project.id, + runtimeEnvironmentId: environment.id, + rootTaskRunId: { + in: roots.map((run) => run.id), + }, + }, + _count: { + _all: true, + }, + }); + + const statusesByFriendlyId = mapGroupedStatusesToFriendlyIds(grouped, roots); + + return childrenStatusesResponseForRunIds(runIds, statusesByFriendlyId); +}