From 56274dd33a47a6e7d649b3884d853735d86d3268 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 21 Apr 2026 12:38:13 +0200 Subject: [PATCH 1/2] fix(UpdateCard): render Execute when status=completed + phase=prepared SOVD collapses prepare and execute terminal states into status=completed and keeps the real phase on the vendor extension x-medkit-phase. Plugins that split the install pipeline (uptane_ota and similar) emit { status: 'completed', 'x-medkit-phase': 'prepared' } after prepare - the install still needs to be triggered. The old actionButtonsForStatus only branched on status, so prepared updates flashed as fully terminal and showed only Delete; the Execute button never rendered and users had no UI path to finish the install. Add the phase field to the UpdateStatus type and pass the full status into actionButtonsForStatus. When phase === 'prepared' return ['execute', 'delete']; any other completed update is truly terminal and keeps the previous ["delete"] behaviour. Existing tests still pass; add three cases covering phase-missing, phase=prepared, and phase=executed. --- src/components/UpdateCard.test.tsx | 39 ++++++++++++++++++++++++++++++ src/components/UpdateCard.tsx | 17 +++++++++---- src/lib/types.ts | 9 +++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/components/UpdateCard.test.tsx b/src/components/UpdateCard.test.tsx index 70b9704..8d48817 100644 --- a/src/components/UpdateCard.test.tsx +++ b/src/components/UpdateCard.test.tsx @@ -79,6 +79,45 @@ describe('UpdateCard', () => { expect(screen.getByText('completed')).toBeInTheDocument(); }); + it('only offers Delete when an update completed with no x-medkit-phase', () => { + const entry: UpdateEntry = { + id: 'update-done', + status: { status: 'completed' }, + }; + + render(); + + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument(); + }); + + it('surfaces Execute alongside Delete when completed maps to phase=prepared', () => { + // uptane_ota and similar plugins split prepare/execute but expose both + // terminal states as SOVD status=completed. The phase disambiguates: + // completed + prepared means the install still needs to run. + const entry: UpdateEntry = { + id: 'update-prepared', + status: { status: 'completed', 'x-medkit-phase': 'prepared' }, + }; + + render(); + + expect(screen.getByRole('button', { name: /^execute$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('drops Execute once phase advances past prepared', () => { + const entry: UpdateEntry = { + id: 'update-executed', + status: { status: 'completed', 'x-medkit-phase': 'executed' }, + }; + + render(); + + expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + it('shows failed badge with error message text', () => { const entry: UpdateEntry = { id: 'update-failed', diff --git a/src/components/UpdateCard.tsx b/src/components/UpdateCard.tsx index b30d9b8..d8074bc 100644 --- a/src/components/UpdateCard.tsx +++ b/src/components/UpdateCard.tsx @@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Package, Loader2, AlertCircle, FileText } from 'lucide-react'; import { fetchUpdateDetail } from '@/lib/updates-api'; -import type { UpdateEntry, UpdateStatusValue } from '@/lib/types'; +import type { UpdateEntry, UpdateStatus, UpdateStatusValue } from '@/lib/types'; export type UpdateAction = 'prepare' | 'execute' | 'automated' | 'delete'; @@ -58,14 +58,21 @@ function progressBarColor(status: UpdateStatusValue): string { } } -function actionButtonsForStatus(status: UpdateStatusValue): UpdateAction[] { - switch (status) { +function actionButtonsForStatus(status: UpdateStatus): UpdateAction[] { + // SOVD collapses the prepare + execute pipeline into a single + // `completed` terminal status. Plugins that split the pipeline (e.g. + // uptane_ota) keep the real phase on the `x-medkit-phase` vendor field, + // so when status=completed + phase=prepared we are only half done and + // must surface Execute / Delete. Any other completed update (phase + // missing or `executed`) is truly terminal and only Delete applies. + const phase = status['x-medkit-phase']; + switch (status.status) { case 'pending': return ['prepare', 'execute', 'automated', 'delete']; case 'inProgress': return []; case 'completed': - return ['delete']; + return phase === 'prepared' ? ['execute', 'delete'] : ['delete']; case 'failed': return ['prepare', 'execute', 'delete']; } @@ -213,7 +220,7 @@ export function UpdateCard({ entry, baseUrl, busy, onAction }: UpdateCardProps)
{onAction && - actionButtonsForStatus(status.status).map((action) => ( + actionButtonsForStatus(status).map((action) => (