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:
parent
c773742658
commit
176b778a1f
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user