Merge feat/fleet-ui-submit-job: submit fleet jobs from the dashboard
This commit is contained in:
commit
8b9ca4fee2
@ -3,8 +3,9 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||||
|
import { Button } from '@/components/ui/Primitives';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
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 = [
|
const STAGES = [
|
||||||
'',
|
'',
|
||||||
@ -26,6 +27,14 @@ export default function FleetJobsPage() {
|
|||||||
const [stage, setStage] = useState('');
|
const [stage, setStage] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = { limit: '50' };
|
const params: Record<string, string> = { limit: '50' };
|
||||||
@ -46,10 +55,108 @@ export default function FleetJobsPage() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [token, refresh]);
|
}, [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 (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<PageHeader title="Fleet Jobs" />
|
<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 */}
|
{/* Filters */}
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<label htmlFor="stage-filter" className="text-sm font-medium">
|
<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}`);
|
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). */
|
/** WIP checkpoint a factory carries across lease re-assignments (server schema). */
|
||||||
export interface FleetCheckpoint {
|
export interface FleetCheckpoint {
|
||||||
wipBranch: string;
|
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) });
|
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
|
* Operator-initiated lifecycle action (no lease required). The coordinator
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user