Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
226 changes: 226 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,226 @@
import type { RouterOutputs } from "@ctrlplane/trpc";
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 { cn } from "~/lib/utils";
import { useWorkspace } from "~/components/WorkspaceProvider";
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]) => (
<>
<span key={`${key}-label`} className="text-muted-foreground">
{key}
</span>
<span key={`${key}-value`} className="font-mono text-xs">
{JSON.stringify(value)}
</span>
</>
))}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
);
}

function LinksCell({ metadata }: { metadata: Record<string, string> }) {
const links: Record<string, string> =
metadata["ctrlplane/links"] != null
? JSON.parse(metadata["ctrlplane/links"])
: {};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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! },
{ 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>
</>
);
}
66 changes: 66 additions & 0 deletions packages/trpc/src/routes/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,29 @@ import { z } from "zod";
import { and, count, eq, sql, takeFirst } from "@ctrlplane/db";
import * as schema from "@ctrlplane/db/schema";

import type { Tx } from "@ctrlplane/db";

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 +87,51 @@ export const workflowsRouter = router({
}),

runs: router({
get: protectedProcedure
.input(
z.object({
workflowRunId: z.string().uuid(),
}),
)
.query(async ({ ctx, input }) => {
const run = await ctx.db
.select()
.from(schema.workflowRun)
.where(eq(schema.workflowRun.id, input.workflowRunId))
.then(takeFirst);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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