From b9ac4771a04feb623d808fbc350cf7671eb1ac2c Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 19 Mar 2026 19:15:25 -0400 Subject: [PATCH] feat: add job detail and listing pages with real-time status - Replace placeholder job detail page with functional implementation that fetches from job_queue and job_events tables via Supabase - Show status badge, repo URL, task, cost, branch, timestamps, and error details - Display job events as a visual timeline with icons per event type - Auto-refresh every 5 seconds while job is in progress (queued/ claimed/running) - Add jobs listing page at /jobs with recent jobs table (limit 20) - Add "Jobs" link to landing page and detail page nav bars - Error state when job not found Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/app/jobs/[id]/page.tsx | 453 +++++++++++++++++++++++++--- apps/web/src/app/jobs/page.tsx | 224 ++++++++++++++ apps/web/src/app/page.tsx | 6 + 3 files changed, 641 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/app/jobs/page.tsx diff --git a/apps/web/src/app/jobs/[id]/page.tsx b/apps/web/src/app/jobs/[id]/page.tsx index e044550..1ee78d2 100644 --- a/apps/web/src/app/jobs/[id]/page.tsx +++ b/apps/web/src/app/jobs/[id]/page.tsx @@ -1,19 +1,165 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' import Link from 'next/link' +import { createBrowserClient } from '@/lib/supabase' +import { TABLES, JOB_STATUS } from '@wright/shared' +import type { Job, JobEvent } from '@wright/shared' + +/** Polling interval for in-progress jobs (ms). */ +const POLL_INTERVAL = 5_000 + +/** Whether a job is still in progress and should be polled. */ +function isInProgress(status: string): boolean { + return ( + status === JOB_STATUS.QUEUED || + status === JOB_STATUS.CLAIMED || + status === JOB_STATUS.RUNNING + ) +} + +/** Human-readable relative time string. */ +function timeAgo(dateStr: string): string { + const seconds = Math.floor( + (Date.now() - new Date(dateStr).getTime()) / 1000, + ) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +/** Status badge color map. */ +function statusBadge(status: string) { + const styles: Record = { + queued: 'bg-slate-100 text-slate-700', + claimed: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + succeeded: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + } + return ( + + {status} + + ) +} + +/** Icon for event timeline entries. */ +function eventIcon(eventType: string) { + const iconMap: Record = { + claimed: { bg: 'bg-yellow-500', icon: 'C' }, + cloned: { bg: 'bg-blue-500', icon: 'G' }, + loop_start: { bg: 'bg-wright-500', icon: 'L' }, + edit: { bg: 'bg-purple-500', icon: 'E' }, + test_run: { bg: 'bg-slate-500', icon: 'T' }, + test_pass: { bg: 'bg-green-500', icon: 'P' }, + test_fail: { bg: 'bg-red-500', icon: 'F' }, + pr_created: { bg: 'bg-wright-600', icon: 'R' }, + completed: { bg: 'bg-green-600', icon: 'D' }, + error: { bg: 'bg-red-600', icon: '!' }, + budget_exceeded: { bg: 'bg-orange-500', icon: '$' }, + } + const entry = iconMap[eventType] || { bg: 'bg-slate-400', icon: '?' } + return ( +
+ {entry.icon} +
+ ) +} + +/** Human-readable event type labels. */ +function eventLabel(eventType: string): string { + const labels: Record = { + claimed: 'Job claimed by worker', + cloned: 'Repository cloned', + loop_start: 'Loop iteration started', + edit: 'Code edited', + test_run: 'Tests running', + test_pass: 'Tests passed', + test_fail: 'Tests failed', + pr_created: 'Pull request created', + completed: 'Job completed', + error: 'Error occurred', + budget_exceeded: 'Budget limit reached', + } + return labels[eventType] || eventType +} -/** - * Job detail page -- shows status, events timeline, and test results. - * - * This is a scaffold. The full implementation will: - * - Fetch job details from Supabase - * - Subscribe to real-time job_events updates - * - Show test result diffs between loops - * - Link to the created PR - */ export default function JobDetailPage({ params, }: { params: { id: string } }) { + const [job, setJob] = useState(null) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchJob = useCallback(async () => { + try { + const supabase = createBrowserClient() + + const { data: jobData, error: jobError } = await supabase + .from(TABLES.JOB_QUEUE) + .select('*') + .eq('id', params.id) + .single() + + if (jobError) { + if (jobError.code === 'PGRST116') { + setError('Job not found') + } else { + setError(jobError.message) + } + setLoading(false) + return + } + + setJob(jobData as Job) + + const { data: eventsData } = await supabase + .from(TABLES.JOB_EVENTS) + .select('*') + .eq('job_id', params.id) + .order('created_at', { ascending: true }) + + if (eventsData) { + setEvents(eventsData as JobEvent[]) + } + + setError(null) + setLoading(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch job') + setLoading(false) + } + }, [params.id]) + + useEffect(() => { + fetchJob() + }, [fetchJob]) + + // Poll while job is in progress + useEffect(() => { + if (!job || !isInProgress(job.status)) return + + const interval = setInterval(fetchJob, POLL_INTERVAL) + return () => clearInterval(interval) + }, [job, fetchJob]) + + // Extract repo name from URL + const repoName = job?.repo_url + ? job.repo_url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '') + : '' + return (
{/* Navigation */} @@ -25,45 +171,268 @@ export default function JobDetailPage({ beta - - New Task - +
+ + Jobs + + + New Task + +
-
-
-

Job Details

- - {params.id} - -
+
+ {/* Loading state */} + {loading && ( +
+
+
+ )} - {/* Placeholder for job details */} -
-
-

- This page will show real-time job status, event timeline, and test - results once connected to Supabase. + {/* Error state */} + {error && !loading && ( +

+

{error}

+

+ The job could not be found. It may have been deleted or the ID is + invalid.

-
-

- Coming Soon -

-
    -
  • Real-time status updates via Supabase Realtime
  • -
  • Event timeline (claimed, cloning, editing, testing...)
  • -
  • Test results per loop iteration
  • -
  • Cost tracking (API spend per loop)
  • -
  • Link to created PR
  • -
  • Cancel / retry controls
  • -
-
+ + Back to Jobs +
-
+ )} + + {/* Job details */} + {job && !loading && ( + <> + {/* Header */} +
+
+
+

+ Job Details +

+ {statusBadge(job.status)} + {isInProgress(job.status) && ( + + auto-refreshing + + )} +
+ + {job.id} + +
+ {job.pr_url && ( + + + + + View PR + + )} +
+ + {/* Info grid */} +
+ {/* Repository */} +
+

+ Repository +

+ + {repoName} + +
+ + {/* Branch */} +
+

+ Branch +

+

+ {job.branch} +

+
+ + {/* Cost */} +
+

+ Cost +

+

+ ${job.total_cost_usd.toFixed(2)}{' '} + + / ${job.max_budget_usd.toFixed(2)} + +

+
+ + {/* Created */} +
+

+ Created +

+

+ {timeAgo(job.created_at)} +

+
+
+ + {/* Task description */} +
+

+ Task Description +

+

+ {job.task} +

+
+ + {/* Error message */} + {job.error && ( +
+

Error

+

+ {job.error} +

+
+ )} + + {/* Metadata row */} +
+ {job.test_runner && ( + + Test runner:{' '} + + {job.test_runner} + + + )} + {job.package_manager && ( + + Package manager:{' '} + + {job.package_manager} + + + )} + + Loops:{' '} + + max {job.max_loops} + + + + Attempt:{' '} + + {job.attempt} / {job.max_attempts} + + + {job.worker_id && ( + + Worker:{' '} + + {job.worker_id.slice(0, 8)} + + + )} +
+ + {/* Events timeline */} +
+

+ Event Timeline +

+ + {events.length === 0 ? ( +
+ {isInProgress(job.status) + ? 'Waiting for events...' + : 'No events recorded for this job.'} +
+ ) : ( +
+ {events.map((event, idx) => ( +
+ {/* Timeline line + icon */} +
+ {eventIcon(event.event_type)} + {idx < events.length - 1 && ( +
+ )} +
+ + {/* Content */} +
+
+ + {eventLabel(event.event_type)} + + {event.loop_number != null && ( + + Loop {event.loop_number} + + )} + + {timeAgo(event.created_at)} + +
+ + {/* Event payload */} + {event.payload && + Object.keys(event.payload).length > 0 && ( +
+ + details + +
+                                {JSON.stringify(event.payload, null, 2)}
+                              
+
+ )} +
+
+ ))} +
+ )} +
+ + )}
) diff --git a/apps/web/src/app/jobs/page.tsx b/apps/web/src/app/jobs/page.tsx new file mode 100644 index 0000000..bb25e68 --- /dev/null +++ b/apps/web/src/app/jobs/page.tsx @@ -0,0 +1,224 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { createBrowserClient } from '@/lib/supabase' +import { TABLES } from '@wright/shared' +import type { Job } from '@wright/shared' + +/** Human-readable relative time string. */ +function timeAgo(dateStr: string): string { + const seconds = Math.floor( + (Date.now() - new Date(dateStr).getTime()) / 1000, + ) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +/** Status badge color map. */ +function statusBadge(status: string) { + const styles: Record = { + queued: 'bg-slate-100 text-slate-700', + claimed: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + succeeded: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + } + return ( + + {status} + + ) +} + +/** Extract short repo name from GitHub URL. */ +function repoName(url: string): string { + return url + .replace(/^https?:\/\/github\.com\//, '') + .replace(/\.git$/, '') +} + +/** Truncate text with ellipsis. */ +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text + return text.slice(0, maxLen) + '...' +} + +export default function JobsListPage() { + const [jobs, setJobs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchJobs() { + try { + const supabase = createBrowserClient() + + const { data, error: fetchError } = await supabase + .from(TABLES.JOB_QUEUE) + .select('*') + .order('created_at', { ascending: false }) + .limit(20) + + if (fetchError) { + setError(fetchError.message) + setLoading(false) + return + } + + setJobs((data || []) as Job[]) + setLoading(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch jobs') + setLoading(false) + } + } + + fetchJobs() + }, []) + + return ( +
+ {/* Navigation */} + + +
+
+

Recent Jobs

+ + Submit a Task + +
+ + {/* Loading state */} + {loading && ( +
+
+
+ )} + + {/* Error state */} + {error && !loading && ( +
+

{error}

+
+ )} + + {/* Empty state */} + {!loading && !error && jobs.length === 0 && ( +
+

No jobs yet.

+ + Submit your first task + +
+ )} + + {/* Jobs table */} + {!loading && !error && jobs.length > 0 && ( +
+ + + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + + ))} + +
+ Status + + Repository + + Task + + Cost + + Created + + Actions +
+ {statusBadge(job.status)} + + + {repoName(job.repo_url)} + + + + {truncate(job.task, 60)} + + + ${job.total_cost_usd.toFixed(2)} + + {timeAgo(job.created_at)} + + + View + +
+
+ )} +
+
+ ) +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index baec9de..21e6fb2 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -152,6 +152,12 @@ export default function Home() { > Pricing + + Jobs +