Skip to content
Open
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
51 changes: 50 additions & 1 deletion apps/dashboard/src/components/boxes/BoxDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
setSearchParams: vi.fn(),
mutateAsync: vi.fn(),
uploadMutateAsync: vi.fn(),
terminalCwd: null as string | null,
}))

vi.mock('react-router-dom', () => ({
Expand Down Expand Up @@ -106,8 +108,29 @@ vi.mock('@/hooks/mutations/useDeleteBoxMutation', () => ({
useDeleteBoxMutation: () => ({ isPending: false, mutateAsync: mocks.mutateAsync }),
}))

vi.mock('@/hooks/mutations/useUploadBoxFilesMutation', () => ({
useUploadBoxFilesMutation: () => ({ isPending: false, mutateAsync: mocks.uploadMutateAsync }),
}))

vi.mock('./BoxTerminalFrame', () => ({
BoxTerminalFrame: ({ sessionUrl }: { sessionUrl: string }) => <div data-testid="terminal-frame">{sessionUrl}</div>,
BoxTerminalFrame: ({
onCurrentDirChange,
sessionUrl,
}: {
onCurrentDirChange?: (path: string) => void
sessionUrl: string
}) => (
<div data-testid="terminal-frame">
{sessionUrl}
<button
type="button"
data-testid="set-terminal-cwd"
onClick={() => onCurrentDirChange?.(mocks.terminalCwd ?? '/')}
>
set cwd
</button>
</div>
),
}))

function makeRunningBox(): Box {
Expand Down Expand Up @@ -140,6 +163,7 @@ describe('BoxDetails refresh', () => {

beforeEach(() => {
mocks.box = makeRunningBox()
mocks.terminalCwd = null
vi.clearAllMocks()
})

Expand Down Expand Up @@ -183,4 +207,29 @@ describe('BoxDetails refresh', () => {
expect(mocks.terminalRefetch).toHaveBeenCalledTimes(1)
expect(document.querySelector('[data-testid="terminal-frame"]')).toBe(frameBeforeRefresh)
})

it.each([
['/tmp', '/tmp'],
['/etc/nginx', '/etc'],
['//etc//nginx', '/etc'],
['/root/.ssh', '/root/.ssh'],
['/root//.ssh/authorized_keys', '/root/.ssh'],
['/home/app/../../etc', '/etc'],
['/proc/self', '/proc'],
])('disables uploads when the terminal cwd is blocked: %s', async (cwd, blockedPath) => {
mocks.terminalCwd = cwd
await renderBoxDetails()

await act(async () => {
document.querySelector<HTMLButtonElement>('[data-testid="set-terminal-cwd"]')?.click()
})
await flushReactWork()

const uploadButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((button) =>
button.textContent?.includes('Upload Files'),
)
expect(uploadButton).toBeTruthy()
expect(uploadButton?.disabled).toBe(true)
expect(uploadButton?.title).toContain(blockedPath)
})
})
207 changes: 204 additions & 3 deletions apps/dashboard/src/components/boxes/BoxDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import { useDeleteBoxMutation } from '@/hooks/mutations/useDeleteBoxMutation'
import { useRecoverBoxMutation } from '@/hooks/mutations/useRecoverBoxMutation'
import { useStartBoxMutation } from '@/hooks/mutations/useStartBoxMutation'
import { useStopBoxMutation } from '@/hooks/mutations/useStopBoxMutation'
import { useUploadBoxFilesMutation } from '@/hooks/mutations/useUploadBoxFilesMutation'
import { useBoxQuery } from '@/hooks/queries/useBoxQuery'
import { useConfig } from '@/hooks/useConfig'
import { useRegions } from '@/hooks/useRegions'
import { useBoxWsSync } from '@/hooks/useBoxWsSync'
import { useSelectedOrganization } from '@/hooks/useSelectedOrganization'
import { DEFAULT_BOX_UPLOAD_DIR, getBoxUploadDestinationBlockedReason, type BoxUploadItem } from '@/lib/box-upload'
import { getBoxPublicId, getBoxPublicIdLabel } from '@/lib/box-identity'
import { handleApiError } from '@/lib/error-handling'
import { setLocalStorageItem } from '@/lib/local-storage'
Expand All @@ -43,11 +45,14 @@ import { isRecoverable, isStartable, isStoppable, isTransitioning } from '@/lib/
import { Box, BoxState, OrganizationRolePermissionsEnum, OrganizationUserRoleEnum } from '@boxlite-ai/api-client'
import { isAxiosError } from 'axios'
import { Check, Container, Copy, MoreVertical, Pause, Play, RefreshCw, RotateCcw, Trash2 } from '@/components/ui/icon'
import { Icon as IconifyIcon } from '@iconify/react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { useAuth } from 'react-oidc-context'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { BoxFileUploadControl, buildDroppedUploadItems } from './BoxFileUploadControl'
import { BoxTerminalTab } from './BoxTerminalTab'
import { TERMINAL_FILE_DRAG_EVENT } from './terminalIframeSrc'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'

const STATUS = { running: '#5ad67d', idle: '#e0b341', stopped: '#8C919C', error: '#e0564a', dim: '#5b616e' } as const
Expand Down Expand Up @@ -120,6 +125,9 @@ export default function BoxDetails() {
const [copied, setCopied] = useState(false)
const [terminalRefreshSignal, setTerminalRefreshSignal] = useState(0)
const refreshRef = useRef<HTMLSpanElement>(null)
const shellDropRef = useRef<HTMLDivElement>(null)
const [isShellDragging, setIsShellDragging] = useState(false)
const [filesCurrentDir, setFilesCurrentDir] = useState<string | null>(null)

const updateOnboardingProgress = useCallback(
(progress: OnboardingProgress) => {
Expand Down Expand Up @@ -176,13 +184,35 @@ export default function BoxDetails() {
const stopMutation = useStopBoxMutation()
const recoverMutation = useRecoverBoxMutation()
const deleteMutation = useDeleteBoxMutation()
const uploadMutation = useUploadBoxFilesMutation()

const writePermitted = authenticatedUserHasPermission(OrganizationRolePermissionsEnum.WRITE_BOXES)
const deletePermitted = authenticatedUserHasPermission(OrganizationRolePermissionsEnum.DELETE_BOXES)
const transitioning = box ? isTransitioning(box) : false
const anyMutating =
startMutation.isPending || stopMutation.isPending || recoverMutation.isPending || deleteMutation.isPending
const actionsDisabled = anyMutating || transitioning
const hasFilesCurrentDir = filesCurrentDir !== null
const uploadDestinationDir = filesCurrentDir ?? DEFAULT_BOX_UPLOAD_DIR
const uploadDestinationBlockedReason = getBoxUploadDestinationBlockedReason(uploadDestinationDir)
const uploadDisabledReason = !writePermitted
? 'You need write access to upload files'
: actionsDisabled
? 'Wait for the current box action to finish'
: !box || !isStoppable(box)
? 'Start the box before uploading files'
: uploadDestinationBlockedReason
const canUpload = uploadDisabledReason === undefined
const canDragUpload = canUpload && hasFilesCurrentDir

useEffect(() => {
setFilesCurrentDir(null)
setIsShellDragging(false)
}, [box?.id, box?.state])

const handleCurrentDirChange = useCallback((path: string) => {
setFilesCurrentDir(path)
}, [])

const handleStart = async () => {
if (!box) return
Expand Down Expand Up @@ -235,6 +265,118 @@ export default function BoxDetails() {
}
}

const handleUploadFiles = async (items: BoxUploadItem[]) => {
if (!box || !canUpload || items.length === 0) return
try {
await uploadMutation.mutateAsync({
boxId: box.id,
detailRef: boxId,
destinationDir: uploadDestinationDir,
items,
Comment thread
law-chain-hot marked this conversation as resolved.
})
toast.success(items.length === 1 ? `Uploaded ${items[0].name}` : `Uploaded ${items.length} items`)
} catch (error) {
handleApiError(error, 'Failed to upload files')
}
}

const handleUploadInputError = (error: unknown) => {
handleApiError(error, 'Failed to prepare upload')
}

const isInsideShellDropTarget = useCallback((event: Pick<DragEvent, 'clientX' | 'clientY'>) => {
const rect = shellDropRef.current?.getBoundingClientRect()
return Boolean(
rect &&
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom,
)
}, [])

const handleDroppedDataTransfer = useCallback(
async (dataTransfer: DataTransfer) => {
try {
const items = await buildDroppedUploadItems(dataTransfer)
if (items.length === 0) {
throw new Error('No uploadable files found. Drop files or a non-empty folder into the shell.')
}
await handleUploadFiles(items)
} catch (error) {
handleUploadInputError(error)
}
},
[handleUploadFiles, handleUploadInputError],
)

useEffect(() => {
const resetShellDragging = () => setIsShellDragging(false)

const hasDraggedFiles = (dataTransfer: DataTransfer) => Array.from(dataTransfer.types ?? []).includes('Files')

const updateShellDragState = (event: DragEvent) => {
if (!canDragUpload || !event.dataTransfer || !hasDraggedFiles(event.dataTransfer)) {
resetShellDragging()
return
}
if (!isInsideShellDropTarget(event)) {
resetShellDragging()
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
setIsShellDragging(true)
}

const handleWindowDragLeave = (event: DragEvent) => {
if (
event.clientX <= 0 ||
event.clientY <= 0 ||
event.clientX >= window.innerWidth ||
event.clientY >= window.innerHeight
) {
resetShellDragging()
}
}

const handleWindowDrop = async (event: DragEvent) => {
if (!canDragUpload || !event.dataTransfer || !isInsideShellDropTarget(event)) {
resetShellDragging()
return
}
event.preventDefault()
resetShellDragging()
await handleDroppedDataTransfer(event.dataTransfer)
}

const handleTerminalFileDrag = (event: Event) => {
if (!canDragUpload) {
resetShellDragging()
return
}

const active = Boolean((event as CustomEvent<{ active?: boolean }>).detail?.active)
setIsShellDragging(active)
}

window.addEventListener('dragenter', updateShellDragState, { capture: true })
window.addEventListener('dragover', updateShellDragState, { capture: true })
window.addEventListener('drop', handleWindowDrop)
window.addEventListener('dragleave', handleWindowDragLeave)
window.addEventListener('dragend', resetShellDragging)
window.addEventListener(TERMINAL_FILE_DRAG_EVENT, handleTerminalFileDrag)

return () => {
window.removeEventListener('dragenter', updateShellDragState, { capture: true })
window.removeEventListener('dragover', updateShellDragState, { capture: true })
window.removeEventListener('drop', handleWindowDrop)
window.removeEventListener('dragleave', handleWindowDragLeave)
window.removeEventListener('dragend', resetShellDragging)
window.removeEventListener(TERMINAL_FILE_DRAG_EVENT, handleTerminalFileDrag)
}
}, [canDragUpload, handleDroppedDataTransfer, isInsideShellDropTarget])

const handleRefresh = () => {
refetch()
setTerminalRefreshSignal((signal) => signal + 1)
Expand Down Expand Up @@ -452,19 +594,78 @@ export default function BoxDetails() {
</div>

{/* shell / terminal */}
<div className="flex h-[60vh] flex-none flex-col border border-border bg-[hsl(var(--code-background))] lg:h-auto lg:min-h-0 lg:flex-1">
<div className="flex flex-none items-center justify-between border-b border-dashed border-border px-5 py-[15px]">
<div
ref={shellDropRef}
data-testid="box-shell-drop-target"
className={[
'relative flex h-[60vh] flex-none flex-col border bg-[hsl(var(--code-background))] transition-colors lg:h-auto lg:min-h-0 lg:flex-1',
isShellDragging ? 'border-brand bg-brand/10' : 'border-border',
].join(' ')}
>
<div className="flex flex-none flex-col gap-3 border-b border-dashed border-border px-5 py-[13px] sm:flex-row sm:items-center sm:justify-between">
<span className="flex items-center gap-[9px] text-[11px] uppercase tracking-[2px]">
<span className="size-[6px] flex-none bg-brand" />
shell
<span className="ml-0.5 tracking-[0.5px] text-muted-foreground normal-case">
{getBoxPublicIdLabel(box)}
</span>
</span>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
{hasFilesCurrentDir && (
<span
title={
uploadDestinationBlockedReason ??
`Drag files or folders into the shell area to upload to ${uploadDestinationDir}`
}
className="inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[11px] text-muted-foreground sm:mr-5"
>
<IconifyIcon icon="pixelarticons:drag-and-drop" className="size-4 flex-none text-foreground" />
<span className="flex-none">
{uploadDestinationBlockedReason ? 'Upload disabled for' : 'Drag files/folders to'}
</span>
<span className="min-w-0 max-w-[min(36vw,360px)] truncate font-medium text-foreground">
{uploadDestinationDir}
</span>
</span>
)}
<BoxFileUploadControl
disabled={!canUpload}
disabledReason={uploadDisabledReason}
destinationDir={uploadDestinationDir}
isUploading={uploadMutation.isPending}
onError={handleUploadInputError}
onUpload={handleUploadFiles}
/>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<BoxTerminalTab box={box} refreshSignal={terminalRefreshSignal} />
<BoxTerminalTab
box={box}
refreshSignal={terminalRefreshSignal}
onCurrentDirChange={handleCurrentDirChange}
/>
</div>
{isShellDragging && canDragUpload && (
<div
className="absolute inset-0 z-20 flex items-center justify-center border border-brand bg-background/75 text-[13px] text-foreground backdrop-blur-[1px] transition-colors"
onDragOver={(event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
setIsShellDragging(true)
}}
onDragLeave={(event) => {
if (!isInsideShellDropTarget(event.nativeEvent)) setIsShellDragging(false)
}}
onDrop={async (event) => {
event.preventDefault()
event.stopPropagation()
setIsShellDragging(false)
await handleDroppedDataTransfer(event.dataTransfer)
}}
>
{`Drop to upload into ${uploadDestinationDir}`}
</div>
)}
</div>
</div>
</>
Expand Down
Loading
Loading