- 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>
194 lines
5.6 KiB
TypeScript
194 lines
5.6 KiB
TypeScript
/**
|
|
* 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' });
|
|
}
|