diff --git a/.server-changes/task-page-runs-live-updating.md b/.server-changes/task-page-runs-live-updating.md new file mode 100644 index 00000000000..a082fdfb3d5 --- /dev/null +++ b/.server-changes/task-page-runs-live-updating.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The runs list on a task's page now updates live — run statuses change and newly triggered runs appear without a manual refresh, matching the main Runs page. diff --git a/apps/webapp/app/components/runs/v3/TaskRunsList.tsx b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx new file mode 100644 index 00000000000..1b9830ec54f --- /dev/null +++ b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx @@ -0,0 +1,117 @@ +import { useLocation, useNavigation, useRevalidator } from "@remix-run/react"; +import { type MutableRefObject, useEffect } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { PulsingDot } from "~/components/primitives/PulsingDot"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import type { NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; +import { useRunsLiveReload } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload"; +import { TaskRunsTable } from "./TaskRunsTable"; + +/** + * Compact "N new runs" button, shown in a task page's header to the left of the + * time filter when the live-reload hook has detected newer runs. + */ +export function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) { + return ( + + + + ); +} + +/** + * Runs table with live updating, shared by the standard and scheduled task + * landing pages. Mirrors the Runs list page: active rows are patched in place + * (status/timing/cost). The "N new runs" count is surfaced to the top-bar + * button via `onNewRunsCountChange` (count drives visibility) and + * `showNewRunsRef` (the latest click action), since the button lives outside + * this deferred boundary. The task lives in the route path rather than a + * `tasks` filter, so we pass `taskSlug` to scope new-run detection to this task. + */ +export function TaskRunsList({ + list, + taskSlug, + onNewRunsCountChange, + showNewRunsRef, +}: { + list: NextRunList; + taskSlug: string; + onNewRunsCountChange: (count: number) => void; + showNewRunsRef: MutableRefObject<() => void>; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const navigation = useNavigation(); + const location = useLocation(); + const { has, replace } = useSearchParams(); + const revalidator = useRevalidator(); + + // Loading a new version of this same page (time filter / pagination change). + const isLoading = + navigation.state === "loading" && + navigation.location !== undefined && + navigation.location.pathname === location.pathname && + navigation.location.search !== location.search; + + const { visibleRuns, newRunsCount, dismissNewRuns, childrenStatusesBasePath } = useRunsLiveReload( + { + runs: list.runs, + hasAnyRuns: list.hasAnyRuns, + isLoading, + organizationSlug: organization.slug, + projectSlug: project.slug, + environmentSlug: environment.slug, + taskSlug, + } + ); + + const onClickShowNewRuns = () => { + const isPaginated = has("cursor") || has("direction"); + dismissNewRuns(); + if (isPaginated) { + replace({ cursor: undefined, direction: undefined }); + return; + } + revalidator.revalidate(); + }; + + // Surface the banner to the top-bar button rendered by the page: keep the + // ref's action current, mirror the count up, and clear it when this boundary + // unmounts (e.g. the table re-suspends on a filter change). + useEffect(() => { + showNewRunsRef.current = onClickShowNewRuns; + }, [onClickShowNewRuns, showNewRunsRef]); + useEffect(() => { + onNewRunsCountChange(newRunsCount); + }, [newRunsCount, onNewRunsCountChange]); + useEffect(() => () => onNewRunsCountChange(0), [onNewRunsCountChange]); + + return ( +
+ +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts index a8d26ac350b..e443fb84cfb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts @@ -48,12 +48,22 @@ function isNewRunsCheckTick(tick: number) { function appendNewRunsSearchParams( searchParams: URLSearchParams, - { locationSearch, since }: { locationSearch: string; since: number } + { locationSearch, since, taskSlug }: { locationSearch: string; since: number; taskSlug?: string } ) { const filterParams = filterParamsWithoutPagination(locationSearch); for (const [key, value] of filterParams) { searchParams.append(key, value); } + // On the task landing pages the task lives in the route path, not the query + // string, so scope the new-runs count to this task explicitly. The task pages + // list every run of the task (their loaders apply no rootOnly filter), so + // force rootOnly off for the count too: a rootOnly preference persisted from + // the main Runs page would otherwise make the count skip child runs the list + // is showing, so the "N new runs" button could under-count or never appear. + if (taskSlug) { + searchParams.append("tasks", taskSlug); + searchParams.set("rootOnly", "false"); + } searchParams.set("includeNewRuns", "true"); searchParams.set("since", String(since)); } @@ -138,6 +148,7 @@ export function useRunsLiveReload({ organizationSlug, projectSlug, environmentSlug, + taskSlug, }: { runs: ListedRun[]; hasAnyRuns: boolean; @@ -145,6 +156,11 @@ export function useRunsLiveReload({ organizationSlug: string; projectSlug: string; environmentSlug: string; + /** + * When set, scopes new-run detection to this task. Used by the task landing + * pages, where the task is a route path param rather than a `tasks` filter. + */ + taskSlug?: string; }) { const location = useLocation(); const runsPollFetcher = useTypedFetcher(); @@ -230,6 +246,7 @@ export function useRunsLiveReload({ appendNewRunsSearchParams(searchParams, { locationSearch: location.search, since: knownNewestRunMs, + taskSlug, }); } @@ -242,6 +259,7 @@ export function useRunsLiveReload({ knownNewestRunMs, runsPollFetcher, runsResourcesBasePath, + taskSlug, ] ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 9ef03b5c189..398c6135531 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -56,7 +56,6 @@ import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { ScheduleTypeIcon, scheduleTypeName } from "~/components/runs/v3/ScheduleType"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; -import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { ScheduleInspector } from "~/components/schedules/ScheduleInspector"; import { ScheduleLimitActions } from "~/components/schedules/ScheduleLimitActions"; import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar"; @@ -95,6 +94,7 @@ import type { loader as scheduleEditLoader } from "../_app.orgs.$organizationSlu import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; +import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; @@ -181,6 +181,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + includeHasAnyRuns: true, }) .catch(() => null); @@ -231,6 +232,13 @@ export default function Page() { !!plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed; const isAtLimit = !!limits && limits.used >= limits.limit; + // New-runs banner state is lifted here so the button can live in the top bar, + // while the count/action originate from the live-reload hook inside the + // deferred runs table below. Count drives visibility; the ref exposes the + // click action (kept current by TaskRunsList each render). + const [newRunsCount, setNewRunsCount] = useState(0); + const showNewRunsRef = useRef<() => void>(() => {}); + return ( @@ -265,6 +273,9 @@ export default function Page() { onCreate={openCreateSchedule} disabled={isCreatingSchedule} /> + {newRunsCount > 0 ? ( + showNewRunsRef.current()} /> + ) : null} }> {(list) => list ? ( -
- -
+ ) : ( ) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index f38124f2596..ac765406ce1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -1,7 +1,7 @@ import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; -import { Suspense, useMemo } from "react"; +import { Suspense, useMemo, useRef, useState } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; @@ -29,7 +29,6 @@ import { } from "~/components/primitives/Resizable"; import { Spinner } from "~/components/primitives/Spinner"; import { TextLink } from "~/components/primitives/TextLink"; -import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; @@ -52,6 +51,7 @@ import { v3TestTaskPath, } from "~/utils/pathBuilder"; import { parseFiniteInt } from "~/utils/searchParams"; +import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; @@ -121,6 +121,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + includeHasAnyRuns: true, }) .catch(() => null); @@ -144,6 +145,13 @@ export default function Page() { }); const queuesPath = v3QueuesPath(organization, project, environment); + // New-runs banner state is lifted here so the button can live in the top bar, + // while the count/action originate from the live-reload hook inside the + // deferred runs table below. Count drives visibility; the ref exposes the + // click action (kept current by TaskRunsList each render). + const [newRunsCount, setNewRunsCount] = useState(0); + const showNewRunsRef = useRef<() => void>(() => {}); + return ( @@ -166,6 +174,9 @@ export default function Page() {
Runs
+ {newRunsCount > 0 ? ( + showNewRunsRef.current()} /> + ) : null} @@ -200,17 +211,12 @@ export default function Page() { }> {(list) => list ? ( -
- -
+ ) : ( )