diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9158da94..95af5bbf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -48,3 +48,30 @@ jobs: - name: Test release package run: pnpm --filter @bytelyst/errors test + + e2e-fleet: + name: Fleet E2E (Playwright) + runs-on: [ubuntu-latest, bytelyst, hostinger] + container: + image: node:20-bookworm + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + github-server-url: https://gitea.bytelyst.com + + - name: Install pinned pnpm + run: | + npm install -g pnpm@10.6.5 + pnpm --version + + - name: Install dependencies + run: HUSKY=0 pnpm install --frozen-lockfile + + - name: Install Playwright browser + system deps + run: pnpm --filter @bytelyst/tracker-web exec playwright install --with-deps chromium + + - name: Run fleet e2e + run: pnpm --filter @bytelyst/tracker-web test:e2e diff --git a/dashboards/tracker-web/e2e/fleet.spec.ts b/dashboards/tracker-web/e2e/fleet.spec.ts index f6d97c3f..7949a8ad 100644 --- a/dashboards/tracker-web/e2e/fleet.spec.ts +++ b/dashboards/tracker-web/e2e/fleet.spec.ts @@ -294,6 +294,24 @@ test.describe('Fleet — Job detail', () => { // After requeue the coordinator returns stage 'queued', mirrored on refresh. await expect(page.getByText('queued', { exact: true }).first()).toBeVisible(); }); + + test('surfaces the polling indicator when the live stream is unavailable', async ({ page }) => { + await authenticate(page); + await mockFleet(page, { jobStage: 'building' }); + // Override just the SSE stream to fail → the client falls back to polling. + // Registered after mockFleet so it takes precedence over the catch-all. + await page.route('**/api/fleet/jobs/*/events/stream', (route: Route) => + route.fulfill({ status: 500, body: 'stream down' }) + ); + await page.goto('/dashboard/fleet/jobs/job-1'); + + await expect(page.getByRole('heading', { name: 'feat-x' })).toBeVisible(); + // The degraded transport must be visible to the operator, not silent. + await expect(page.getByTestId('polling-indicator')).toBeVisible(); + await expect(page.getByTestId('live-indicator')).toHaveCount(0); + // Events still render via the polling fallback (GET /events). + await expect(page.getByText('submitted', { exact: true })).toBeVisible(); + }); }); test.describe('Fleet — Review gate', () => { diff --git a/dashboards/tracker-web/src/__tests__/fleet-client.test.ts b/dashboards/tracker-web/src/__tests__/fleet-client.test.ts index 2716d820..a0d34414 100644 --- a/dashboards/tracker-web/src/__tests__/fleet-client.test.ts +++ b/dashboards/tracker-web/src/__tests__/fleet-client.test.ts @@ -30,6 +30,7 @@ import { upsertBudget, pauseBudget, resumeBudget, + budgetUsagePct, parseSseFrames, subscribeJobEvents, } from '@/lib/fleet-client'; @@ -82,6 +83,41 @@ describe('fleet-client', () => { expect.objectContaining({ method: 'PATCH' }) ); }); + + it('forwards an optional checkpoint in the PATCH body', async () => { + fetchSpy.mockResolvedValue({ id: 'j1', stage: 'building' }); + await patchJob('j1', { + leaseEpoch: 3, + stage: 'building', + checkpoint: { wipBranch: 'feat/x', wipCommit: 'abc123' }, + }); + expect(fetchSpy).toHaveBeenCalledWith( + '/jobs/j1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + leaseEpoch: 3, + stage: 'building', + checkpoint: { wipBranch: 'feat/x', wipCommit: 'abc123' }, + }), + }) + ); + }); + }); + + describe('budgetUsagePct', () => { + it('computes a clamped percentage for a normal ceiling', () => { + expect(budgetUsagePct(25, 100)).toBe(25); + expect(budgetUsagePct(150, 100)).toBe(100); // clamps over-budget to 100 + }); + + it('returns 0 for a zero, missing, or non-finite ceiling (no NaN bar)', () => { + expect(budgetUsagePct(10, 0)).toBe(0); + expect(budgetUsagePct(0, 0)).toBe(0); + expect(budgetUsagePct(10, NaN)).toBe(0); + expect(budgetUsagePct(10, undefined as unknown as number)).toBe(0); + expect(budgetUsagePct(10, -5)).toBe(0); + }); }); describe('operatorAction', () => { diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx index c0e72598..95b2144d 100644 --- a/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx @@ -9,6 +9,7 @@ import { getBudgetBurndown, pauseBudget, resumeBudget, + budgetUsagePct, type FleetBudget, type CostBurndown, } from '@/lib/fleet-client'; @@ -112,23 +113,34 @@ export default function FleetBudgetPage() { - {/* Spend bar */} -
-
- Spent - - ${budget.spentUsd.toFixed(2)} / ${budget.ceilingUsd.toFixed(2)} - -
-
-
= budget.ceilingUsd ? 'bg-red-500' : 'bg-blue-500' - }`} - style={{ width: `${Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100)}%` }} - /> -
-
+ {/* Spend bar — guards against a missing/zero ceiling (no NaN bar). */} + {(() => { + const hasCeiling = Number.isFinite(budget.ceilingUsd) && budget.ceilingUsd > 0; + const usagePct = budgetUsagePct(budget.spentUsd, budget.ceilingUsd); + const overCeiling = hasCeiling && budget.spentUsd >= budget.ceilingUsd; + return ( +
+
+ Spent + + ${budget.spentUsd.toFixed(2)} /{' '} + {hasCeiling ? `$${budget.ceilingUsd.toFixed(2)}` : 'no ceiling set'} + +
+
+
+
+ {!hasCeiling && ( +

+ No spend ceiling configured — usage is unbounded. +

+ )} +
+ ); + })()}

Window: {budget.window}

diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx index 417cf0a2..a4162819 100644 --- a/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx @@ -42,7 +42,7 @@ export default function FleetJobDetailPage() { const [shipping, setShipping] = useState(false); const [acting, setActing] = useState(null); const [reviewing, setReviewing] = useState(false); - const [live, setLive] = useState(false); + const [streamMode, setStreamMode] = useState<'connecting' | 'live' | 'polling'>('connecting'); const refresh = useCallback(async () => { try { @@ -79,19 +79,21 @@ export default function FleetJobDetailPage() { }, [token, jobId, refresh]); // Live event stream: subscribe via SSE once authenticated; append new events - // (deduped by seq). Fall back to polling if streaming is unavailable. + // (deduped by seq). Fall back to polling if streaming is unavailable, and + // surface that degraded state to the operator (live vs polling badge). useEffect(() => { if (!token || !jobId) return; let pollTimer: ReturnType | undefined; + setStreamMode('connecting'); const appendEvent = (e: FleetEvent) => { setEvents(prev => (prev.some(x => x.seq === e.seq) ? prev : [...prev, e])); - setLive(true); + setStreamMode('live'); }; const startPolling = () => { if (pollTimer) return; - setLive(false); + setStreamMode('polling'); pollTimer = setInterval(() => { getJobEvents(jobId) .then(r => setEvents(r.events)) @@ -273,7 +275,7 @@ export default function FleetJobDetailPage() {

Event Timeline - {live && ( + {streamMode === 'live' && ( )} + {streamMode === 'polling' && ( + + + Polling + + )}

{events.length === 0 ? (

No events recorded.

diff --git a/dashboards/tracker-web/src/lib/fleet-client.ts b/dashboards/tracker-web/src/lib/fleet-client.ts index e1c83195..674e2686 100644 --- a/dashboards/tracker-web/src/lib/fleet-client.ts +++ b/dashboards/tracker-web/src/lib/fleet-client.ts @@ -177,10 +177,21 @@ export async function getJob(id: string): Promise { return apiFetchOptional(`/jobs/${id}`); } -export async function patchJob( - id: string, - body: { leaseEpoch: number; stage: string } -): Promise { +/** WIP checkpoint a factory carries across lease re-assignments (server schema). */ +export interface FleetCheckpoint { + wipBranch: string; + wipBase?: string; + wipCommit?: string; +} + +export interface PatchJobBody { + leaseEpoch: number; + stage?: string; + checkpoint?: FleetCheckpoint; + blockedReason?: string; +} + +export async function patchJob(id: string, body: PatchJobBody): Promise { return apiFetch(`/jobs/${id}`, { method: 'PATCH', body: JSON.stringify(body) }); } @@ -426,6 +437,19 @@ export async function listFactories(): Promise<{ factories: FleetFactory[] }> { // ── Budgets ───────────────────────────────────────────────────────────────── +/** + * Spend as a clamped 0–100 percentage of the ceiling. Guards against a missing, + * non-finite, or zero ceiling (which would otherwise yield NaN/Infinity and + * render a broken spend bar) by returning 0 — callers should show a "no ceiling" + * state in that case. + */ +export function budgetUsagePct(spentUsd: number, ceilingUsd: number): number { + if (!Number.isFinite(ceilingUsd) || ceilingUsd <= 0) return 0; + const pct = (spentUsd / ceilingUsd) * 100; + if (!Number.isFinite(pct) || pct < 0) return 0; + return Math.min(100, pct); +} + export async function getBudget(productId: string): Promise { return apiFetchOptional(`/budgets/${productId}`); }