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
4 changes: 4 additions & 0 deletions apps/web/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<TableRow>
<TableRow
className="cursor-pointer"
onClick={() =>
navigate(
`/${workspace.slug}/workflows/${workflowId}/runs/${run.id}`,
)
}
>
Comment on lines +29 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make row navigation keyboard-accessible.

The row is clickable with a mouse, but it is not focusable/operable via keyboard. Please add keyboard semantics (or render a real <Link> target in-cell).

♿ Suggested fix
     <TableRow
       className="cursor-pointer"
+      role="link"
+      tabIndex={0}
       onClick={() =>
         navigate(
           `/${workspace.slug}/workflows/${workflowId}/runs/${run.id}`,
         )
       }
+      onKeyDown={(e) => {
+        if (e.key === "Enter" || e.key === " ") {
+          e.preventDefault();
+          navigate(`/${workspace.slug}/workflows/${workflowId}/runs/${run.id}`);
+        }
+      }}
     >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<TableRow
className="cursor-pointer"
onClick={() =>
navigate(
`/${workspace.slug}/workflows/${workflowId}/runs/${run.id}`,
)
}
>
<TableRow
className="cursor-pointer"
role="link"
tabIndex={0}
onClick={() =>
navigate(
`/${workspace.slug}/workflows/${workflowId}/runs/${run.id}`,
)
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/${workspace.slug}/workflows/${workflowId}/runs/${run.id}`);
}
}}
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/routes/ws/workflows/_components/WorkflowRunsTable.tsx` around
lines 29 - 36, TableRow currently only handles mouse clicks via onClick
(TableRow, navigate, workspace.slug, workflowId, run.id) and is not
keyboard-focusable/operable; make the row keyboard-accessible by either
rendering a real Link inside the row cell pointing to
`/${workspace.slug}/workflows/${workflowId}/runs/${run.id}` or add accessible
semantics to TableRow: set tabIndex={0}, role="link", and handle onKeyDown to
trigger the same navigate call when Enter or Space is pressed, and ensure focus
styling is preserved for keyboard users.

<TableCell className="font-mono text-xs text-muted-foreground">
{run.id.slice(0, 8)}
</TableCell>
Expand Down
230 changes: 230 additions & 0 deletions apps/web/app/routes/ws/workflows/page.$workflowId.runs.$runId.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="flex h-16 shrink-0 items-center gap-2 border-b">
<div className="flex w-full items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/${workspace.slug}/workflows`}>Workflows</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/${workspace.slug}/workflows/${workflowId}`}>
{workflowName}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{runId.slice(0, 8)}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
);
}

function InputsSection({ inputs }: { inputs: unknown }) {
const entries = Object.entries((inputs as Record<string, unknown>) ?? {});
if (entries.length === 0)
return <p className="text-sm text-muted-foreground">No inputs.</p>;

return (
<div className="grid grid-cols-[120px_1fr] gap-y-1 text-sm">
{entries.map(([key, value]) => (
<Fragment key={key}>
<span className="text-muted-foreground">{key}</span>
<span className="font-mono text-xs">{JSON.stringify(value)}</span>
</Fragment>
))}
</div>
);
}

function extractLinks(metadata: Record<string, string>) {
try {
const linksMetadata = metadata["ctrlplane/links"];
if (linksMetadata == null) return {};
return JSON.parse(linksMetadata) as Record<string, string>;
} catch {
return {};
}
}

function LinksCell({ metadata }: { metadata: Record<string, string> }) {
const links = extractLinks(metadata);

return (
<TableCell>
<div className="flex gap-1">
{Object.entries(links).map(([label, url]) => (
<a
key={label}
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"max-w-30 flex h-6 items-center gap-1.5 px-2 py-0",
)}
>
<span className="truncate">{label}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
))}
</div>
</TableCell>
);
}

function JobRow({ job }: { job: WorkflowRunJob }) {
return (
<TableRow>
<TableCell className="font-mono text-xs text-muted-foreground">
{job.id.slice(0, 8)}
</TableCell>
<TableCell>{job.jobAgentName ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">
{job.jobAgentType ?? "-"}
</TableCell>
<TableCell>
<JobStatusBadge status={job.status} message={job.message} />
</TableCell>
<LinksCell metadata={job.metadata} />
<TableCell className="text-muted-foreground">
{timeAgo(job.createdAt)}
</TableCell>
</TableRow>
);
}

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 (
<>
<RunPageHeader workflowName={workflowName} runId={runId ?? ""} />
<div className="flex h-64 items-center justify-center">
<Spinner className="size-6" />
</div>
</>
);
}

if (run == null) throw new Error("Workflow run not found");

return (
<>
<RunPageHeader workflowName={workflowName} runId={run.id} />

<main className="flex-1 space-y-8 overflow-auto p-6">
<section className="space-y-2">
<h2 className="text-lg font-semibold">Inputs</h2>
<InputsSection inputs={run.inputs} />
</section>

<section className="space-y-2">
<h2 className="text-lg font-semibold">Jobs</h2>
{run.jobs.length === 0 ? (
<p className="text-sm text-muted-foreground">
No jobs were dispatched for this run.
</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Job</TableHead>
<TableHead>Agent</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Links</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{run.jobs.map((job) => (
<JobRow key={job.id} job={job} />
))}
</TableBody>
</Table>
</div>
)}
</section>
</main>
</>
);
}
76 changes: 76 additions & 0 deletions packages/trpc/src/routes/workflows.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Tx } from "@ctrlplane/db";
import _ from "lodash";
import { z } from "zod";

Expand All @@ -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<string, Record<string, string>>();

const metadata = await db.query.jobMetadata.findMany({
where: (jm, { inArray }) => inArray(jm.jobId, jobIds),
});

const map = new Map<string, Record<string, string>>();
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(
Expand Down Expand Up @@ -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({
Expand Down
Loading