Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/api-client/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1537,6 +1537,11 @@ export interface components {
created_at: string;
updated_at: string;
started_at: string | null;
estimated_time?: {
low: number;
median: number;
high: number;
} | null;
} & {
[key: string]: unknown;
};
Expand Down Expand Up @@ -1566,6 +1571,11 @@ export interface components {
created_at: string;
updated_at: string;
started_at: string | null;
estimated_time?: {
low: number;
median: number;
high: number;
} | null;
}[];
nextCursor: string | null;
};
Expand Down
18 changes: 16 additions & 2 deletions packages/api/src/postgres/run-query-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,22 @@ export class RunQueryRepository {
}
values.push(limit + 1);
const order = sort ? `CASE WHEN ${sortExpressions[sort]} IS NULL THEN 1 ELSE 0 END ASC, ${sortExpressions[sort]} ${direction} NULLS LAST, r.id ${direction}` : "r.created_at DESC,r.id DESC";
const result = await this.database.pool.query<any>(`SELECT r.id,r.job_id,j.project_id,j.name job_name,p.name project_name,r.issue_id,r.issue_url,r.issue_title,r.run_name,r.agent_name,r.workspace_name,r.agent_kind,r.state,r.provider,r.execution_backend_kind backend_kind,r.execution_capabilities capabilities,r.destination_url,r.artifact_state,r.artifact_error,r.created_at,r.updated_at,jr.started_at
FROM app.runs r JOIN app.jobs j ON j.tenant_id=r.tenant_id AND j.id=r.job_id JOIN app.projects p ON p.tenant_id=j.tenant_id AND p.id=j.project_id JOIN app.job_runs jr ON jr.tenant_id=r.tenant_id AND jr.id=r.id JOIN app.invocations i ON i.tenant_id=r.tenant_id AND i.id=r.invocation_id WHERE ${clauses.join(" AND ")} ORDER BY ${order} LIMIT $${values.length}`, values);
const result = await this.database.pool.query<any>(`SELECT r.id,r.job_id,j.project_id,j.name job_name,p.name project_name,r.issue_id,r.issue_url,r.issue_title,r.run_name,r.agent_name,r.workspace_name,r.agent_kind,r.state,r.provider,r.execution_backend_kind backend_kind,r.execution_capabilities capabilities,r.destination_url,r.artifact_state,r.artifact_error,r.created_at,r.updated_at,jr.started_at,e.estimated_time
FROM app.runs r JOIN app.jobs j ON j.tenant_id=r.tenant_id AND j.id=r.job_id JOIN app.projects p ON p.tenant_id=j.tenant_id AND p.id=j.project_id JOIN app.job_runs jr ON jr.tenant_id=r.tenant_id AND jr.id=r.id JOIN app.invocations i ON i.tenant_id=r.tenant_id AND i.id=r.invocation_id
LEFT JOIN LATERAL (
WITH durations AS (
SELECT EXTRACT(EPOCH FROM (completed.updated_at - completed.started_at)) duration
FROM app.job_runs completed
WHERE completed.tenant_id=r.tenant_id AND completed.job_id=r.job_id AND completed.state IN ('succeeded','failed','stopped')
AND completed.started_at IS NOT NULL AND completed.updated_at >= completed.started_at
), stats AS (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration) median FROM durations)
SELECT CASE WHEN stats.median IS NULL THEN NULL ELSE json_build_object(
'low', round(avg(durations.duration) FILTER (WHERE durations.duration <= stats.median)),
'median', round(stats.median),
'high', round(avg(durations.duration) FILTER (WHERE durations.duration >= stats.median))
) END estimated_time FROM durations CROSS JOIN stats GROUP BY stats.median
) e ON true
WHERE ${clauses.join(" AND ")} ORDER BY ${order} LIMIT $${values.length}`, values);
const rows = result.rows.slice(0, limit), hasMore = result.rows.length > limit;
const last = rows.at(-1);
const nextCursor = hasMore && last ? sort ? (() => {
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/run-read-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const revisionTracePage = z.object({ items: z.array(traceEventResponse),
export const traceRevisionSql = "jsonb_build_array(c.generation,p.source_kind,p.artifact_sha256,p.parser_version,p.updated_at)::text";
export const runSummaryResponse = z.object({
id: z.string(), job_id: z.string(), project_id: z.string().optional(), project_name: z.string().optional(), job_name: z.string(), issue_id: z.string(), issue_url: z.string().nullable(), issue_title: z.string(), run_name: z.string(), agent_name: z.string(), workspace_name: z.string(), agent_kind: z.string(), state: z.enum([...executionState.options, "done", "ignored"]), provider: z.string(), backend_kind: z.string(), capabilities: z.array(z.string()), destination_url: z.string().nullable(), artifact_state: z.enum(["pending", "collecting", "stored", "partial", "failed"]), artifact_error: z.string().nullable(), created_at: z.string(), updated_at: z.string(), started_at: z.string().nullable(),
estimated_time: z.object({ low: z.number(), median: z.number(), high: z.number() }).nullable().optional(),
});
export const runPageResponse = z.object({ items: z.array(runSummaryResponse), nextCursor: z.string().nullable() });
// Public JSON after serialization: enumerate known safe fields and keep opaque
Expand Down
9 changes: 5 additions & 4 deletions packages/app/src/features/runs/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Button, RunStatusDot, buttonClasses, SortableHeader, StatusHeader, type
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { historyQuery } from "./queries";
import { formatEstimatedTime } from "../../shared/estimated-time";
export type HistorySort = "run" | "status" | "created" | "agent";
export type HistorySearch = { cursor?: string; previous?: string; states?: RunStatus[]; sort?: HistorySort; direction?: "asc" | "desc" };
export function RunHistory({ jobId }: { jobId: string }) {
Expand All @@ -21,13 +22,13 @@ export function RunHistory({ jobId }: { jobId: string }) {
const clearStatuses = () => void navigate({ to: "/jobs/$jobId", params: { jobId }, search: { ...search, states: undefined, sort: undefined, direction: undefined, cursor: undefined, previous: undefined } });
return <section aria-label="Runs" className="my-6 min-w-0">
{runs.isPending && <p role="status">Loading run history…</p>}{runs.error && <p role="alert">{runs.error.message} <Button onClick={() => void runs.refetch()}>Retry</Button></p>}
{runs.data && <><Table className="min-w-[36rem] table-fixed" shellProps={{ role: "region", "aria-label": "Runs table", tabIndex: 0 }}><caption className="sr-only">Job runs{search.sort ? "" : ", newest first"}</caption><colgroup><col className="w-16 sm:w-20" /><col className="w-[38%] sm:w-[42%]" /><col className="w-[30%] sm:w-[26%]" /><col className="w-[20%] sm:w-[16%]" /></colgroup>
<thead className={tableHeadClasses}><tr><StatusHeader selected={search.states ?? []} sortDirection={search.sort === "status" ? search.direction : undefined} onFilterChange={changeFilter} onSortChange={changeStatusSort} onClear={clearStatuses} />{columns.slice(1).map(([column, label]) => <SortableHeader key={column} label={label} active={search.sort === column} direction={search.direction} ariaLabel={`${label}, ${sortLabel(column)}`} onSort={() => sortColumn(column)} />)}</tr></thead>
{runs.data && <><Table className="min-w-[40rem] table-fixed" shellProps={{ role: "region", "aria-label": "Runs table", tabIndex: 0 }}><caption className="sr-only">Job runs{search.sort ? "" : ", newest first"}</caption><colgroup><col className="w-16 sm:w-20" /><col className="w-[34%] sm:w-[38%]" /><col className="w-[22%] sm:w-[20%]" /><col className="w-[20%] sm:w-[18%]" /><col className="w-[20%] sm:w-[16%]" /></colgroup>
<thead className={tableHeadClasses}><tr><StatusHeader selected={search.states ?? []} sortDirection={search.sort === "status" ? search.direction : undefined} onFilterChange={changeFilter} onSortChange={changeStatusSort} onClear={clearStatuses} />{columns.slice(1, 2).map(([column, label]) => <SortableHeader key={column} label={label} active={search.sort === column} direction={search.direction} ariaLabel={`${label}, ${sortLabel(column)}`} onSort={() => sortColumn(column)} />)}<th scope="col" className={tableCellClasses}>Estimated</th>{columns.slice(2).map(([column, label]) => <SortableHeader key={column} label={label} active={search.sort === column} direction={search.direction} ariaLabel={`${label}, ${sortLabel(column)}`} onSort={() => sortColumn(column)} />)}</tr></thead>
<tbody>{runs.data.items.map(run => <tr className={tableRowClasses} key={run.id}>
<td className={`whitespace-nowrap ${tableCellClasses}`}><RunStatusDot state={run.state} /></td>
<td className={tableCellClasses}><Link to="/job-runs/$runId" params={{ runId: run.id }} search={{ after: 0 }} title={run.run_name || run.issue_title || "Run"} className="block truncate font-semibold hover:text-factorize-700 focus-visible:rounded-sm dark:hover:text-factorize-500">{run.run_name || run.issue_title || "Run"}</Link></td>
<td className={`whitespace-nowrap ${tableCellClasses}`}><time dateTime={run.created_at}>{new Date(run.created_at).toLocaleString()}</time></td><td className={`${tableCellClasses} truncate`} title={run.agent_kind}>{run.agent_kind}</td>
</tr>)}{runs.data.items.length === 0 && <tr><td colSpan={4} className="px-3 py-6 text-center">{search.states?.length ? <><p>No runs match the selected statuses.</p><Button className="mt-3" onClick={() => void navigate({ to: "/jobs/$jobId", params: { jobId }, search: { ...search, states: undefined, cursor: undefined, previous: undefined } })}>Clear status filters</Button></> : "No runs found."}</td></tr>}</tbody>
<td className={`whitespace-nowrap ${tableCellClasses}`} title={run.estimated_time ? `Low ${formatEstimatedTime(run.estimated_time.low)}, median ${formatEstimatedTime(run.estimated_time.median)}, high ${formatEstimatedTime(run.estimated_time.high)}` : undefined}>{run.estimated_time ? `${formatEstimatedTime(run.estimated_time.low)}–${formatEstimatedTime(run.estimated_time.median)}–${formatEstimatedTime(run.estimated_time.high)}` : "—"}</td><td className={`whitespace-nowrap ${tableCellClasses}`}><time dateTime={run.created_at}>{new Date(run.created_at).toLocaleString()}</time></td><td className={`${tableCellClasses} truncate`} title={run.agent_kind}>{run.agent_kind}</td>
</tr>)}{runs.data.items.length === 0 && <tr><td colSpan={5} className="px-3 py-6 text-center">{search.states?.length ? <><p>No runs match the selected statuses.</p><Button className="mt-3" onClick={() => void navigate({ to: "/jobs/$jobId", params: { jobId }, search: { ...search, states: undefined, cursor: undefined, previous: undefined } })}>Clear status filters</Button></> : "No runs found."}</td></tr>}</tbody>
</Table></>}
<nav aria-label="Run pages" className="mt-4 flex flex-wrap items-center gap-2">
{search.cursor && <Link className={pageClasses} to="/jobs/$jobId" params={{ jobId }} search={{ states: search.states, sort: search.sort, direction: search.direction }}>First page</Link>}
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/features/runs/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Button, RunStatusDot, buttonClasses, SortableHeader, StatusHeader, type
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { runPageQuery } from "./queries";
import { formatEstimatedTime } from "../../shared/estimated-time";

export type RunListSearch = { cursor?: string; previous?: string; states?: RunStatus[]; sort?: "job" | "run" | "status" | "created" | "agent"; direction?: "asc" | "desc" };
export function RunList({ jobId, limit = 30 }: { jobId?: string; limit?: number }) {
Expand All @@ -24,7 +25,7 @@ export function RunList({ jobId, limit = 30 }: { jobId?: string; limit?: number
</nav>;
return <section aria-label="Job runs" className="my-6 min-w-0">
{runs.isPending && <p role="status">Loading runs…</p>}{runs.error && <p role="alert">{runs.error.message} <Button onClick={() => void runs.refetch()}>Retry</Button></p>}
{runs.data && <><Table className="min-w-[52rem] table-fixed" shellProps={{ role: "region", "aria-label": "Job runs table", tabIndex: 0 }}><caption className="sr-only">Job runs{search.sort ? "" : ", newest first"}</caption><colgroup><col className="w-14 sm:w-16" /><col className="w-[22%] sm:w-[24%]" /><col className="w-[22%] sm:w-[24%]" /><col className="w-[26%] sm:w-[24%]" /><col className="w-[20%] sm:w-[16%]" /></colgroup><thead className={tableHeadClasses}><tr>{statusHeader}<th scope="col" className={tableCellClasses}>Project name</th>{sortableHeader("job", "Job name")}{sortableHeader("run", "Run")}{sortableHeader("created", "Created")}</tr></thead><tbody>{runs.data.items.map(run => <tr className={tableRowClasses} key={run.id}><td className={`whitespace-nowrap ${tableCellClasses}`}><RunStatusDot state={run.state} /></td><td className={tableCellClasses}>{run.project_id && run.project_name ? <Link to="/projects/$projectId" params={{ projectId: run.project_id }} title={run.project_name} className="block truncate font-semibold hover:text-factorize-700 dark:hover:text-factorize-500">{run.project_name}</Link> : <span className="text-slate-500">—</span>}</td><td className={tableCellClasses}><Link to="/jobs/$jobId" params={{ jobId: run.job_id }} title={run.job_name} className="block truncate font-semibold hover:text-factorize-700 dark:hover:text-factorize-500">{run.job_name}</Link></td><td className={tableCellClasses}><Link to="/job-runs/$runId" params={{ runId: run.id }} search={{ after: 0 }} title={run.run_name || "Run"} className="block truncate font-semibold hover:text-factorize-700 dark:hover:text-factorize-500">{run.run_name || "Run"}</Link></td><td className={`whitespace-nowrap ${tableCellClasses}`}><time dateTime={run.created_at}>{new Date(run.created_at).toLocaleString()}</time></td></tr>)}{runs.data.items.length === 0 && <tr><td colSpan={5} className={`${tableCellClasses} py-6 text-center`}>{search.states?.length ? <><p>No runs match the selected statuses.</p><Button className="mt-3" onClick={() => void navigate({ search: { ...search, states: undefined, cursor: undefined, previous: undefined } })}>Clear status filters</Button></> : jobId ? "No runs found." : "No runs yet"}</td></tr>}</tbody></Table></>}
{runs.data && <><Table className="min-w-[58rem] table-fixed" shellProps={{ role: "region", "aria-label": "Job runs table", tabIndex: 0 }}><caption className="sr-only">Job runs{search.sort ? "" : ", newest first"}</caption><colgroup><col className="w-14 sm:w-16" /><col className="w-[20%] sm:w-[22%]" /><col className="w-[20%] sm:w-[22%]" /><col className="w-[24%] sm:w-[22%]" /><col className="w-[16%] sm:w-[14%]" /><col className="w-[20%] sm:w-[16%]" /></colgroup><thead className={tableHeadClasses}><tr>{statusHeader}<th scope="col" className={tableCellClasses}>Project name</th>{sortableHeader("job", "Job name")}{sortableHeader("run", "Run")}<th scope="col" className={tableCellClasses}>Estimated</th>{sortableHeader("created", "Created")}</tr></thead><tbody>{runs.data.items.map(run => <tr className={tableRowClasses} key={run.id}><td className={`whitespace-nowrap ${tableCellClasses}`}><RunStatusDot state={run.state} /></td><td className={tableCellClasses}>{run.project_id && run.project_name ? <Link to="/projects/$projectId" params={{ projectId: run.project_id }} title={run.project_name} className="block truncate font-semibold hover:text-factorize-700 dark:hover:text-factorize-500">{run.project_name}</Link> : <span className="text-slate-500">—</span>}</td><td className={tableCellClasses}><Link to="/jobs/$jobId" params={{ jobId: run.job_id }} title={run.job_name} className="block truncate font-semibold hover:text-factorize-700 dark:hover:text-factorize-500">{run.job_name}</Link></td><td className={tableCellClasses}><Link to="/job-runs/$runId" params={{ runId: run.id }} search={{ after: 0 }} title={run.run_name || "Run"} className="block truncate font-semibold hover:text-factorize-700 dark:hover:text-factorize-500">{run.run_name || "Run"}</Link></td><td className={`whitespace-nowrap ${tableCellClasses}`} title={run.estimated_time ? `Low ${formatEstimatedTime(run.estimated_time.low)}, median ${formatEstimatedTime(run.estimated_time.median)}, high ${formatEstimatedTime(run.estimated_time.high)}` : undefined}>{run.estimated_time ? `${formatEstimatedTime(run.estimated_time.low)}–${formatEstimatedTime(run.estimated_time.median)}–${formatEstimatedTime(run.estimated_time.high)}` : "—"}</td><td className={`whitespace-nowrap ${tableCellClasses}`}><time dateTime={run.created_at}>{new Date(run.created_at).toLocaleString()}</time></td></tr>)}{runs.data.items.length === 0 && <tr><td colSpan={6} className={`${tableCellClasses} py-6 text-center`}>{search.states?.length ? <><p>No runs match the selected statuses.</p><Button className="mt-3" onClick={() => void navigate({ search: { ...search, states: undefined, cursor: undefined, previous: undefined } })}>Clear status filters</Button></> : jobId ? "No runs found." : "No runs yet"}</td></tr>}</tbody></Table></>}
{pagination}
</section>;
}
7 changes: 7 additions & 0 deletions packages/app/src/shared/estimated-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const formatEstimatedTime = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60), remainder = seconds % 60;
if (minutes < 60) return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60), remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
4 changes: 2 additions & 2 deletions packages/app/test/jobs.browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,9 @@ test("run table headers sort every column accessibly and persist in the URL", as
await page.goto(origin + `/jobs/${jobId}`);
const table = page.getByRole("table");
await table.getByRole("columnheader").nth(0).getByRole("button").waitFor();
assert.deepEqual(await table.locator("thead th").allTextContents(), ["Status", "Run", "Created", "Agent"]);
assert.deepEqual(await table.locator("thead th").allTextContents(), ["Status", "Run", "Estimated", "Created", "Agent"]);
assert.equal(await table.getByRole("columnheader", { name: /Agent/ }).count(), 1);
for (const index of [0, 1, 2, 3]) assert.equal(await table.getByRole("columnheader").nth(index).getByRole("button").count(), 1);
for (const index of [0, 1, 3, 4]) assert.equal(await table.getByRole("columnheader").nth(index).getByRole("button").count(), 1);
const runButton = page.getByRole("button", { name: "Run job", exact: true }), optionsButton = page.getByRole("button", { name: "More run options", exact: true });
const runBox = await runButton.boundingBox(), optionsBox = await optionsButton.boundingBox();
assert.equal(runBox.x + runBox.width, optionsBox.x);
Expand Down
Loading
Loading