Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion src/components/UpdateCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UpdateCard } from './UpdateCard';
import type { UpdateEntry } from '@/lib/types';
import type { UpdateEntry, UpdatePhase } from '@/lib/types';

describe('UpdateCard', () => {
it('renders update ID', () => {
Expand Down Expand Up @@ -79,6 +79,75 @@ 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(<UpdateCard entry={entry} onAction={vi.fn()} />);

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', () => {
// Plugins that split prepare/execute 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(<UpdateCard entry={entry} onAction={vi.fn()} />);

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(<UpdateCard entry={entry} onAction={vi.fn()} />);

expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
Comment thread
mfaferek93 marked this conversation as resolved.

it('falls back to Delete-only when completed carries an unknown phase', () => {
// Contract: only `prepared` surfaces Execute. Any other (or
// out-of-enum) phase on `completed` is treated as terminal.
const entry: UpdateEntry = {
id: 'update-unknown-phase',
status: {
status: 'completed',
'x-medkit-phase': 'installing' as UpdatePhase,
},
};

render(<UpdateCard entry={entry} onAction={vi.fn()} />);

expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});

it('ignores phase on failed updates and offers the retry actions', () => {
const entry: UpdateEntry = {
id: 'update-failed-after-prepare',
status: { status: 'failed', 'x-medkit-phase': 'prepared' },
};

render(<UpdateCard entry={entry} onAction={vi.fn()} />);

expect(screen.getByRole('button', { name: /prepare/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^execute$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});

it('shows failed badge with error message text', () => {
const entry: UpdateEntry = {
id: 'update-failed',
Expand Down
21 changes: 16 additions & 5 deletions src/components/UpdateCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -58,16 +58,27 @@ 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 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. The
// `default` keeps the UI safe if a plugin emits a status outside the
// documented enum.
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'];
Comment thread
mfaferek93 marked this conversation as resolved.
case 'failed':
return ['prepare', 'execute', 'delete'];
Comment thread
mfaferek93 marked this conversation as resolved.
default:
return ['delete'];
}
}

Expand Down Expand Up @@ -213,7 +224,7 @@ export function UpdateCard({ entry, baseUrl, busy, onAction }: UpdateCardProps)

<div className="flex flex-wrap gap-2 pt-1">
{onAction &&
actionButtonsForStatus(status.status).map((action) => (
actionButtonsForStatus(status).map((action) => (
<Button
key={action}
size="sm"
Expand Down
16 changes: 16 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,13 @@ export interface TokenRevokeRequest {
*/
export type UpdateStatusValue = 'pending' | 'inProgress' | 'completed' | 'failed';

/**
* Lifecycle phase for an update, carried on the gateway-specific vendor
* extension `x-medkit-phase`. See `docs/api/rest.rst` in the gateway repo
* for the authoritative contract.
*/
export type UpdatePhase = 'none' | 'preparing' | 'prepared' | 'executing' | 'executed' | 'failed' | 'deleting';

/**
* Sub-step progress for an update operation
*/
Expand All @@ -957,12 +964,21 @@ export interface UpdateSubProgress {

/**
* Status response from GET /updates/{id}/status
*
* `x-medkit-phase` is a gateway-specific vendor extension used by OTA
* plugins that split the lifecycle into two SOVD-visible terminal states:
* `prepared` (ready for Execute) and `executed` (install actually applied).
* Plain SOVD collapses both into `status: "completed"`, so without the
* phase the UI cannot tell a prepared update apart from a fully installed
* one. Optional because plugins that run the pipeline in one shot do not
* emit it.
*/
export interface UpdateStatus {
status: UpdateStatusValue;
progress?: number; // 0-100
sub_progress?: UpdateSubProgress[];
error?: string;
'x-medkit-phase'?: UpdatePhase;
}
Comment thread
mfaferek93 marked this conversation as resolved.

/**
Expand Down
Loading