feat(fleet-web): harden budget bar, surface SSE polling, allow checkpoint in patchJob
- budget page: guard spend bar against missing/zero ceiling (no NaN width); show an explicit "no ceiling set" state. Add pure budgetUsagePct() helper. - job detail: replace silent live/poll toggle with an explicit stream-mode badge (Live vs Polling) so operators see when SSE degrades to polling. - fleet-client: extend patchJob to carry optional checkpoint/blockedReason matching the server PatchJobSchema; add FleetCheckpoint type. - tests: unit cover budgetUsagePct + patchJob checkpoint forwarding; e2e asserts the polling indicator appears when the stream is unavailable. - ci: add a Gitea Playwright e2e job that runs the fleet control-plane specs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3de2a797b9
commit
0799f69c30
@ -48,3 +48,30 @@ jobs:
|
|||||||
|
|
||||||
- name: Test release package
|
- name: Test release package
|
||||||
run: pnpm --filter @bytelyst/errors test
|
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
|
||||||
|
|||||||
@ -294,6 +294,24 @@ test.describe('Fleet — Job detail', () => {
|
|||||||
// After requeue the coordinator returns stage 'queued', mirrored on refresh.
|
// After requeue the coordinator returns stage 'queued', mirrored on refresh.
|
||||||
await expect(page.getByText('queued', { exact: true }).first()).toBeVisible();
|
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', () => {
|
test.describe('Fleet — Review gate', () => {
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
upsertBudget,
|
upsertBudget,
|
||||||
pauseBudget,
|
pauseBudget,
|
||||||
resumeBudget,
|
resumeBudget,
|
||||||
|
budgetUsagePct,
|
||||||
parseSseFrames,
|
parseSseFrames,
|
||||||
subscribeJobEvents,
|
subscribeJobEvents,
|
||||||
} from '@/lib/fleet-client';
|
} from '@/lib/fleet-client';
|
||||||
@ -82,6 +83,41 @@ describe('fleet-client', () => {
|
|||||||
expect.objectContaining({ method: 'PATCH' })
|
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', () => {
|
describe('operatorAction', () => {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
getBudgetBurndown,
|
getBudgetBurndown,
|
||||||
pauseBudget,
|
pauseBudget,
|
||||||
resumeBudget,
|
resumeBudget,
|
||||||
|
budgetUsagePct,
|
||||||
type FleetBudget,
|
type FleetBudget,
|
||||||
type CostBurndown,
|
type CostBurndown,
|
||||||
} from '@/lib/fleet-client';
|
} from '@/lib/fleet-client';
|
||||||
@ -112,23 +113,34 @@ export default function FleetBudgetPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spend bar */}
|
{/* Spend bar — guards against a missing/zero ceiling (no NaN bar). */}
|
||||||
<div>
|
{(() => {
|
||||||
<div className="flex justify-between text-sm mb-1">
|
const hasCeiling = Number.isFinite(budget.ceilingUsd) && budget.ceilingUsd > 0;
|
||||||
<span>Spent</span>
|
const usagePct = budgetUsagePct(budget.spentUsd, budget.ceilingUsd);
|
||||||
<span>
|
const overCeiling = hasCeiling && budget.spentUsd >= budget.ceilingUsd;
|
||||||
${budget.spentUsd.toFixed(2)} / ${budget.ceilingUsd.toFixed(2)}
|
return (
|
||||||
</span>
|
<div>
|
||||||
</div>
|
<div className="flex justify-between text-sm mb-1">
|
||||||
<div className="w-full bg-muted rounded-full h-2.5" aria-label="Budget usage bar">
|
<span>Spent</span>
|
||||||
<div
|
<span>
|
||||||
className={`h-2.5 rounded-full ${
|
${budget.spentUsd.toFixed(2)} /{' '}
|
||||||
budget.spentUsd >= budget.ceilingUsd ? 'bg-red-500' : 'bg-blue-500'
|
{hasCeiling ? `$${budget.ceilingUsd.toFixed(2)}` : 'no ceiling set'}
|
||||||
}`}
|
</span>
|
||||||
style={{ width: `${Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100)}%` }}
|
</div>
|
||||||
/>
|
<div className="w-full bg-muted rounded-full h-2.5" aria-label="Budget usage bar">
|
||||||
</div>
|
<div
|
||||||
</div>
|
className={`h-2.5 rounded-full ${overCeiling ? 'bg-red-500' : 'bg-blue-500'}`}
|
||||||
|
style={{ width: `${usagePct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!hasCeiling && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
No spend ceiling configured — usage is unbounded.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
<p>Window: {budget.window}</p>
|
<p>Window: {budget.window}</p>
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export default function FleetJobDetailPage() {
|
|||||||
const [shipping, setShipping] = useState(false);
|
const [shipping, setShipping] = useState(false);
|
||||||
const [acting, setActing] = useState<OperatorAction | null>(null);
|
const [acting, setActing] = useState<OperatorAction | null>(null);
|
||||||
const [reviewing, setReviewing] = useState(false);
|
const [reviewing, setReviewing] = useState(false);
|
||||||
const [live, setLive] = useState(false);
|
const [streamMode, setStreamMode] = useState<'connecting' | 'live' | 'polling'>('connecting');
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -79,19 +79,21 @@ export default function FleetJobDetailPage() {
|
|||||||
}, [token, jobId, refresh]);
|
}, [token, jobId, refresh]);
|
||||||
|
|
||||||
// Live event stream: subscribe via SSE once authenticated; append new events
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!token || !jobId) return;
|
if (!token || !jobId) return;
|
||||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
setStreamMode('connecting');
|
||||||
|
|
||||||
const appendEvent = (e: FleetEvent) => {
|
const appendEvent = (e: FleetEvent) => {
|
||||||
setEvents(prev => (prev.some(x => x.seq === e.seq) ? prev : [...prev, e]));
|
setEvents(prev => (prev.some(x => x.seq === e.seq) ? prev : [...prev, e]));
|
||||||
setLive(true);
|
setStreamMode('live');
|
||||||
};
|
};
|
||||||
|
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
if (pollTimer) return;
|
if (pollTimer) return;
|
||||||
setLive(false);
|
setStreamMode('polling');
|
||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
getJobEvents(jobId)
|
getJobEvents(jobId)
|
||||||
.then(r => setEvents(r.events))
|
.then(r => setEvents(r.events))
|
||||||
@ -273,7 +275,7 @@ export default function FleetJobDetailPage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-semibold mb-2 flex items-center gap-2">
|
<h2 className="text-lg font-semibold mb-2 flex items-center gap-2">
|
||||||
Event Timeline
|
Event Timeline
|
||||||
{live && (
|
{streamMode === 'live' && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium text-green-600"
|
className="inline-flex items-center gap-1 text-xs font-medium text-green-600"
|
||||||
data-testid="live-indicator"
|
data-testid="live-indicator"
|
||||||
@ -282,6 +284,16 @@ export default function FleetJobDetailPage() {
|
|||||||
Live
|
Live
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{streamMode === 'polling' && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-amber-600"
|
||||||
|
data-testid="polling-indicator"
|
||||||
|
title="Live stream unavailable — refreshing every 4s instead."
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
Polling
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm">No events recorded.</p>
|
<p className="text-muted-foreground text-sm">No events recorded.</p>
|
||||||
|
|||||||
@ -177,10 +177,21 @@ export async function getJob(id: string): Promise<FleetJob | null> {
|
|||||||
return apiFetchOptional(`/jobs/${id}`);
|
return apiFetchOptional(`/jobs/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patchJob(
|
/** WIP checkpoint a factory carries across lease re-assignments (server schema). */
|
||||||
id: string,
|
export interface FleetCheckpoint {
|
||||||
body: { leaseEpoch: number; stage: string }
|
wipBranch: string;
|
||||||
): Promise<FleetJob> {
|
wipBase?: string;
|
||||||
|
wipCommit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchJobBody {
|
||||||
|
leaseEpoch: number;
|
||||||
|
stage?: string;
|
||||||
|
checkpoint?: FleetCheckpoint;
|
||||||
|
blockedReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -426,6 +437,19 @@ export async function listFactories(): Promise<{ factories: FleetFactory[] }> {
|
|||||||
|
|
||||||
// ── Budgets ─────────────────────────────────────────────────────────────────
|
// ── 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<FleetBudget | null> {
|
export async function getBudget(productId: string): Promise<FleetBudget | null> {
|
||||||
return apiFetchOptional(`/budgets/${productId}`);
|
return apiFetchOptional(`/budgets/${productId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user