Extract shared run data, add list view for workflow runs tab

Shared data module at data/runs.ts powers both the kanban board
(/runs) and the vertical list view (/workflows/:name/runs). List view
shows status dot, repo, title, diff stats, CI badge, comments, and
elapsed time in a compact row format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-28 13:58:40 -05:00
parent 093eecdb6e
commit 0600b2ab59
4 changed files with 270 additions and 174 deletions

View file

@ -0,0 +1,180 @@
export type CiStatus = "passing" | "failing" | "pending";
export interface RunItem {
repo: string;
title: string;
number?: number;
additions?: number;
deletions?: number;
ci?: CiStatus;
elapsed?: string;
elapsedWarning?: boolean;
resources?: string;
actionDisabled?: boolean;
comments?: number;
}
export type ColumnStatus = "working" | "pending" | "review" | "merge";
export interface RunWithStatus extends RunItem {
status: ColumnStatus;
statusLabel: string;
}
export const columns: {
id: ColumnStatus;
name: string;
accent: string;
iconColor: string;
iconType: "branch" | "pr";
actions: string[];
items: RunItem[];
}[] = [
{
id: "working",
name: "Working",
accent: "bg-teal-500",
iconColor: "text-teal-500",
iconType: "branch",
actions: ["Watch", "Steer"],
items: [
{
repo: "api-server",
title: "Add rate limiting to auth endpoints",
resources: "4 CPU / 8 GB",
elapsed: "7m",
},
{
repo: "web-dashboard",
title: "Migrate to React Router v7",
resources: "8 CPU / 16 GB",
elapsed: "2h 15m",
},
{
repo: "cli-tools",
title: "Fix config parsing for nested values",
resources: "2 CPU / 4 GB",
elapsed: "45m",
},
],
},
{
id: "pending",
name: "Pending",
accent: "bg-amber",
iconColor: "text-amber",
iconType: "branch",
actions: ["Answer Question"],
items: [
{
repo: "api-server",
title: "Update OpenAPI spec for v3",
additions: 567,
deletions: 234,
elapsed: "1h 12m",
},
{
repo: "shared-types",
title: "Add pipeline event types",
additions: 145,
deletions: 23,
elapsed: "28m",
},
],
},
{
id: "review",
name: "Verify",
accent: "bg-mint",
iconColor: "text-mint",
iconType: "pr",
actions: ["Resolve"],
items: [
{
repo: "web-dashboard",
title: "Add dark mode toggle",
number: 889,
additions: 234,
deletions: 67,
ci: "failing",
elapsed: "35m",
comments: 4,
},
{
repo: "infrastructure",
title: "Terraform module for Redis cluster",
number: 156,
additions: 412,
deletions: 0,
ci: "pending",
elapsed: "12m",
actionDisabled: true,
comments: 1,
},
],
},
{
id: "merge",
name: "Merge",
accent: "bg-teal-300",
iconColor: "text-teal-300",
iconType: "pr",
actions: ["Merge"],
items: [
{
repo: "api-server",
title: "Implement webhook retry logic",
number: 1249,
additions: 189,
deletions: 45,
ci: "passing",
elapsed: "3d",
elapsedWarning: true,
comments: 7,
},
{
repo: "cli-tools",
title: "Add --verbose flag to run command",
number: 430,
additions: 56,
deletions: 12,
ci: "passing",
elapsed: "1h 5m",
comments: 2,
},
{
repo: "shared-types",
title: "Export utility type helpers",
number: 76,
additions: 34,
deletions: 8,
ci: "passing",
elapsed: "48m",
comments: 0,
},
],
},
];
export function allRunsFlat(): RunWithStatus[] {
return columns.flatMap((col) =>
col.items.map((item) => ({
...item,
status: col.id,
statusLabel: col.name,
})),
);
}
export const statusColors: Record<ColumnStatus, { dot: string; text: string }> = {
working: { dot: "bg-teal-500", text: "text-teal-500" },
pending: { dot: "bg-amber", text: "text-amber" },
review: { dot: "bg-mint", text: "text-mint" },
merge: { dot: "bg-teal-300", text: "text-teal-300" },
};
export const ciConfig: Record<CiStatus, { label: string; dot: string; text: string }> = {
passing: { label: "Passing", dot: "bg-mint", text: "text-mint" },
failing: { label: "Changes needed", dot: "bg-coral", text: "text-coral" },
pending: { label: "Pending", dot: "bg-amber", text: "text-amber" },
};

View file

@ -45,8 +45,9 @@ export default function AppShell() {
const currentNav = navigation.find((item) => pathname.startsWith(item.href));
const title = currentNav?.name ?? "";
const lastMatch = matches[matches.length - 1];
const headerExtra = (lastMatch?.handle as { headerExtra?: React.ReactNode })
?.headerExtra;
const handle = lastMatch?.handle as { headerExtra?: React.ReactNode; wide?: boolean } | undefined;
const headerExtra = handle?.headerExtra;
const maxWidth = handle?.wide ? "max-w-7xl" : "max-w-5xl";
return (
<div className="min-h-full">
@ -187,13 +188,15 @@ export default function AppShell() {
</Disclosure>
<header className="relative bg-navy-800 after:pointer-events-none after:absolute after:inset-x-0 after:inset-y-0 after:bottom-0 after:border-y after:border-white/10">
<div className="flex items-center px-4 py-4 sm:px-6 lg:px-8">
<h1 className="text-lg/6 font-semibold text-white">{title}</h1>
{headerExtra && <div className="ml-auto">{headerExtra}</div>}
<div className={`mx-auto ${maxWidth} px-4 py-4 sm:px-6 lg:px-8`}>
<div className="flex items-center">
<h1 className="text-lg/6 font-semibold text-white">{title}</h1>
{headerExtra && <div className="ml-auto">{headerExtra}</div>}
</div>
</div>
</header>
<main>
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className={`mx-auto ${maxWidth} px-4 py-6 sm:px-6 lg:px-8`}>
<Outlet />
</div>
</main>

View file

@ -1,3 +1,5 @@
import { columns, ciConfig } from "../data/runs";
import type { CiStatus, RunItem } from "../data/runs";
import type { Route } from "./+types/pipelines";
export function meta({}: Route.MetaArgs) {
@ -31,18 +33,10 @@ function GitPullRequestIcon({ className }: { className?: string }) {
);
}
type CiStatus = "passing" | "failing" | "pending";
const ciConfig: Record<CiStatus, { label: string; dot: string; text: string }> =
{
passing: { label: "Passing", dot: "bg-mint", text: "text-mint" },
failing: {
label: "Changes needed",
dot: "bg-coral",
text: "text-coral",
},
pending: { label: "Pending", dot: "bg-amber", text: "text-amber" },
};
const iconMap = {
branch: GitBranchIcon,
pr: GitPullRequestIcon,
};
function CiBadge({ status }: { status: CiStatus }) {
const config = ciConfig[status];
@ -54,157 +48,6 @@ function CiBadge({ status }: { status: CiStatus }) {
);
}
interface PullRequest {
repo: string;
title: string;
number?: number;
additions?: number;
deletions?: number;
ci?: CiStatus;
elapsed?: string;
elapsedWarning?: boolean;
resources?: string;
actionDisabled?: boolean;
comments?: number;
}
interface Column {
id: string;
name: string;
accent: string;
iconColor: string;
icon: React.ComponentType<{ className?: string }>;
actions?: string[];
items: PullRequest[];
}
const columns: Column[] = [
{
id: "working",
name: "Working",
accent: "bg-teal-500",
iconColor: "text-teal-500",
icon: GitBranchIcon,
actions: ["Watch", "Steer"],
items: [
{
repo: "api-server",
title: "Add rate limiting to auth endpoints",
resources: "4 CPU / 8 GB",
elapsed: "7m",
},
{
repo: "web-dashboard",
title: "Migrate to React Router v7",
resources: "8 CPU / 16 GB",
elapsed: "2h 15m",
},
{
repo: "cli-tools",
title: "Fix config parsing for nested values",
resources: "2 CPU / 4 GB",
elapsed: "45m",
},
],
},
{
id: "pending",
name: "Pending",
accent: "bg-amber",
iconColor: "text-amber",
icon: GitBranchIcon,
actions: ["Answer Question"],
items: [
{
repo: "api-server",
title: "Update OpenAPI spec for v3",
additions: 567,
deletions: 234,
elapsed: "1h 12m",
},
{
repo: "shared-types",
title: "Add pipeline event types",
additions: 145,
deletions: 23,
elapsed: "28m",
},
],
},
{
id: "review",
name: "Verify",
accent: "bg-mint",
iconColor: "text-mint",
icon: GitPullRequestIcon,
actions: ["Resolve"],
items: [
{
repo: "web-dashboard",
title: "Add dark mode toggle",
number: 889,
additions: 234,
deletions: 67,
ci: "failing",
elapsed: "35m",
comments: 4,
},
{
repo: "infrastructure",
title: "Terraform module for Redis cluster",
number: 156,
additions: 412,
deletions: 0,
ci: "pending",
elapsed: "12m",
actionDisabled: true,
comments: 1,
},
],
},
{
id: "merge",
name: "Merge",
accent: "bg-teal-300",
iconColor: "text-teal-300",
icon: GitPullRequestIcon,
actions: ["Merge"],
items: [
{
repo: "api-server",
title: "Implement webhook retry logic",
number: 1249,
additions: 189,
deletions: 45,
ci: "passing",
elapsed: "3d",
elapsedWarning: true,
comments: 7,
},
{
repo: "cli-tools",
title: "Add --verbose flag to run command",
number: 430,
additions: 56,
deletions: 12,
ci: "passing",
elapsed: "1h 5m",
comments: 2,
},
{
repo: "shared-types",
title: "Export utility type helpers",
number: 76,
additions: 34,
deletions: 8,
ci: "passing",
elapsed: "48m",
comments: 0,
},
],
},
];
const totalCards = columns.reduce((sum, col) => sum + col.items.length, 0);
const totalPrs = columns.reduce(
(sum, col) => sum + col.items.filter((item) => item.number != null).length,
@ -212,6 +55,7 @@ const totalPrs = columns.reduce(
);
export const handle = {
wide: true,
headerExtra: (
<div className="flex items-center gap-4 font-mono text-xs text-ice-300">
<span>
@ -230,7 +74,7 @@ function PrCard({
iconColor,
actions,
}: {
pr: PullRequest;
pr: RunItem;
icon: React.ComponentType<{ className?: string }>;
iconColor: string;
actions?: string[];
@ -332,7 +176,8 @@ function PrCard({
);
}
function BoardColumn({ column }: { column: Column }) {
function BoardColumn({ column }: { column: (typeof columns)[number] }) {
const Icon = iconMap[column.iconType];
return (
<div className="flex min-w-[280px] flex-1 flex-col">
<div className="mb-4 flex items-center gap-3">
@ -350,7 +195,7 @@ function BoardColumn({ column }: { column: Column }) {
<PrCard
key={`${pr.repo}-${pr.number ?? pr.title}`}
pr={pr}
icon={column.icon}
icon={Icon}
iconColor={column.iconColor}
actions={column.actions}
/>

View file

@ -1,3 +1,71 @@
export default function WorkflowRuns() {
return <p className="text-sm text-navy-600">Recent runs will appear here.</p>;
import { allRunsFlat, ciConfig, statusColors } from "../data/runs";
import type { CiStatus, RunWithStatus } from "../data/runs";
const runs = allRunsFlat();
function CiBadge({ status }: { status: CiStatus }) {
const config = ciConfig[status];
return (
<span className={`inline-flex items-center gap-1.5 font-mono text-xs ${config.text}`}>
<span className={`size-1.5 rounded-full ${config.dot}`} />
{config.label}
</span>
);
}
function RunRow({ run }: { run: RunWithStatus }) {
const colors = statusColors[run.status];
return (
<div className="flex items-center gap-4 rounded-lg border border-white/[0.06] bg-navy-800/80 px-4 py-3 transition-all duration-200 hover:border-white/[0.12] hover:bg-navy-800">
<span className="flex items-center gap-2 min-w-[80px]">
<span className={`size-2 shrink-0 rounded-full ${colors.dot}`} />
<span className={`text-xs font-medium ${colors.text}`}>{run.statusLabel}</span>
</span>
<span className="font-mono text-xs font-medium text-teal-500 min-w-[110px]">
{run.repo}
{run.number != null && (
<span className="text-navy-600"> #{run.number}</span>
)}
</span>
<span className="flex-1 truncate text-sm text-ice-100">{run.title}</span>
{run.additions != null && run.deletions != null && (
<span className="hidden sm:flex items-center gap-2 font-mono text-xs">
<span className="text-mint">+{run.additions.toLocaleString()}</span>
<span className="text-coral">-{run.deletions.toLocaleString()}</span>
</span>
)}
{run.ci != null && (
<span className="hidden md:block min-w-[120px]">
<CiBadge status={run.ci} />
</span>
)}
{run.comments != null && run.comments > 0 && (
<span className="hidden sm:inline-flex items-center gap-1 font-mono text-xs text-navy-600">
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
</svg>
{run.comments}
</span>
)}
<span className={`font-mono text-xs min-w-[60px] text-right ${run.elapsedWarning ? "text-amber" : "text-navy-600"}`}>
{run.elapsed}
</span>
</div>
);
}
export default function WorkflowRuns() {
return (
<div className="space-y-2">
{runs.map((run) => (
<RunRow key={`${run.repo}-${run.number ?? run.title}`} run={run} />
))}
</div>
);
}