feat(tracker-web): redesign fleet jobs list — stage chips, filters, hide-shipped
- Clickable stage chips with live counts (color-coded) replace the plain dropdown; click to filter, click again to clear. - Search box (key / repo / id) + 'Hide shipped' checkbox (persisted) + 'N of M shown'. - Redesigned table: color-coded stage badges, priority emphasis, a Repo column (PR target), newest-first sort, bordered/zebra layout. - All filtering is client-side over a 100-job window (instant + accurate counts); list/links/New-Job form unchanged. FleetJob gains repo/baseBranch. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
2bd97791c9
commit
6709862c1a
@ -8,7 +8,6 @@ import { useAuth } from '@/lib/auth-context';
|
|||||||
import { listJobs, submitJob, type FleetJob } from '@/lib/fleet-client';
|
import { listJobs, submitJob, type FleetJob } from '@/lib/fleet-client';
|
||||||
|
|
||||||
const STAGES = [
|
const STAGES = [
|
||||||
'',
|
|
||||||
'queued',
|
'queued',
|
||||||
'blocked',
|
'blocked',
|
||||||
'assigned',
|
'assigned',
|
||||||
@ -18,7 +17,28 @@ const STAGES = [
|
|||||||
'shipped',
|
'shipped',
|
||||||
'failed',
|
'failed',
|
||||||
'dead_letter',
|
'dead_letter',
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
// Semantic badge styling per stage (token-backed Tailwind colors).
|
||||||
|
const STAGE_STYLE: Record<string, string> = {
|
||||||
|
queued: 'bg-muted text-muted-foreground',
|
||||||
|
blocked: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||||
|
assigned: 'bg-sky-500/15 text-sky-700 dark:text-sky-400',
|
||||||
|
building: 'bg-blue-500/15 text-blue-700 dark:text-blue-400',
|
||||||
|
review: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||||
|
testing: 'bg-purple-500/15 text-purple-700 dark:text-purple-400',
|
||||||
|
shipped: 'bg-green-600/15 text-green-700 dark:text-green-400',
|
||||||
|
failed: 'bg-destructive/15 text-destructive',
|
||||||
|
dead_letter: 'bg-destructive/15 text-destructive',
|
||||||
|
};
|
||||||
|
const stageStyle = (s: string) => STAGE_STYLE[s] ?? 'bg-muted text-muted-foreground';
|
||||||
|
const stageLabel = (s: string) => s.replace(/_/g, ' ');
|
||||||
|
const PRIORITY_STYLE: Record<string, string> = {
|
||||||
|
critical: 'text-destructive font-semibold',
|
||||||
|
high: 'text-amber-700 dark:text-amber-400',
|
||||||
|
medium: 'text-muted-foreground',
|
||||||
|
low: 'text-muted-foreground',
|
||||||
|
};
|
||||||
const POLL_INTERVAL = 30_000;
|
const POLL_INTERVAL = 30_000;
|
||||||
|
|
||||||
// MVP: PR-mode target repos (local checkouts under the factory's repo base).
|
// MVP: PR-mode target repos (local checkouts under the factory's repo base).
|
||||||
@ -59,6 +79,8 @@ export default function FleetJobsPage() {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const [jobs, setJobs] = useState<FleetJob[]>([]);
|
const [jobs, setJobs] = useState<FleetJob[]>([]);
|
||||||
const [stage, setStage] = useState('');
|
const [stage, setStage] = useState('');
|
||||||
|
const [hideShipped, setHideShipped] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// New-job form state
|
// New-job form state
|
||||||
@ -73,18 +95,18 @@ export default function FleetJobsPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Fetch the full recent window once; stage / hide-shipped / search are applied
|
||||||
|
// client-side so the stage counts stay accurate and filtering is instant.
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = { limit: '50' };
|
const res = await listJobs({ limit: '100' } as never);
|
||||||
if (stage) params.stage = stage;
|
|
||||||
const res = await listJobs(params as never);
|
|
||||||
setJobs(res.jobs);
|
setJobs(res.jobs);
|
||||||
} catch {
|
} catch {
|
||||||
/* degrade */
|
/* degrade */
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [stage]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@ -93,6 +115,33 @@ export default function FleetJobsPage() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [token, refresh]);
|
}, [token, refresh]);
|
||||||
|
|
||||||
|
// Restore the hide-shipped preference (client-only; avoids hydration mismatch).
|
||||||
|
useEffect(() => {
|
||||||
|
setHideShipped(localStorage.getItem('fleet_hide_shipped') === '1');
|
||||||
|
}, []);
|
||||||
|
const toggleHideShipped = useCallback((next: boolean) => {
|
||||||
|
setHideShipped(next);
|
||||||
|
localStorage.setItem('fleet_hide_shipped', next ? '1' : '0');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stage counts (over everything fetched) + the filtered, sorted view.
|
||||||
|
const stageCounts = jobs.reduce<Record<string, number>>((acc, j) => {
|
||||||
|
acc[j.stage] = (acc[j.stage] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const visible = jobs
|
||||||
|
.filter(j => {
|
||||||
|
if (stage && j.stage !== stage) return false;
|
||||||
|
if (hideShipped && j.stage === 'shipped') return false;
|
||||||
|
if (q) {
|
||||||
|
const hay = `${j.idempotencyKey} ${j.repo ?? ''} ${j.id}`.toLowerCase();
|
||||||
|
if (!hay.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!body.trim()) {
|
if (!body.trim()) {
|
||||||
setSubmitMsg({ ok: false, text: 'Job body is required.' });
|
setSubmitMsg({ ok: false, text: 'Job body is required.' });
|
||||||
@ -291,64 +340,111 @@ export default function FleetJobsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-3 items-center">
|
<div className="space-y-3">
|
||||||
<label htmlFor="stage-filter" className="text-sm font-medium">
|
{/* Stage chips with live counts (click to filter) */}
|
||||||
Stage:
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
</label>
|
<button
|
||||||
<select
|
type="button"
|
||||||
id="stage-filter"
|
onClick={() => setStage('')}
|
||||||
value={stage}
|
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${
|
||||||
onChange={e => setStage(e.target.value)}
|
stage === '' ? 'bg-primary text-primary-foreground border-primary' : 'hover:bg-muted'
|
||||||
className="rounded border px-2 py-1 text-sm bg-background"
|
}`}
|
||||||
aria-label="Filter by stage"
|
>
|
||||||
>
|
All <span className="opacity-70">{jobs.length}</span>
|
||||||
{STAGES.map(s => (
|
</button>
|
||||||
<option key={s} value={s}>
|
{STAGES.filter(s => stageCounts[s]).map(s => (
|
||||||
{s || 'All'}
|
<button
|
||||||
</option>
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStage(stage === s ? '' : s)}
|
||||||
|
className={`rounded-full px-2.5 py-1 text-xs font-medium ${stageStyle(s)} ${
|
||||||
|
stage === s ? 'ring-2 ring-primary ring-offset-1 ring-offset-background' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stageLabel(s)} <span className="opacity-70">{stageCounts[s]}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
|
|
||||||
|
{/* Search + hide-shipped */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search key / repo / id…"
|
||||||
|
className="w-64 rounded border bg-background px-2 py-1 text-sm"
|
||||||
|
aria-label="Search jobs"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideShipped}
|
||||||
|
onChange={e => toggleHideShipped(e.target.checked)}
|
||||||
|
aria-label="Hide shipped jobs"
|
||||||
|
/>
|
||||||
|
Hide shipped
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{visible.length} of {jobs.length} shown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-muted-foreground">Loading jobs...</p>
|
<p className="text-muted-foreground">Loading jobs...</p>
|
||||||
) : jobs.length === 0 ? (
|
) : visible.length === 0 ? (
|
||||||
<p className="text-muted-foreground">No jobs match the current filter.</p>
|
<p className="text-muted-foreground">
|
||||||
|
{jobs.length === 0 ? 'No jobs yet — submit one above.' : 'No jobs match the filters.'}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
<table className="w-full text-sm" aria-label="Fleet jobs table">
|
<table className="w-full text-sm" aria-label="Fleet jobs table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left text-muted-foreground">
|
<tr className="border-b bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
<th className="pb-2 pr-4">Idempotency Key</th>
|
<th className="px-3 py-2">Job</th>
|
||||||
<th className="pb-2 pr-4">Stage</th>
|
<th className="px-3 py-2">Stage</th>
|
||||||
<th className="pb-2 pr-4">Priority</th>
|
<th className="px-3 py-2">Priority</th>
|
||||||
<th className="pb-2 pr-4">Kind</th>
|
<th className="px-3 py-2">Repo</th>
|
||||||
<th className="pb-2 pr-4">Attempts</th>
|
<th className="px-3 py-2 text-right">Attempts</th>
|
||||||
<th className="pb-2">Created</th>
|
<th className="px-3 py-2 text-right">Created</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{jobs.map(j => (
|
{visible.map(j => (
|
||||||
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50 cursor-pointer">
|
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/40">
|
||||||
<td className="py-2 pr-4">
|
<td className="px-3 py-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/fleet/jobs/${j.id}`}
|
href={`/dashboard/fleet/jobs/${j.id}`}
|
||||||
className="hover:underline font-mono text-xs"
|
className="font-mono text-xs hover:underline"
|
||||||
aria-label={`View job ${j.idempotencyKey}`}
|
aria-label={`View job ${j.idempotencyKey}`}
|
||||||
>
|
>
|
||||||
{j.idempotencyKey}
|
{j.idempotencyKey}
|
||||||
</Link>
|
</Link>
|
||||||
|
{j.kind !== 'leaf' && (
|
||||||
|
<span className="ml-2 text-[10px] text-muted-foreground">{j.kind}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-4">
|
<td className="px-3 py-2">
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted">
|
<span
|
||||||
{j.stage}
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageStyle(j.stage)}`}
|
||||||
|
>
|
||||||
|
{stageLabel(j.stage)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-4 capitalize">{j.priority}</td>
|
<td className={`px-3 py-2 capitalize ${PRIORITY_STYLE[j.priority] ?? ''}`}>
|
||||||
<td className="py-2 pr-4">{j.kind}</td>
|
{j.priority}
|
||||||
<td className="py-2 pr-4">{j.attempts}</td>
|
</td>
|
||||||
<td className="py-2 text-xs text-muted-foreground">
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
{j.repo ? (
|
||||||
|
<span title={`${j.repo}@${j.baseBranch ?? 'main'}`}>{j.repo}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{j.attempts}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{new Date(j.createdAt).toLocaleString()}
|
{new Date(j.createdAt).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -25,6 +25,8 @@ export interface FleetJob {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
reviewPolicy?: ReviewPolicy;
|
reviewPolicy?: ReviewPolicy;
|
||||||
reviewDecisions?: ReviewDecision[];
|
reviewDecisions?: ReviewDecision[];
|
||||||
|
repo?: string;
|
||||||
|
baseBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetFactory {
|
export interface FleetFactory {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user