fix(web): /machine terminal visible prop, stop/restart confirmation, accurate waiting label
This commit is contained in:
@@ -1,8 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { Loader2, Play, RotateCw, Server, Square } from 'lucide-react';
|
import { Loader2, Play, RotateCw, Server, Square } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -50,6 +60,48 @@ const Detail = ({ label, value }: { label: string; value: string }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stopping or restarting the box tears down the SAME per-user container that a
|
||||||
|
// running agent thread job executes in, so both actions are destructive and get
|
||||||
|
// a confirmation step. Start is safe and stays a plain button.
|
||||||
|
const ConfirmLifecycleButton = ({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
busy,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
busy: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) => (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type='button' variant='outline' size='sm' disabled={busy}>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant='destructive' onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
|
||||||
export const BoxStatusCard = ({
|
export const BoxStatusCard = ({
|
||||||
status,
|
status,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
@@ -96,34 +148,36 @@ export const BoxStatusCard = ({
|
|||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{running ? (
|
{running ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<ConfirmLifecycleButton
|
||||||
type='button'
|
label='Stop'
|
||||||
variant='outline'
|
busy={busy}
|
||||||
size='sm'
|
icon={
|
||||||
disabled={busy}
|
pendingAction === 'stop' ? (
|
||||||
onClick={() => onAction('stop')}
|
<Loader2 className='size-4 animate-spin' />
|
||||||
>
|
) : (
|
||||||
{pendingAction === 'stop' ? (
|
<Square className='size-4' />
|
||||||
<Loader2 className='size-4 animate-spin' />
|
)
|
||||||
) : (
|
}
|
||||||
<Square className='size-4' />
|
title='Stop this machine?'
|
||||||
)}
|
description='Stopping the machine shuts down the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
|
||||||
Stop
|
confirmLabel='Stop machine'
|
||||||
</Button>
|
onConfirm={() => onAction('stop')}
|
||||||
<Button
|
/>
|
||||||
type='button'
|
<ConfirmLifecycleButton
|
||||||
variant='outline'
|
label='Restart'
|
||||||
size='sm'
|
busy={busy}
|
||||||
disabled={busy}
|
icon={
|
||||||
onClick={() => onAction('restart')}
|
pendingAction === 'restart' ? (
|
||||||
>
|
<Loader2 className='size-4 animate-spin' />
|
||||||
{pendingAction === 'restart' ? (
|
) : (
|
||||||
<Loader2 className='size-4 animate-spin' />
|
<RotateCw className='size-4' />
|
||||||
) : (
|
)
|
||||||
<RotateCw className='size-4' />
|
}
|
||||||
)}
|
title='Restart this machine?'
|
||||||
Restart
|
description='Restarting the machine recreates the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
|
||||||
</Button>
|
confirmLabel='Restart machine'
|
||||||
|
onConfirm={() => onAction('restart')}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -235,6 +235,13 @@ export const MachineShell = () => {
|
|||||||
|
|
||||||
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
||||||
const running = Boolean(status?.running);
|
const running = Boolean(status?.running);
|
||||||
|
const starting = pendingAction === 'start' || pendingAction === 'restart';
|
||||||
|
// The terminal only shows this while the box is not running. Distinguish a box
|
||||||
|
// that is starting/restarting from one that is simply stopped so the label is
|
||||||
|
// accurate instead of always claiming "Starting…".
|
||||||
|
const terminalWaitingLabel = starting
|
||||||
|
? 'Starting your machine…'
|
||||||
|
: 'Machine is stopped — start it to open a terminal.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className='space-y-6'>
|
<main className='space-y-6'>
|
||||||
@@ -339,9 +346,10 @@ export const MachineShell = () => {
|
|||||||
>
|
>
|
||||||
<XtermSession
|
<XtermSession
|
||||||
active={running}
|
active={running}
|
||||||
|
visible={activeTab === 'terminal'}
|
||||||
tokenUrl='/api/box/terminal-token'
|
tokenUrl='/api/box/terminal-token'
|
||||||
sessionKey={username}
|
sessionKey={username}
|
||||||
waitingLabel='Starting your machine…'
|
waitingLabel={terminalWaitingLabel}
|
||||||
label='your machine'
|
label='your machine'
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -42,6 +42,40 @@ const stoppedStatus = {
|
|||||||
containerName: 'spoon-home-gib',
|
containerName: 'spoon-home-gib',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runningStatus = {
|
||||||
|
...stoppedStatus,
|
||||||
|
running: true,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Point the status endpoint at a running box so the Stop/Restart controls render.
|
||||||
|
const useRunningStatus = () => {
|
||||||
|
fetchSpy.mockImplementation((input: RequestInfo | URL) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
if (url.startsWith('/api/box/status')) {
|
||||||
|
return Promise.resolve(jsonResponse({ status: runningStatus }));
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/box/tree')) {
|
||||||
|
return Promise.resolve(jsonResponse({ tree }));
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/box/lifecycle')) {
|
||||||
|
return Promise.resolve(jsonResponse({ status: stoppedStatus }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({}));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const lifecycleCalls = () =>
|
||||||
|
fetchSpy.mock.calls.filter(([input]) => {
|
||||||
|
const url = typeof input === 'string' ? input : '';
|
||||||
|
return url.startsWith('/api/box/lifecycle');
|
||||||
|
});
|
||||||
|
|
||||||
const tree = {
|
const tree = {
|
||||||
name: '',
|
name: '',
|
||||||
path: '',
|
path: '',
|
||||||
@@ -127,4 +161,51 @@ describe('MachineShell', () => {
|
|||||||
render(<MachineShell />);
|
render(<MachineShell />);
|
||||||
expect(await screen.findByText('terminal')).toBeInTheDocument();
|
expect(await screen.findByText('terminal')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not stop the box until the confirmation is accepted', async () => {
|
||||||
|
useRunningStatus();
|
||||||
|
render(<MachineShell />);
|
||||||
|
const stopButton = await screen.findByRole('button', { name: 'Stop' });
|
||||||
|
fireEvent.click(stopButton);
|
||||||
|
|
||||||
|
// The confirmation dialog intervenes: no lifecycle POST fires on the click.
|
||||||
|
const confirm = await screen.findByRole('button', { name: 'Stop machine' });
|
||||||
|
expect(lifecycleCalls()).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
'/api/box/lifecycle',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'stop' }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restart the box until the confirmation is accepted', async () => {
|
||||||
|
useRunningStatus();
|
||||||
|
render(<MachineShell />);
|
||||||
|
const restartButton = await screen.findByRole('button', {
|
||||||
|
name: 'Restart',
|
||||||
|
});
|
||||||
|
fireEvent.click(restartButton);
|
||||||
|
|
||||||
|
const confirm = await screen.findByRole('button', {
|
||||||
|
name: 'Restart machine',
|
||||||
|
});
|
||||||
|
expect(lifecycleCalls()).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
'/api/box/lifecycle',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'restart' }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user