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:
parent
f4ea7b4a5b
commit
b061cc47f3
171
dashboards/tracker-web/src/__tests__/fleet-client.test.ts
Normal file
171
dashboards/tracker-web/src/__tests__/fleet-client.test.ts
Normal 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' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
58
dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts
Normal file
58
dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts
Normal 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;
|
||||||
157
dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx
Normal file
157
dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
dashboards/tracker-web/src/app/dashboard/fleet/jobs/page.tsx
Normal file
122
dashboards/tracker-web/src/app/dashboard/fleet/jobs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
dashboards/tracker-web/src/app/dashboard/fleet/page.tsx
Normal file
175
dashboards/tracker-web/src/app/dashboard/fleet/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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. */
|
||||||
|
|||||||
193
dashboards/tracker-web/src/lib/fleet-client.ts
Normal file
193
dashboards/tracker-web/src/lib/fleet-client.ts
Normal 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' });
|
||||||
|
}
|
||||||
67
services/platform-service/.data/platform-events.json
Normal file
67
services/platform-service/.data/platform-events.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user