feat(tracker-web): submit fleet jobs from the dashboard

Add a collapsible 'New Job' form on the fleet jobs page (task body, priority,
capabilities) wired to a new fleet-client submitJob() -> POST /fleet/jobs, with
inline success/error and auto-refresh. Also add 'ship' to the OperatorAction type
for parity with the coordinator. The existing job-detail 'Ship' button already
drives the human-gate testing -> shipped transition.

Verified: tsc clean; tracker-web suite 230/230.

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:
saravanakumardb1 2026-05-31 04:53:23 -07:00
parent c773742658
commit 176b778a1f
2 changed files with 121 additions and 2 deletions

View File

@ -3,8 +3,9 @@
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components';
import { Button } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context';
import { listJobs, type FleetJob } from '@/lib/fleet-client';
import { listJobs, submitJob, type FleetJob } from '@/lib/fleet-client';
const STAGES = [
'',
@ -26,6 +27,14 @@ export default function FleetJobsPage() {
const [stage, setStage] = useState('');
const [loading, setLoading] = useState(true);
// New-job form state
const [showForm, setShowForm] = useState(false);
const [body, setBody] = useState('');
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('high');
const [caps, setCaps] = useState('build');
const [submitting, setSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null);
const refresh = useCallback(async () => {
try {
const params: Record<string, string> = { limit: '50' };
@ -46,10 +55,108 @@ export default function FleetJobsPage() {
return () => clearInterval(id);
}, [token, refresh]);
const handleSubmit = useCallback(async () => {
if (!body.trim()) {
setSubmitMsg({ ok: false, text: 'Job body is required.' });
return;
}
setSubmitting(true);
setSubmitMsg(null);
try {
const capabilities = caps
.split(',')
.map(c => c.trim())
.filter(Boolean);
const { job } = await submitJob({
idempotencyKey: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
bodyMd: body.trim(),
priority,
capabilities,
});
setSubmitMsg({ ok: true, text: `Submitted ${job.id} (stage: ${job.stage}).` });
setBody('');
await refresh();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Submit failed.';
setSubmitMsg({ ok: false, text: msg });
} finally {
setSubmitting(false);
}
}, [body, caps, priority, refresh]);
return (
<div className="p-6 space-y-6">
<PageHeader title="Fleet Jobs" />
{/* New job */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowForm(v => !v)}
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium"
aria-expanded={showForm}
>
<span>New Job</span>
<span className="text-muted-foreground">{showForm ? '' : '+'}</span>
</button>
{showForm && (
<div className="space-y-3 border-t px-4 py-4">
<div>
<label htmlFor="job-body" className="mb-1 block text-sm font-medium">
Task (markdown)
</label>
<textarea
id="job-body"
value={body}
onChange={e => setBody(e.target.value)}
rows={4}
placeholder="e.g. Create a file HELLO.md with one line: hello. Then stop."
className="w-full rounded border bg-background px-2 py-1 text-sm font-mono"
/>
</div>
<div className="flex flex-wrap items-end gap-4">
<div>
<label htmlFor="job-priority" className="mb-1 block text-sm font-medium">
Priority
</label>
<select
id="job-priority"
value={priority}
onChange={e => setPriority(e.target.value as 'low' | 'normal' | 'high')}
className="rounded border bg-background px-2 py-1 text-sm"
>
<option value="high">high</option>
<option value="normal">normal</option>
<option value="low">low</option>
</select>
</div>
<div>
<label htmlFor="job-caps" className="mb-1 block text-sm font-medium">
Capabilities (comma-separated)
</label>
<input
id="job-caps"
value={caps}
onChange={e => setCaps(e.target.value)}
className="rounded border bg-background px-2 py-1 text-sm"
/>
</div>
<Button onClick={handleSubmit} disabled={submitting} aria-label="Submit new job">
{submitting ? 'Submitting…' : 'Submit Job'}
</Button>
</div>
{submitMsg && (
<p
className={`text-sm ${submitMsg.ok ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}
role="status"
>
{submitMsg.text}
</p>
)}
</div>
)}
</div>
{/* Filters */}
<div className="flex gap-3 items-center">
<label htmlFor="stage-filter" className="text-sm font-medium">

View File

@ -192,6 +192,18 @@ export async function getJob(id: string): Promise<FleetJob | null> {
return apiFetchOptional(`/jobs/${id}`);
}
export interface SubmitJobBody {
idempotencyKey: string;
bodyMd: string;
priority?: 'low' | 'normal' | 'high';
capabilities?: string[];
}
/** Submit a new fleet job. Returns the created job. */
export async function submitJob(body: SubmitJobBody): Promise<{ job: FleetJob }> {
return apiFetch(`/jobs`, { method: 'POST', body: JSON.stringify(body) });
}
/** WIP checkpoint a factory carries across lease re-assignments (server schema). */
export interface FleetCheckpoint {
wipBranch: string;
@ -210,7 +222,7 @@ export async function patchJob(id: string, body: PatchJobBody): Promise<FleetJob
return apiFetch(`/jobs/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
}
export type OperatorAction = 'requeue' | 'reject' | 'cancel';
export type OperatorAction = 'requeue' | 'reject' | 'cancel' | 'ship';
/**
* Operator-initiated lifecycle action (no lease required). The coordinator