diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 0e120505a..59f665632 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -107,6 +107,10 @@ export default [ "routes/ws/workflows/page.$workflowId.settings.tsx", ), ]), + route( + "workflows/:workflowId/runs/:runId", + "routes/ws/workflows/page.$workflowId.runs.$runId.tsx", + ), route("settings", "routes/ws/settings/_layout.tsx", [ route("general", "routes/ws/settings/general.tsx"), diff --git a/apps/web/app/routes/ws/workflows/_components/WorkflowRunsTable.tsx b/apps/web/app/routes/ws/workflows/_components/WorkflowRunsTable.tsx index 37289c43e..f1b72d6e5 100644 --- a/apps/web/app/routes/ws/workflows/_components/WorkflowRunsTable.tsx +++ b/apps/web/app/routes/ws/workflows/_components/WorkflowRunsTable.tsx @@ -1,6 +1,8 @@ import type { RouterOutputs } from "@ctrlplane/trpc"; import { formatDistanceToNowStrict } from "date-fns"; +import { useNavigate, useParams } from "react-router"; +import { useWorkspace } from "~/components/WorkspaceProvider"; import { Table, TableBody, @@ -20,8 +22,18 @@ function timeAgo(date: Date | string | null) { } function WorkflowRunRow({ run }: { run: WorkflowRun }) { + const { workspace } = useWorkspace(); + const { workflowId } = useParams<{ workflowId: string }>(); + const navigate = useNavigate(); return ( - + + navigate( + `/${workspace.slug}/workflows/${workflowId}/runs/${run.id}`, + ) + } + > {run.id.slice(0, 8)} diff --git a/apps/web/app/routes/ws/workflows/page.$workflowId.runs.$runId.tsx b/apps/web/app/routes/ws/workflows/page.$workflowId.runs.$runId.tsx new file mode 100644 index 000000000..28d9e3ad3 --- /dev/null +++ b/apps/web/app/routes/ws/workflows/page.$workflowId.runs.$runId.tsx @@ -0,0 +1,230 @@ +import type { RouterOutputs } from "@ctrlplane/trpc"; +import { Fragment } from "react"; +import { formatDistanceToNowStrict } from "date-fns"; +import { ExternalLink } from "lucide-react"; +import { Link, useParams } from "react-router"; + +import { trpc } from "~/api/trpc"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb"; +import { buttonVariants } from "~/components/ui/button"; +import { Separator } from "~/components/ui/separator"; +import { SidebarTrigger } from "~/components/ui/sidebar"; +import { Spinner } from "~/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { useWorkspace } from "~/components/WorkspaceProvider"; +import { cn } from "~/lib/utils"; +import { JobStatusBadge } from "../_components/JobStatusBadge"; + +type WorkflowRunJob = RouterOutputs["workflows"]["runs"]["get"]["jobs"][number]; + +function timeAgo(date: Date | string | null) { + if (date == null) return "-"; + const d = typeof date === "string" ? new Date(date) : date; + return formatDistanceToNowStrict(d, { addSuffix: true }); +} + +function RunPageHeader({ + workflowName, + runId, +}: { + workflowName: string; + runId: string; +}) { + const { workspace } = useWorkspace(); + const { workflowId } = useParams<{ workflowId: string }>(); + return ( +
+
+ + + + + + + Workflows + + + + + + + {workflowName} + + + + + + {runId.slice(0, 8)} + + + +
+
+ ); +} + +function InputsSection({ inputs }: { inputs: unknown }) { + const entries = Object.entries((inputs as Record) ?? {}); + if (entries.length === 0) + return

No inputs.

; + + return ( +
+ {entries.map(([key, value]) => ( + + {key} + {JSON.stringify(value)} + + ))} +
+ ); +} + +function extractLinks(metadata: Record) { + try { + const linksMetadata = metadata["ctrlplane/links"]; + if (linksMetadata == null) return {}; + return JSON.parse(linksMetadata) as Record; + } catch { + return {}; + } +} + +function LinksCell({ metadata }: { metadata: Record }) { + const links = extractLinks(metadata); + + return ( + +
+ {Object.entries(links).map(([label, url]) => ( + + {label} + + + ))} +
+
+ ); +} + +function JobRow({ job }: { job: WorkflowRunJob }) { + return ( + + + {job.id.slice(0, 8)} + + {job.jobAgentName ?? "-"} + + {job.jobAgentType ?? "-"} + + + + + + + {timeAgo(job.createdAt)} + + + ); +} + +export default function WorkflowRunDetailPage() { + const { workspace } = useWorkspace(); + const { workflowId, runId } = useParams<{ + workflowId: string; + runId: string; + }>(); + + const { data: workflow } = trpc.workflows.get.useQuery( + { workspaceId: workspace.id, workflowId: workflowId! }, + { enabled: workflowId != null }, + ); + + const { data: run, isLoading } = trpc.workflows.runs.get.useQuery( + { workflowRunId: runId!, workspaceId: workspace.id }, + { enabled: runId != null }, + ); + + const workflowName = workflow?.name ?? "..."; + + if (isLoading) { + return ( + <> + +
+ +
+ + ); + } + + if (run == null) throw new Error("Workflow run not found"); + + return ( + <> + + +
+
+

Inputs

+ +
+ +
+

Jobs

+ {run.jobs.length === 0 ? ( +

+ No jobs were dispatched for this run. +

+ ) : ( +
+ + + + Job + Agent + Type + Status + Links + Created + + + + {run.jobs.map((job) => ( + + ))} + +
+
+ )} +
+
+ + ); +} diff --git a/packages/trpc/src/routes/workflows.ts b/packages/trpc/src/routes/workflows.ts index 8f48a08f4..ff2553379 100644 --- a/packages/trpc/src/routes/workflows.ts +++ b/packages/trpc/src/routes/workflows.ts @@ -1,3 +1,4 @@ +import type { Tx } from "@ctrlplane/db"; import _ from "lodash"; import { z } from "zod"; @@ -6,6 +7,25 @@ import * as schema from "@ctrlplane/db/schema"; import { protectedProcedure, router } from "../trpc.js"; +const getJobMetadataMap = async (db: Tx, jobIds: string[]) => { + if (jobIds.length === 0) return new Map>(); + + const metadata = await db.query.jobMetadata.findMany({ + where: (jm, { inArray }) => inArray(jm.jobId, jobIds), + }); + + const map = new Map>(); + for (const m of metadata) { + let entry = map.get(m.jobId); + if (!entry) { + entry = {}; + map.set(m.jobId, entry); + } + entry[m.key] = m.value; + } + return map; +}; + export const workflowsRouter = router({ get: protectedProcedure .input( @@ -66,6 +86,62 @@ export const workflowsRouter = router({ }), runs: router({ + get: protectedProcedure + .input( + z.object({ + workflowRunId: z.uuid(), + workspaceId: z.uuid(), + }), + ) + .query(async ({ ctx, input }) => { + const run = await ctx.db + .select() + .from(schema.workflowRun) + .innerJoin( + schema.workflow, + eq(schema.workflow.id, schema.workflowRun.workflowId), + ) + .where( + and( + eq(schema.workflowRun.id, input.workflowRunId), + eq(schema.workflow.workspaceId, input.workspaceId), + ), + ) + .then(takeFirst) + .then(({ workflow_run }) => workflow_run); + + const jobRows = await ctx.db + .select({ + id: schema.job.id, + status: schema.job.status, + message: schema.job.message, + createdAt: schema.job.createdAt, + completedAt: schema.job.completedAt, + jobAgentId: schema.job.jobAgentId, + jobAgentName: schema.jobAgent.name, + jobAgentType: schema.jobAgent.type, + }) + .from(schema.workflowJob) + .innerJoin(schema.job, eq(schema.job.id, schema.workflowJob.jobId)) + .leftJoin( + schema.jobAgent, + eq(schema.jobAgent.id, schema.job.jobAgentId), + ) + .where(eq(schema.workflowJob.workflowRunId, input.workflowRunId)); + + const metadataMap = await getJobMetadataMap( + ctx.db, + jobRows.map((r) => r.id), + ); + + const jobs = jobRows.map((r) => ({ + ...r, + metadata: metadataMap.get(r.id) ?? {}, + })); + + return { ...run, jobs }; + }), + create: protectedProcedure .input( z.object({