feat(tracker-web): fleet control plane UI — overview, jobs, budget, detail pages (Phase 3 Slice 4)

- Fleet overview page with factory cards + recent jobs polling
- Job table with stage filter tabs
- Job detail page with events timeline, runs, artifacts, DAG subtree, SHIP action
- Budget page with usage bar, pause/resume controls
- API proxy route forwarding /api/fleet/* to platform-service
- Typed fleet-client.ts with graceful 404 degradation
- 16 unit tests for fleet-client (198 total tracker-web tests green)
- Added Fleet nav item to dashboard layout
- Full monorepo build + test green

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Saravanakumar D 2026-05-30 09:47:35 -07:00
parent f4ea7b4a5b
commit b061cc47f3
9 changed files with 1174 additions and 0 deletions

View File

@ -0,0 +1,171 @@
/**
* Fleet client unit tests verifies correct URL construction,
* method usage, and graceful degradation on errors.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { fetchSpy } = vi.hoisted(() => ({ fetchSpy: vi.fn() }));
vi.mock('@bytelyst/api-client', () => ({
createApiClient: () => ({ fetch: fetchSpy }),
}));
import {
listJobs,
getJob,
patchJob,
getJobRuns,
getJobEvents,
getJobArtifacts,
getJobDag,
listFactories,
getBudget,
upsertBudget,
pauseBudget,
resumeBudget,
} from '@/lib/fleet-client';
describe('fleet-client', () => {
beforeEach(() => {
fetchSpy.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('listJobs', () => {
it('calls /jobs with query params', async () => {
fetchSpy.mockResolvedValue({ jobs: [] });
const res = await listJobs({ stage: 'queued', limit: 10 });
expect(res.jobs).toEqual([]);
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining('/jobs'), expect.anything());
});
it('calls /jobs without params when none provided', async () => {
fetchSpy.mockResolvedValue({ jobs: [{ id: 'j1' }] });
const res = await listJobs();
expect(res.jobs).toHaveLength(1);
});
});
describe('getJob', () => {
it('returns job on success', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'queued' });
const job = await getJob('j1');
expect(job?.id).toBe('j1');
});
it('returns null on 404', async () => {
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
const job = await getJob('missing');
expect(job).toBeNull();
});
});
describe('patchJob', () => {
it('sends PATCH with correct body', async () => {
fetchSpy.mockResolvedValue({ id: 'j1', stage: 'shipped' });
const res = await patchJob('j1', { leaseEpoch: 1, stage: 'shipped' });
expect(res.stage).toBe('shipped');
expect(fetchSpy).toHaveBeenCalledWith(
'/jobs/j1',
expect.objectContaining({ method: 'PATCH' })
);
});
});
describe('getJobRuns', () => {
it('returns runs array', async () => {
fetchSpy.mockResolvedValue({ runs: [{ id: 'r1', attempt: 1 }] });
const res = await getJobRuns('j1');
expect(res.runs).toHaveLength(1);
});
});
describe('getJobEvents', () => {
it('returns events array', async () => {
fetchSpy.mockResolvedValue({ events: [{ id: 'e1', type: 'submitted' }] });
const res = await getJobEvents('j1');
expect(res.events).toHaveLength(1);
});
});
describe('getJobArtifacts', () => {
it('returns artifacts array', async () => {
fetchSpy.mockResolvedValue({ artifacts: [] });
const res = await getJobArtifacts('j1');
expect(res.artifacts).toHaveLength(0);
});
});
describe('getJobDag', () => {
it('returns dag on success', async () => {
fetchSpy.mockResolvedValue({ dag: { id: 'j1', children: [] } });
const res = await getJobDag('j1');
expect(res?.dag.id).toBe('j1');
});
it('returns null on 404 (leaf job with no children)', async () => {
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
const res = await getJobDag('leaf');
expect(res).toBeNull();
});
});
describe('listFactories', () => {
it('returns factories on success', async () => {
fetchSpy.mockResolvedValue({ factories: [{ id: 'f1' }] });
const res = await listFactories();
expect(res.factories).toHaveLength(1);
});
it('returns empty array on error (graceful degradation)', async () => {
fetchSpy.mockRejectedValue(new Error('Network error'));
const res = await listFactories();
expect(res.factories).toEqual([]);
});
});
describe('budget operations', () => {
it('getBudget returns budget or null', async () => {
fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 });
const b = await getBudget('lysnrai');
expect(b?.ceilingUsd).toBe(100);
fetchSpy.mockRejectedValue(new Error('404'));
const missing = await getBudget('unknown');
expect(missing).toBeNull();
});
it('upsertBudget sends PUT', async () => {
fetchSpy.mockResolvedValue({ id: 'p1', ceilingUsd: 50 });
await upsertBudget('p1', 50, 'monthly');
expect(fetchSpy).toHaveBeenCalledWith(
'/budgets/p1',
expect.objectContaining({ method: 'PUT' })
);
});
it('pauseBudget sends POST to /pause', async () => {
fetchSpy.mockResolvedValue({ status: 'paused' });
const res = await pauseBudget('p1');
expect(res.status).toBe('paused');
expect(fetchSpy).toHaveBeenCalledWith(
'/budgets/p1/pause',
expect.objectContaining({ method: 'POST' })
);
});
it('resumeBudget sends POST to /resume', async () => {
fetchSpy.mockResolvedValue({ status: 'active' });
const res = await resumeBudget('p1');
expect(res.status).toBe('active');
expect(fetchSpy).toHaveBeenCalledWith(
'/budgets/p1/resume',
expect.objectContaining({ method: 'POST' })
);
});
});
});

View File

@ -0,0 +1,58 @@
/**
* Catch-all proxy to platform-service fleet endpoints.
* Forwards all /api/fleet/* requests to the fleet backend.
*/
import { NextRequest, NextResponse } from 'next/server';
const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const targetPath = `/fleet/${path.join('/')}`;
const url = new URL(targetPath, PLATFORM_API);
req.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value);
});
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const auth = req.headers.get('authorization');
if (auth) headers['Authorization'] = auth;
const tokenHeader = req.headers.get('x-tracker-token');
if (tokenHeader && !auth) {
headers['Authorization'] = `Bearer ${tokenHeader}`;
}
const productId = req.headers.get('x-product-id');
if (productId) headers['x-product-id'] = productId;
const fetchOptions: RequestInit = {
method: req.method,
headers,
};
if (req.method !== 'GET' && req.method !== 'HEAD') {
const body = await req.text();
if (body) fetchOptions.body = body;
}
const res = await fetch(url.toString(), fetchOptions);
const data = await res.text();
return new NextResponse(data, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch {
return NextResponse.json({ error: 'Fleet service unavailable' }, { status: 502 });
}
}
export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;

View File

@ -0,0 +1,157 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { PageHeader } from '@bytelyst/dashboard-components';
import { Button } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context';
import { getBudget, pauseBudget, resumeBudget, type FleetBudget } from '@/lib/fleet-client';
export default function FleetBudgetPage() {
const { token } = useAuth();
const [budget, setBudget] = useState<FleetBudget | null | undefined>(undefined);
const [acting, setActing] = useState(false);
const productId =
typeof window !== 'undefined' ? (localStorage.getItem('tracker_selected_product') ?? '') : '';
const refresh = useCallback(async () => {
if (!productId) {
setBudget(null);
return;
}
try {
const b = await getBudget(productId);
setBudget(b);
} catch {
setBudget(null);
}
}, [productId]);
useEffect(() => {
if (!token) return;
refresh();
}, [token, refresh]);
const handlePause = async () => {
if (!productId) return;
setActing(true);
try {
const updated = await pauseBudget(productId);
setBudget(updated);
} catch {
/* degrade */
} finally {
setActing(false);
}
};
const handleResume = async () => {
if (!productId) return;
setActing(true);
try {
const updated = await resumeBudget(productId);
setBudget(updated);
} catch {
/* degrade */
} finally {
setActing(false);
}
};
if (budget === undefined) {
return (
<div className="p-6">
<PageHeader title="Fleet Budget" />
<p className="text-muted-foreground mt-4">Loading budget...</p>
</div>
);
}
return (
<div className="p-6 space-y-6">
<PageHeader title="Fleet Budget" />
{!productId && (
<p className="text-muted-foreground">
Select a product from the sidebar to view its budget.
</p>
)}
{productId && budget === null && (
<div className="rounded-lg border p-6 text-center">
<p className="text-muted-foreground">
No budget configured for <strong>{productId}</strong>.
</p>
<p className="text-sm text-muted-foreground mt-1">
Use the API (PUT /fleet/budgets/{productId}) to set a ceiling.
</p>
</div>
)}
{budget && (
<div className="rounded-lg border p-6 space-y-4 max-w-md">
<div className="flex items-center justify-between">
<h2 className="font-semibold">{budget.productId}</h2>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
budget.status === 'active'
? 'bg-green-500/20 text-green-700 dark:text-green-400'
: 'bg-red-500/20 text-red-700 dark:text-red-400'
}`}
>
{budget.status}
</span>
</div>
{/* Spend bar */}
<div>
<div className="flex justify-between text-sm mb-1">
<span>Spent</span>
<span>
${budget.spentUsd.toFixed(2)} / ${budget.ceilingUsd.toFixed(2)}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2.5" aria-label="Budget usage bar">
<div
className={`h-2.5 rounded-full ${
budget.spentUsd >= budget.ceilingUsd ? 'bg-red-500' : 'bg-blue-500'
}`}
style={{ width: `${Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100)}%` }}
/>
</div>
</div>
<div className="text-sm text-muted-foreground">
<p>Window: {budget.window}</p>
<p>Last updated: {new Date(budget.updatedAt).toLocaleString()}</p>
</div>
{/* Controls */}
<div className="flex gap-3">
{budget.status === 'active' ? (
<Button
variant="destructive"
size="sm"
onClick={handlePause}
disabled={acting}
aria-label="Pause budget"
>
{acting ? 'Pausing...' : 'Pause'}
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={handleResume}
disabled={acting}
aria-label="Resume budget"
>
{acting ? 'Resuming...' : 'Resume'}
</Button>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,230 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components';
import { Button } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context';
import {
getJob,
getJobRuns,
getJobEvents,
getJobArtifacts,
getJobDag,
patchJob,
type FleetJob,
type FleetRun,
type FleetEvent,
type FleetArtifact,
type DagNode,
} from '@/lib/fleet-client';
export default function FleetJobDetailPage() {
const { token } = useAuth();
const params = useParams();
const jobId = params.id as string;
const [job, setJob] = useState<FleetJob | null>(null);
const [runs, setRuns] = useState<FleetRun[]>([]);
const [events, setEvents] = useState<FleetEvent[]>([]);
const [artifacts, setArtifacts] = useState<FleetArtifact[]>([]);
const [dag, setDag] = useState<DagNode | null>(null);
const [loading, setLoading] = useState(true);
const [shipping, setShipping] = useState(false);
const refresh = useCallback(async () => {
try {
const [j, r, e, a, d] = await Promise.all([
getJob(jobId),
getJobRuns(jobId),
getJobEvents(jobId),
getJobArtifacts(jobId),
getJobDag(jobId),
]);
setJob(j);
setRuns(r.runs);
setEvents(e.events);
setArtifacts(a.artifacts);
setDag(d?.dag ?? null);
} catch {
/* degrade */
} finally {
setLoading(false);
}
}, [jobId]);
useEffect(() => {
if (!token || !jobId) return;
refresh();
}, [token, jobId, refresh]);
const handleShip = async () => {
if (!job) return;
setShipping(true);
try {
const updated = await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' });
setJob(updated);
} catch {
/* show error in production */
} finally {
setShipping(false);
}
};
if (loading) {
return (
<div className="p-6">
<PageHeader title="Job Detail" />
<p className="text-muted-foreground mt-4">Loading...</p>
</div>
);
}
if (!job) {
return (
<div className="p-6">
<PageHeader title="Job Not Found" />
<p className="text-muted-foreground mt-4">The requested job does not exist.</p>
<Link href="/dashboard/fleet/jobs" className="text-sm underline mt-2 inline-block">
Back to jobs
</Link>
</div>
);
}
return (
<div className="p-6 space-y-8">
<div className="flex items-center justify-between">
<PageHeader title={job.idempotencyKey} />
{job.stage !== 'shipped' && job.stage !== 'failed' && (
<Button onClick={handleShip} disabled={shipping} aria-label="Ship this job">
{shipping ? 'Shipping...' : 'Ship ✓'}
</Button>
)}
</div>
{/* Job metadata */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetaCard label="Stage" value={job.stage} />
<MetaCard label="Priority" value={job.priority} />
<MetaCard label="Kind" value={job.kind} />
<MetaCard label="Attempts" value={String(job.attempts)} />
</section>
{/* DAG subtree (if present) */}
{dag && dag.children.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-2">DAG Subtree</h2>
<DagTree node={dag} />
</section>
)}
{/* Event timeline */}
<section>
<h2 className="text-lg font-semibold mb-2">Event Timeline</h2>
{events.length === 0 ? (
<p className="text-muted-foreground text-sm">No events recorded.</p>
) : (
<ul className="space-y-2">
{events.map(e => (
<li
key={e.id}
className="flex items-start gap-3 text-sm border-l-2 border-muted pl-3 py-1"
>
<span className="text-muted-foreground text-xs whitespace-nowrap">
{new Date(e.at).toLocaleTimeString()}
</span>
<span className="font-medium">{e.type}</span>
{e.actor && <span className="text-muted-foreground">by {e.actor}</span>}
</li>
))}
</ul>
)}
</section>
{/* Runs */}
<section>
<h2 className="text-lg font-semibold mb-2">Runs</h2>
{runs.length === 0 ? (
<p className="text-muted-foreground text-sm">No runs yet.</p>
) : (
<table className="w-full text-sm" aria-label="Job runs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Attempt</th>
<th className="pb-2 pr-4">Engine</th>
<th className="pb-2 pr-4">Factory</th>
<th className="pb-2 pr-4">Result</th>
<th className="pb-2">Started</th>
</tr>
</thead>
<tbody>
{runs.map(r => (
<tr key={r.id} className="border-b last:border-0">
<td className="py-2 pr-4">#{r.attempt}</td>
<td className="py-2 pr-4 font-mono text-xs">{r.engine}</td>
<td className="py-2 pr-4 font-mono text-xs">{r.factoryId ?? '—'}</td>
<td className="py-2 pr-4">{r.result ?? 'running'}</td>
<td className="py-2 text-xs text-muted-foreground">
{new Date(r.startedAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{/* Artifacts */}
<section>
<h2 className="text-lg font-semibold mb-2">Artifacts</h2>
{artifacts.length === 0 ? (
<p className="text-muted-foreground text-sm">No artifacts.</p>
) : (
<ul className="space-y-1">
{artifacts.map(a => (
<li key={a.id} className="text-sm flex items-center gap-2">
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">{a.kind}</span>
<span className="font-mono text-xs">{a.contentType}</span>
<span className="text-muted-foreground text-xs">
({(a.sizeBytes / 1024).toFixed(1)} KB)
</span>
</li>
))}
</ul>
)}
</section>
<Link href="/dashboard/fleet/jobs" className="text-sm underline inline-block">
Back to jobs
</Link>
</div>
);
}
function MetaCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium capitalize">{value}</p>
</div>
);
}
function DagTree({ node, depth = 0 }: { node: DagNode; depth?: number }) {
return (
<div className={`${depth > 0 ? 'ml-4 border-l pl-3' : ''}`}>
<div className="flex items-center gap-2 py-1">
<span className="font-mono text-xs">{node.idempotencyKey}</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">{node.stage}</span>
{node.kind === 'composite' && (
<span className="text-xs text-muted-foreground">(composite)</span>
)}
</div>
{node.children.map(child => (
<DagTree key={child.id} node={child} depth={depth + 1} />
))}
</div>
);
}

View File

@ -0,0 +1,122 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components';
import { useAuth } from '@/lib/auth-context';
import { listJobs, type FleetJob } from '@/lib/fleet-client';
const STAGES = [
'',
'queued',
'blocked',
'assigned',
'building',
'review',
'testing',
'shipped',
'failed',
'dead_letter',
];
const POLL_INTERVAL = 30_000;
export default function FleetJobsPage() {
const { token } = useAuth();
const [jobs, setJobs] = useState<FleetJob[]>([]);
const [stage, setStage] = useState('');
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const params: Record<string, string> = { limit: '50' };
if (stage) params.stage = stage;
const res = await listJobs(params as never);
setJobs(res.jobs);
} catch {
/* degrade */
} finally {
setLoading(false);
}
}, [stage]);
useEffect(() => {
if (!token) return;
refresh();
const id = setInterval(refresh, POLL_INTERVAL);
return () => clearInterval(id);
}, [token, refresh]);
return (
<div className="p-6 space-y-6">
<PageHeader title="Fleet Jobs" />
{/* Filters */}
<div className="flex gap-3 items-center">
<label htmlFor="stage-filter" className="text-sm font-medium">
Stage:
</label>
<select
id="stage-filter"
value={stage}
onChange={e => setStage(e.target.value)}
className="rounded border px-2 py-1 text-sm bg-background"
aria-label="Filter by stage"
>
{STAGES.map(s => (
<option key={s} value={s}>
{s || 'All'}
</option>
))}
</select>
</div>
{/* Table */}
{loading ? (
<p className="text-muted-foreground">Loading jobs...</p>
) : jobs.length === 0 ? (
<p className="text-muted-foreground">No jobs match the current filter.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Fleet jobs table">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Idempotency Key</th>
<th className="pb-2 pr-4">Stage</th>
<th className="pb-2 pr-4">Priority</th>
<th className="pb-2 pr-4">Kind</th>
<th className="pb-2 pr-4">Attempts</th>
<th className="pb-2">Created</th>
</tr>
</thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50 cursor-pointer">
<td className="py-2 pr-4">
<Link
href={`/dashboard/fleet/jobs/${j.id}`}
className="hover:underline font-mono text-xs"
aria-label={`View job ${j.idempotencyKey}`}
>
{j.idempotencyKey}
</Link>
</td>
<td className="py-2 pr-4">
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted">
{j.stage}
</span>
</td>
<td className="py-2 pr-4 capitalize">{j.priority}</td>
<td className="py-2 pr-4">{j.kind}</td>
<td className="py-2 pr-4">{j.attempts}</td>
<td className="py-2 text-xs text-muted-foreground">
{new Date(j.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,175 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components';
import { useAuth } from '@/lib/auth-context';
import { listFactories, listJobs, type FleetFactory, type FleetJob } from '@/lib/fleet-client';
const POLL_INTERVAL = 30_000;
function HealthBadge({ health }: { health: string }) {
const colors: Record<string, string> = {
ok: 'bg-green-500/20 text-green-700 dark:text-green-400',
degraded: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
down: 'bg-red-500/20 text-red-700 dark:text-red-400',
};
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colors[health] ?? colors.ok}`}
>
{health}
</span>
);
}
function StageBadge({ stage }: { stage: string }) {
const colors: Record<string, string> = {
queued: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
assigned: 'bg-purple-500/20 text-purple-700 dark:text-purple-400',
building: 'bg-orange-500/20 text-orange-700 dark:text-orange-400',
shipped: 'bg-green-500/20 text-green-700 dark:text-green-400',
failed: 'bg-red-500/20 text-red-700 dark:text-red-400',
};
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colors[stage] ?? 'bg-muted text-muted-foreground'}`}
>
{stage}
</span>
);
}
export default function FleetOverviewPage() {
const { token } = useAuth();
const [factories, setFactories] = useState<FleetFactory[]>([]);
const [jobs, setJobs] = useState<FleetJob[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const [facRes, jobRes] = await Promise.all([listFactories(), listJobs({ limit: 10 })]);
setFactories(facRes.factories);
setJobs(jobRes.jobs);
} catch {
/* degrade gracefully */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!token) return;
refresh();
const id = setInterval(refresh, POLL_INTERVAL);
return () => clearInterval(id);
}, [token, refresh]);
if (loading) {
return (
<div className="p-6">
<PageHeader title="Fleet" />
<p className="text-muted-foreground mt-4">Loading fleet data...</p>
</div>
);
}
return (
<div className="p-6 space-y-8">
<PageHeader title="Fleet Control Plane" />
{/* Factory cards */}
<section>
<h2 className="text-lg font-semibold mb-3">Factories</h2>
{factories.length === 0 ? (
<p className="text-muted-foreground text-sm">
No factories registered. Factories appear after their first heartbeat.
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{factories.map(f => (
<div
key={f.id}
className="rounded-lg border p-4 space-y-2"
aria-label={`Factory ${f.factoryId}`}
>
<div className="flex items-center justify-between">
<span className="font-mono text-sm truncate">{f.factoryId}</span>
<HealthBadge health={f.health} />
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>Capabilities: {f.capabilities.length > 0 ? f.capabilities.join(', ') : '—'}</p>
<p>
Load: {f.load} / {f.seatLimit} seats
</p>
<p>Last heartbeat: {new Date(f.lastHeartbeatAt).toLocaleTimeString()}</p>
</div>
</div>
))}
</div>
)}
</section>
{/* Recent jobs summary */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent Jobs</h2>
<Link
href="/dashboard/fleet/jobs"
className="text-sm text-blue-600 hover:underline"
aria-label="View all jobs"
>
View all
</Link>
</div>
{jobs.length === 0 ? (
<p className="text-muted-foreground text-sm">No jobs found.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Recent fleet jobs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Key</th>
<th className="pb-2 pr-4">Stage</th>
<th className="pb-2 pr-4">Priority</th>
<th className="pb-2">Created</th>
</tr>
</thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50">
<td className="py-2 pr-4">
<Link
href={`/dashboard/fleet/jobs/${j.id}`}
className="hover:underline font-mono text-xs"
>
{j.idempotencyKey}
</Link>
</td>
<td className="py-2 pr-4">
<StageBadge stage={j.stage} />
</td>
<td className="py-2 pr-4 capitalize">{j.priority}</td>
<td className="py-2 text-muted-foreground text-xs">
{new Date(j.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Quick links */}
<nav className="flex gap-4" aria-label="Fleet navigation">
<Link href="/dashboard/fleet/jobs" className="text-sm underline" aria-label="All jobs page">
All Jobs
</Link>
<Link href="/dashboard/fleet/budget" className="text-sm underline" aria-label="Budget page">
Budgets
</Link>
</nav>
</div>
);
}

View File

@ -23,6 +23,7 @@ const NAV_ITEMS = [
{ href: '/dashboard', label: 'Overview' }, { href: '/dashboard', label: 'Overview' },
{ href: '/dashboard/items', label: 'Items' }, { href: '/dashboard/items', label: 'Items' },
{ href: '/dashboard/board', label: 'Board' }, { href: '/dashboard/board', label: 'Board' },
{ href: '/dashboard/fleet', label: 'Fleet' },
]; ];
/** Open the ⌘K command palette by replaying the global hotkey. */ /** Open the ⌘K command palette by replaying the global hotkey. */

View File

@ -0,0 +1,193 @@
/**
* Fleet API client typed wrapper for the fleet coordinator endpoints.
* Follows the same pattern as tracker-client.ts.
* Degrades gracefully: 404s return null, network errors return defaults.
*/
import { createApiClient } from '@bytelyst/api-client';
// ── Types ───────────────────────────────────────────────────────────────────
export interface FleetJob {
id: string;
productId: string;
stage: string;
idempotencyKey: string;
bodyMd: string;
priority: string;
priorityOrder: number;
capabilities: string[];
kind: string;
parentId?: string;
attempts: number;
leaseEpoch: number;
createdAt: string;
updatedAt: string;
}
export interface FleetFactory {
id: string;
productId: string;
factoryId: string;
capabilities: string[];
health: 'ok' | 'degraded' | 'down';
load: number;
seatLimit: number;
lastHeartbeatAt: string;
}
export interface FleetRun {
id: string;
jobId: string;
attempt: number;
factoryId?: string;
engine: string;
startedAt: string;
endedAt?: string;
result?: string;
insights: Record<string, unknown>;
}
export interface FleetEvent {
id: string;
jobId: string;
seq: number;
type: string;
at: string;
actor?: string;
data: Record<string, unknown>;
}
export interface FleetArtifact {
id: string;
jobId: string;
kind: string;
contentType: string;
sizeBytes: number;
createdAt: string;
}
export interface FleetBudget {
id: string;
productId: string;
ceilingUsd: number;
window: string;
spentUsd: number;
status: 'active' | 'paused';
updatedAt: string;
}
export interface DagNode {
id: string;
idempotencyKey: string;
stage: string;
priority: string;
kind: string;
parentId?: string;
children: DagNode[];
}
// ── Client ──────────────────────────────────────────────────────────────────
const fleetApi = createApiClient({
baseUrl: '/api/fleet',
getToken: () => (typeof window !== 'undefined' ? localStorage.getItem('tracker_token') : null),
});
function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const extra: Record<string, string> = {};
if (typeof window !== 'undefined') {
const pid = localStorage.getItem('tracker_selected_product');
if (pid) extra['x-product-id'] = pid;
}
return fleetApi.fetch<T>(path, {
...options,
headers: { ...extra, ...(options?.headers as Record<string, string>) },
});
}
/** Graceful fetch — returns null on 404 instead of throwing. */
async function apiFetchOptional<T>(path: string, options?: RequestInit): Promise<T | null> {
try {
return await apiFetch<T>(path, options);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('404')) return null;
throw err;
}
}
// ── Jobs ────────────────────────────────────────────────────────────────────
export interface ListJobsParams {
stage?: string;
productId?: string;
limit?: number;
offset?: number;
}
export async function listJobs(params?: ListJobsParams): Promise<{ jobs: FleetJob[] }> {
const qs = params ? `?${new URLSearchParams(params as Record<string, string>).toString()}` : '';
return apiFetch(`/jobs${qs}`);
}
export async function getJob(id: string): Promise<FleetJob | null> {
return apiFetchOptional(`/jobs/${id}`);
}
export async function patchJob(
id: string,
body: { leaseEpoch: number; stage: string }
): Promise<FleetJob> {
return apiFetch(`/jobs/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
}
export async function getJobRuns(jobId: string): Promise<{ runs: FleetRun[] }> {
return apiFetch(`/jobs/${jobId}/runs`);
}
export async function getJobEvents(jobId: string): Promise<{ events: FleetEvent[] }> {
return apiFetch(`/jobs/${jobId}/events`);
}
export async function getJobArtifacts(jobId: string): Promise<{ artifacts: FleetArtifact[] }> {
return apiFetch(`/jobs/${jobId}/artifacts`);
}
export async function getJobDag(jobId: string): Promise<{ dag: DagNode } | null> {
return apiFetchOptional(`/jobs/${jobId}/dag`);
}
// ── Factories ───────────────────────────────────────────────────────────────
export async function listFactories(): Promise<{ factories: FleetFactory[] }> {
try {
return await apiFetch('/factories');
} catch {
return { factories: [] };
}
}
// ── Budgets ─────────────────────────────────────────────────────────────────
export async function getBudget(productId: string): Promise<FleetBudget | null> {
return apiFetchOptional(`/budgets/${productId}`);
}
export async function upsertBudget(
productId: string,
ceilingUsd: number,
window: string
): Promise<FleetBudget> {
return apiFetch(`/budgets/${productId}`, {
method: 'PUT',
body: JSON.stringify({ ceilingUsd, window }),
});
}
export async function pauseBudget(productId: string): Promise<FleetBudget> {
return apiFetch(`/budgets/${productId}/pause`, { method: 'POST' });
}
export async function resumeBudget(productId: string): Promise<FleetBudget> {
return apiFetch(`/budgets/${productId}/resume`, { method: 'POST' });
}

View File

@ -0,0 +1,67 @@
{
"platform-events": [
{
"id": "5be64f54-5c39-4f0f-af7c-ac7069121a11",
"queueName": "platform-events",
"type": "user.created",
"payload": {
"event": {
"id": "37c00297-57b4-4024-a946-82a2067f350b",
"type": "user.created",
"payload": {
"userId": "usr_8f2f412f-69f1-4b5f-949c-d40e175b7a93",
"email": "test@chronomind.app",
"plan": "free",
"productId": "chronomind"
},
"timestamp": "2026-05-30T06:45:48.670Z",
"source": "auth/register"
}
},
"status": "succeeded",
"attempts": 1,
"maxAttempts": 3,
"createdAt": "2026-05-30T06:45:48.671Z",
"scheduledAt": "2026-05-30T06:45:48.671Z",
"metadata": {
"source": "auth/register"
},
"idempotencyKey": "37c00297-57b4-4024-a946-82a2067f350b",
"productId": "chronomind",
"startedAt": "2026-05-30T06:45:48.759Z",
"completedAt": "2026-05-30T06:45:51.756Z"
},
{
"id": "7438ea3f-fc58-4d04-b58b-628dec76f1ce",
"queueName": "platform-events",
"type": "user.email_verification_requested",
"payload": {
"event": {
"id": "84156892-12f7-448d-9579-7f759af83bbf",
"type": "user.email_verification_requested",
"payload": {
"userId": "usr_8f2f412f-69f1-4b5f-949c-d40e175b7a93",
"email": "test@chronomind.app",
"verificationToken": "4d96e404-8c86-443c-bb95-cf68b7cc194c",
"displayName": "Test User",
"productId": "chronomind"
},
"timestamp": "2026-05-30T06:45:48.822Z",
"source": "auth/register"
}
},
"status": "succeeded",
"attempts": 1,
"maxAttempts": 3,
"createdAt": "2026-05-30T06:45:48.828Z",
"scheduledAt": "2026-05-30T06:45:48.828Z",
"metadata": {
"source": "auth/register"
},
"idempotencyKey": "84156892-12f7-448d-9579-7f759af83bbf",
"productId": "chronomind",
"startedAt": "2026-05-30T06:45:51.771Z",
"completedAt": "2026-05-30T06:45:54.163Z"
}
]
}