diff --git a/.server-changes/runs-live-and-row-polling.md b/.server-changes/runs-live-and-row-polling.md new file mode 100644 index 00000000000..86e4489ccfa --- /dev/null +++ b/.server-changes/runs-live-and-row-polling.md @@ -0,0 +1,8 @@ +--- +area: webapp +type: feature +--- + +Add a "Live" toggle on the Runs index page that auto-revalidates page 1 every 3s, prepending new runs as they arrive. Visible only when on the first page. + +Add per-row polling for runs in non-terminal statuses on every page (always on, regardless of the Live toggle). Visible non-terminal rows are refreshed in place via a new resource route `…/runs/refresh?ids=…`, which returns the current row data from Postgres (skipping ClickHouse to avoid replication lag). Polling pauses when the tab is hidden. diff --git a/.server-changes/runs-table-new-runs-banner.md b/.server-changes/runs-table-new-runs-banner.md new file mode 100644 index 00000000000..6171f87e4c9 --- /dev/null +++ b/.server-changes/runs-table-new-runs-banner.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add a "X new runs" banner to the runs table. Shows on the first page when Live mode is off, polling every 3s for runs newer than the top visible row. Clicking revalidates the loader to bring in the latest runs. Acts as a halfway house between manual refresh and Live mode auto-refresh — the two are mutually exclusive in the UI. diff --git a/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx b/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx new file mode 100644 index 00000000000..ced48a267d9 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx @@ -0,0 +1,74 @@ +import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { useRevalidator } from "@remix-run/react"; +import { Button } from "~/components/primitives/Buttons"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { useNewRunsCount } from "~/hooks/useNewRunsCount"; + +const LIVE_INTERVAL_MS = 3000; + +export function RunsLiveControl({ + isLive, + onChange, + topRowId, + countNewUrl, +}: { + isLive: boolean; + onChange: (next: boolean) => void; + topRowId: string | undefined; + countNewUrl: string; +}) { + const revalidator = useRevalidator(); + + // When live, the loader auto-revalidates and the count banner is hidden. + // When not live, we poll for new runs and show the banner if any exist. + useAutoRevalidate({ interval: LIVE_INTERVAL_MS, disabled: !isLive }); + + const { count, hasMore } = useNewRunsCount({ + sinceId: topRowId, + countNewUrl, + intervalMs: LIVE_INTERVAL_MS, + disabled: isLive, + }); + + const showCountBanner = !isLive && count > 0; + const label = hasMore ? `${count}+` : String(count); + const noun = count === 1 && !hasMore ? "run" : "runs"; + + return ( + <> + {showCountBanner && ( + + )} + + + ); +} + +function LiveDot({ isLive }: { isLive: boolean }) { + if (!isLive) { + return ; + } + + return ( + + + + + ); +} diff --git a/apps/webapp/app/hooks/useAutoRevalidate.ts b/apps/webapp/app/hooks/useAutoRevalidate.ts index 4205b03bcc0..38ba798bae0 100644 --- a/apps/webapp/app/hooks/useAutoRevalidate.ts +++ b/apps/webapp/app/hooks/useAutoRevalidate.ts @@ -5,10 +5,16 @@ type UseAutoRevalidateOptions = { interval?: number; // in milliseconds onFocus?: boolean; disabled?: boolean; + pauseWhenHidden?: boolean; }; export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { - const { interval = 5000, onFocus = true, disabled = false } = options; + const { + interval = 5000, + onFocus = true, + disabled = false, + pauseWhenHidden = true, + } = options; const revalidator = useRevalidator(); useEffect(() => { @@ -18,11 +24,14 @@ export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { if (revalidator.state === "loading") { return; } + if (pauseWhenHidden && document.visibilityState !== "visible") { + return; + } revalidator.revalidate(); }, interval); return () => clearInterval(intervalId); - }, [interval, disabled]); + }, [interval, disabled, pauseWhenHidden]); useEffect(() => { if (!onFocus || disabled) return; diff --git a/apps/webapp/app/hooks/useNewRunsCount.ts b/apps/webapp/app/hooks/useNewRunsCount.ts new file mode 100644 index 00000000000..7f93fca5737 --- /dev/null +++ b/apps/webapp/app/hooks/useNewRunsCount.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from "react"; + +type CountNewResponse = { count: number; hasMore: boolean }; + +type UseNewRunsCountOptions = { + sinceId: string | undefined; + countNewUrl: string; + intervalMs?: number; + disabled?: boolean; +}; + +const DEFAULT_INTERVAL_MS = 3000; + +/** + * Polls the runs.count-new resource route to count runs newer than the + * top visible row. Uses a plain `fetch` rather than `useFetcher` so Remix's + * automatic fetcher revalidation (e.g. from useAutoRevalidate in Live mode) + * does not re-fire the request when the hook is disabled. + */ +export function useNewRunsCount({ + sinceId, + countNewUrl, + intervalMs = DEFAULT_INTERVAL_MS, + disabled = false, +}: UseNewRunsCountOptions): { count: number; hasMore: boolean } { + const [state, setState] = useState({ count: 0, hasMore: false }); + const inFlightRef = useRef(false); + + // Reset baseline whenever the cursor or url changes, or when disabling. + useEffect(() => { + setState({ count: 0, hasMore: false }); + }, [sinceId, countNewUrl, disabled]); + + useEffect(() => { + if (disabled) return; + if (!sinceId) return; + if (typeof document === "undefined") return; + + const url = appendSinceParam(countNewUrl, sinceId); + let cancelled = false; + const controller = new AbortController(); + + const tick = async () => { + if (inFlightRef.current) return; + if (document.visibilityState !== "visible") return; + inFlightRef.current = true; + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (!res.ok) return; + const data = (await res.json()) as CountNewResponse; + if (!cancelled) { + setState({ count: data.count, hasMore: data.hasMore }); + } + } catch { + // Ignore aborts and transient network errors; next tick will retry. + } finally { + inFlightRef.current = false; + } + }; + + const handleVisibility = () => { + if (document.visibilityState === "visible") { + tick(); + } + }; + + const intervalId = setInterval(tick, intervalMs); + document.addEventListener("visibilitychange", handleVisibility); + + return () => { + cancelled = true; + controller.abort(); + clearInterval(intervalId); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [sinceId, countNewUrl, intervalMs, disabled]); + + if (disabled || !sinceId) { + return { count: 0, hasMore: false }; + } + + return state; +} + +function appendSinceParam(url: string, sinceId: string): string { + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}since=${encodeURIComponent(sinceId)}`; +} diff --git a/apps/webapp/app/hooks/useRunsRowPolling.ts b/apps/webapp/app/hooks/useRunsRowPolling.ts new file mode 100644 index 00000000000..aad099b3281 --- /dev/null +++ b/apps/webapp/app/hooks/useRunsRowPolling.ts @@ -0,0 +1,108 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { type NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server"; +import { type loader as runsRefreshLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.refresh"; +import { isFinalRunStatus } from "~/v3/taskStatus"; + +type UseRunsRowPollingOptions = { + runs: NextRunListItem[]; + refreshUrl: string; + intervalMs?: number; +}; + +const DEFAULT_INTERVAL_MS = 3000; + +/** + * Polls the runs.refresh resource route for the visible non-terminal rows + * and returns a map of overrides keyed by internal run id. Consumers should + * merge the overrides on top of the loader's runs array. + * + * The hook pauses when the tab is hidden and triggers an immediate fetch + * when it becomes visible again. + */ +export function useRunsRowPolling({ + runs, + refreshUrl, + intervalMs = DEFAULT_INTERVAL_MS, +}: UseRunsRowPollingOptions): Map { + const fetcher = useTypedFetcher(); + const [overrides, setOverrides] = useState>(() => new Map()); + + // Compute pollable IDs against the *merged* view, so that once an override + // flips a row to a terminal status, we stop polling it. + const nonTerminalIds = useMemo(() => { + const ids: string[] = []; + for (const run of runs) { + const merged = overrides.get(run.id) ?? run; + if (!isFinalRunStatus(merged.status)) { + ids.push(merged.id); + } + } + ids.sort(); + return ids; + }, [runs, overrides]); + + const idsKey = nonTerminalIds.join(","); + + // Drop overrides for IDs that are no longer visible to keep the map bounded. + useEffect(() => { + setOverrides((prev) => { + if (prev.size === 0) return prev; + const visibleIds = new Set(runs.map((r) => r.id)); + let changed = false; + const next = new Map(); + for (const [id, run] of prev) { + if (visibleIds.has(id)) { + next.set(id, run); + } else { + changed = true; + } + } + return changed ? next : prev; + }); + }, [runs]); + + // Apply incoming fetcher data to the overrides map. + const lastAppliedDataRef = useRef(null); + useEffect(() => { + if (!fetcher.data) return; + if (fetcher.data === lastAppliedDataRef.current) return; + lastAppliedDataRef.current = fetcher.data; + const incoming = fetcher.data.runs; + setOverrides((prev) => { + const next = new Map(prev); + for (const run of incoming) { + next.set(run.id, run); + } + return next; + }); + }, [fetcher.data]); + + // Schedule polling. + useEffect(() => { + if (!idsKey) return; + if (typeof document === "undefined") return; + + const tick = () => { + if (fetcher.state !== "idle") return; + if (document.visibilityState !== "visible") return; + fetcher.load(`${refreshUrl}?ids=${idsKey}`); + }; + + const handleVisibility = () => { + if (document.visibilityState === "visible") { + tick(); + } + }; + + const intervalId = setInterval(tick, intervalMs); + document.addEventListener("visibilitychange", handleVisibility); + + return () => { + clearInterval(intervalId); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [idsKey, refreshUrl, intervalMs]); + + return overrides; +} diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index de111abd279..5913b29afb3 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -9,11 +9,13 @@ import { type Direction } from "~/components/ListPagination"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; import { getTaskIdentifiers } from "~/models/task.server"; -import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { type ListedRun, RunsRepository } from "~/services/runsRepository/runsRepository.server"; import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus"; +type DisplayableEnvironmentForRow = NonNullable>>; + export type RunListOptions = { userId?: string; projectId: string; @@ -208,50 +210,7 @@ export class NextRunListPresenter { } return { - runs: runs.map((run) => { - const hasFinished = isFinalRunStatus(run.status); - - const startedAt = run.startedAt ?? run.lockedAt; - - return { - id: run.id, - number: 1, - friendlyId: run.friendlyId, - createdAt: run.createdAt.toISOString(), - updatedAt: run.updatedAt.toISOString(), - startedAt: startedAt ? startedAt.toISOString() : undefined, - delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined, - hasFinished, - finishedAt: hasFinished - ? run.completedAt?.toISOString() ?? run.updatedAt.toISOString() - : undefined, - isTest: run.isTest, - status: run.status, - version: run.taskVersion, - taskIdentifier: run.taskIdentifier, - spanId: run.spanId, - isReplayable: true, - isCancellable: isCancellableRunStatus(run.status), - isPending: isPendingRunStatus(run.status), - environment: displayableEnvironment, - idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined, - ttl: run.ttl ? run.ttl : undefined, - expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined, - costInCents: run.costInCents, - baseCostInCents: run.baseCostInCents, - usageDurationMs: Number(run.usageDurationMs), - tags: run.runTags ? run.runTags.sort((a, b) => a.localeCompare(b)) : [], - depth: run.depth, - rootTaskRunId: run.rootTaskRunId, - metadata: run.metadata, - metadataType: run.metadataType, - machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined, - queue: { - name: run.queue.replace("task/", ""), - type: run.queue.startsWith("task/") ? "task" : "custom", - }, - }; - }), + runs: runs.map((run) => NextRunListPresenter.toRunListItem(run, displayableEnvironment)), pagination: { next: pagination.nextCursor ?? undefined, previous: pagination.previousCursor ?? undefined, @@ -274,4 +233,117 @@ export class NextRunListPresenter { hasAnyRuns, }; } + + /** + * Returns the latest data for the given internal run UUIDs, in the same shape + * as `call()`'s `runs[]`. Used by the runs.refresh resource route to refresh + * non-terminal rows in place. Hits Postgres directly (not ClickHouse) so we + * don't read replication-lagged values when polling. + */ + public async callByIds( + organizationId: string, + environmentId: string, + { + userId, + runIds, + }: { + userId?: string; + runIds: string[]; + } + ) { + if (runIds.length === 0) { + return { runs: [] }; + } + + const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId); + if (!displayableEnvironment) { + throw new ServiceValidationError("No environment found"); + } + + const rows = await this.replica.taskRun.findMany({ + where: { + id: { in: runIds }, + runtimeEnvironmentId: environmentId, + organizationId, + }, + select: { + id: true, + friendlyId: true, + taskIdentifier: true, + taskVersion: true, + runtimeEnvironmentId: true, + status: true, + createdAt: true, + startedAt: true, + lockedAt: true, + delayUntil: true, + updatedAt: true, + completedAt: true, + isTest: true, + spanId: true, + idempotencyKey: true, + ttl: true, + expiredAt: true, + costInCents: true, + baseCostInCents: true, + usageDurationMs: true, + runTags: true, + depth: true, + rootTaskRunId: true, + batchId: true, + metadata: true, + metadataType: true, + machinePreset: true, + queue: true, + }, + }); + + return { + runs: rows.map((run) => NextRunListPresenter.toRunListItem(run, displayableEnvironment)), + }; + } + + private static toRunListItem(run: ListedRun, displayableEnvironment: DisplayableEnvironmentForRow) { + const hasFinished = isFinalRunStatus(run.status); + const startedAt = run.startedAt ?? run.lockedAt; + + return { + id: run.id, + number: 1, + friendlyId: run.friendlyId, + createdAt: run.createdAt.toISOString(), + updatedAt: run.updatedAt.toISOString(), + startedAt: startedAt ? startedAt.toISOString() : undefined, + delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined, + hasFinished, + finishedAt: hasFinished + ? run.completedAt?.toISOString() ?? run.updatedAt.toISOString() + : undefined, + isTest: run.isTest, + status: run.status, + version: run.taskVersion, + taskIdentifier: run.taskIdentifier, + spanId: run.spanId, + isReplayable: true, + isCancellable: isCancellableRunStatus(run.status), + isPending: isPendingRunStatus(run.status), + environment: displayableEnvironment, + idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined, + ttl: run.ttl ? run.ttl : undefined, + expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined, + costInCents: run.costInCents, + baseCostInCents: run.baseCostInCents, + usageDurationMs: Number(run.usageDurationMs), + tags: run.runTags ? run.runTags.sort((a, b) => a.localeCompare(b)) : [], + depth: run.depth, + rootTaskRunId: run.rootTaskRunId, + metadata: run.metadata, + metadataType: run.metadataType, + machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined, + queue: { + name: run.queue.replace("task/", ""), + type: run.queue.startsWith("task/") ? "task" : "custom", + }, + }; + } } 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 ca7e8b7b0c1..042d1534fe6 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,7 +1,7 @@ import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction, useNavigation } from "@remix-run/react"; +import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { Suspense } from "react"; +import { Suspense, useMemo } from "react"; import { TypedAwait, typeddefer, @@ -32,12 +32,14 @@ import { Spinner } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { RunsLiveControl } from "~/components/runs/v3/RunsLiveControl"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { useRunsRowPolling } from "~/hooks/useRunsRowPolling"; import { useSearchParams } from "~/hooks/useSearchParam"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { findProjectBySlug } from "~/models/project.server"; @@ -56,6 +58,8 @@ import { EnvironmentParamSchema, v3CreateBulkActionPath, v3ProjectPath, + v3RunsCountNewPath, + v3RunsRefreshPath, v3TestPath, v3TestTaskPath, } from "~/utils/pathBuilder"; @@ -199,7 +203,9 @@ function RunsList({ const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { has, replace } = useSearchParams(); + const { has, replace, value } = useSearchParams(); + const location = useLocation(); + const isFirstPage = !list.pagination.previous; // Shortcut keys for bulk actions useShortcutKeys({ @@ -223,6 +229,25 @@ function RunsList({ }, }); + const isLiveAvailable = isFirstPage; + const isLive = isLiveAvailable && value("live") === "1"; + const setLive = (next: boolean) => replace({ live: next ? "1" : undefined }); + + const refreshUrl = v3RunsRefreshPath(organization, project, environment); + const overrides = useRunsRowPolling({ + runs: list.runs, + refreshUrl, + }); + + const mergedRuns = useMemo( + () => list.runs.map((r) => overrides.get(r.id) ?? r), + [list.runs, overrides] + ); + + const countNewUrl = `${v3RunsCountNewPath(organization, project, environment)}${ + location.search + }`; + const isShowingBulkActionInspector = has("bulkInspector") && list.hasAnyRuns; return ( @@ -256,6 +281,14 @@ function RunsList({ rootOnlyDefault={rootOnlyDefault} />
+ {isLiveAvailable && ( + + )} {!isShowingBulkActionInspector && ( { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + const url = new URL(request.url); + const sinceParam = url.searchParams.get("since"); + const since = sinceParam && RunIdSchema.safeParse(sinceParam).success ? sinceParam : null; + + if (!since) { + return typedjson( + { count: 0, hasMore: false }, + { headers: { "Cache-Control": "no-store" } } + ); + } + + const filters = await getRunFiltersFromRequest(request); + + const runsRepository = new RunsRepository({ + clickhouse: clickhouseClient, + prisma: $replica as PrismaClient, + }); + + const ids = await runsRepository.listRunIds({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + tasks: filters.tasks, + versions: filters.versions, + statuses: filters.statuses, + tags: filters.tags, + period: filters.period, + from: filters.from, + to: filters.to, + batchId: filters.batchId, + runId: filters.runId, + bulkId: filters.bulkId, + scheduleId: filters.scheduleId, + rootOnly: filters.rootOnly, + queues: filters.queues, + machines: filters.machines, + errorId: filters.errorId, + page: { cursor: since, direction: "backward", size: COUNT_CAP }, + }); + + return typedjson( + { + count: Math.min(ids.length, COUNT_CAP), + hasMore: ids.length > COUNT_CAP, + }, + { headers: { "Cache-Control": "no-store" } } + ); +}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.refresh.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.refresh.ts new file mode 100644 index 00000000000..3b5c77a9fe2 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.refresh.ts @@ -0,0 +1,52 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson } from "remix-typedjson"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; + +const MAX_IDS = 100; +const RunIdSchema = z.string().cuid(); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + const url = new URL(request.url); + const idsParam = url.searchParams.get("ids") ?? ""; + const candidateIds = idsParam + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0) + .slice(0, MAX_IDS); + const runIds = candidateIds.filter((id) => RunIdSchema.safeParse(id).success); + + const presenter = new NextRunListPresenter($replica, clickhouseClient); + const { runs } = await presenter.callByIds(project.organizationId, environment.id, { + userId, + runIds, + }); + + return typedjson( + { runs }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); +}; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 7a151053f5a..57e2a3ab5cb 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -325,6 +325,26 @@ export function v3RunsPath( return `${v3EnvironmentPath(organization, project, environment)}/runs${query}`; } +export function v3RunsRefreshPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam( + project + )}/env/${environmentParam(environment)}/runs/refresh`; +} + +export function v3RunsCountNewPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam( + project + )}/env/${environmentParam(environment)}/runs/count-new`; +} + export function v3CreateBulkActionPath( organization: OrgForPath, project: ProjectForPath,