Merge branch 'feature/drag-drop-upload' into 'main'

Add drag-and-drop upload component with chunked uploads and offline support

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!23
This commit is contained in:
Mondo Diaz
2026-01-08 11:59:33 -06:00
12 changed files with 6631 additions and 46 deletions

View File

@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Added reusable `DragDropUpload` component for artifact uploads (#8)
- Drag-and-drop file selection with visual feedback
- Click-to-browse fallback
- Multiple file upload support with queue management
- Real-time progress indicators with speed and ETA
- File type and size validation (configurable)
- Concurrent upload handling (configurable max concurrent)
- Automatic retry with exponential backoff for network errors
- Individual file status (pending, uploading, complete, failed)
- Retry and remove actions per file
- Auto-dismiss success messages after 5 seconds
- Integrated DragDropUpload into PackagePage replacing basic file input (#8)
- Added frontend testing infrastructure with Vitest and React Testing Library (#14)
- Configured Vitest for React/TypeScript with jsdom
- Added 24 unit tests for DragDropUpload component
- Tests cover: rendering, drag-drop events, file validation, upload queue, progress, errors
- Added chunked upload support for large files (#9)
- Files >100MB automatically use chunked upload API (10MB chunks)
- Client-side SHA256 hash computation via Web Crypto API
- localStorage persistence for resume after browser close
- Deduplication check at upload init phase
- Added offline detection and network resilience (#12)
- Automatic pause when browser goes offline
- Auto-resume when connection restored
- Offline banner UI with status message
- XHR abort on network loss to prevent hung requests
- Added download by artifact ID feature (#10)
- Direct artifact ID input field on package page
- Hex-only input validation with character count
- File size and filename displayed in tag list
- Added backend security tests (#15)
- Path traversal prevention tests for upload/download
- Malformed request handling tests
- Checksum validation tests
- 10 new security-focused integration tests
- Added download verification with `verify` and `verify_mode` query parameters (#26)
- `?verify=true&verify_mode=pre` - Pre-verification: verify before streaming (guaranteed no corrupt data)
- `?verify=true&verify_mode=stream` - Streaming verification: verify while streaming (logs error if mismatch)

View File

@@ -500,3 +500,210 @@ class TestS3StorageVerification:
artifact = response.json()
assert artifact["id"] == expected_hash
assert artifact["ref_count"] == 3
class TestSecurityPathTraversal:
"""Tests for path traversal attack prevention.
Note: Orchard uses content-addressable storage where files are stored by
SHA256 hash, not filename. Filenames are metadata only and never used in
file path construction, so path traversal in filenames is not a security
vulnerability. These tests verify the system handles unusual inputs safely.
"""
@pytest.mark.integration
def test_path_traversal_in_filename_stored_safely(
self, integration_client, test_package
):
"""Test filenames with path traversal are stored safely (as metadata only)."""
project, package = test_package
content = b"path traversal test content"
expected_hash = compute_sha256(content)
files = {
"file": (
"../../../etc/passwd",
io.BytesIO(content),
"application/octet-stream",
)
}
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
files=files,
data={"tag": "traversal-test"},
)
assert response.status_code == 200
result = response.json()
assert result["artifact_id"] == expected_hash
s3_objects = list_s3_objects_by_hash(expected_hash)
assert len(s3_objects) == 1
assert ".." not in s3_objects[0]
@pytest.mark.integration
def test_path_traversal_in_package_name(self, integration_client, test_project):
"""Test package names with path traversal sequences are rejected."""
response = integration_client.get(
f"/api/v1/project/{test_project}/packages/../../../etc/passwd"
)
assert response.status_code in [400, 404, 422]
@pytest.mark.integration
def test_path_traversal_in_tag_name(self, integration_client, test_package):
"""Test tag names with path traversal are handled safely."""
project, package = test_package
content = b"tag traversal test"
files = {"file": ("test.bin", io.BytesIO(content), "application/octet-stream")}
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
files=files,
data={"tag": "../../../etc/passwd"},
)
assert response.status_code in [200, 400, 422]
@pytest.mark.integration
def test_download_path_traversal_in_ref(self, integration_client, test_package):
"""Test download ref with path traversal is rejected."""
project, package = test_package
response = integration_client.get(
f"/api/v1/project/{project}/{package}/+/../../../etc/passwd"
)
assert response.status_code in [400, 404, 422]
@pytest.mark.integration
def test_path_traversal_in_package_name(self, integration_client, test_project):
"""Test package names with path traversal sequences are rejected."""
response = integration_client.get(
f"/api/v1/project/{test_project}/packages/../../../etc/passwd"
)
assert response.status_code in [400, 404, 422]
@pytest.mark.integration
def test_path_traversal_in_tag_name(self, integration_client, test_package):
"""Test tag names with path traversal are rejected or handled safely."""
project, package = test_package
content = b"tag traversal test"
files = {"file": ("test.bin", io.BytesIO(content), "application/octet-stream")}
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
files=files,
data={"tag": "../../../etc/passwd"},
)
assert response.status_code in [200, 400, 422]
@pytest.mark.integration
def test_download_path_traversal_in_ref(self, integration_client, test_package):
"""Test download ref with path traversal is rejected."""
project, package = test_package
response = integration_client.get(
f"/api/v1/project/{project}/{package}/+/../../../etc/passwd"
)
assert response.status_code in [400, 404, 422]
class TestSecurityMalformedRequests:
"""Tests for malformed request handling."""
@pytest.mark.integration
def test_upload_missing_file_field(self, integration_client, test_package):
"""Test upload without file field returns appropriate error."""
project, package = test_package
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
data={"tag": "no-file"},
)
assert response.status_code == 422
@pytest.mark.integration
def test_upload_null_bytes_in_filename(self, integration_client, test_package):
"""Test filename with null bytes is handled safely."""
project, package = test_package
content = b"null byte test"
files = {
"file": ("test\x00.bin", io.BytesIO(content), "application/octet-stream")
}
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
files=files,
)
assert response.status_code in [200, 400, 422]
@pytest.mark.integration
def test_upload_very_long_filename(self, integration_client, test_package):
"""Test very long filename is handled (truncated or rejected)."""
project, package = test_package
content = b"long filename test"
long_filename = "a" * 1000 + ".bin"
files = {
"file": (long_filename, io.BytesIO(content), "application/octet-stream")
}
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
files=files,
)
assert response.status_code in [200, 400, 413, 422]
@pytest.mark.integration
def test_upload_special_characters_in_filename(
self, integration_client, test_package
):
"""Test filenames with special characters are handled safely."""
project, package = test_package
content = b"special char test"
special_filenames = [
"test<script>.bin",
'test"quote.bin',
"test'apostrophe.bin",
"test;semicolon.bin",
"test|pipe.bin",
]
for filename in special_filenames:
files = {
"file": (filename, io.BytesIO(content), "application/octet-stream")
}
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
files=files,
)
assert response.status_code in [200, 400, 422], (
f"Unexpected status {response.status_code} for filename: {filename}"
)
@pytest.mark.integration
def test_invalid_checksum_header_format(self, integration_client, test_package):
"""Test invalid X-Checksum-SHA256 header format is rejected."""
project, package = test_package
content = b"checksum test"
files = {"file": ("test.bin", io.BytesIO(content), "application/octet-stream")}
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
files=files,
headers={"X-Checksum-SHA256": "not-a-valid-hash"},
)
assert response.status_code == 400
assert "Invalid" in response.json().get("detail", "")
@pytest.mark.integration
def test_checksum_mismatch_rejected(self, integration_client, test_package):
"""Test upload with wrong checksum is rejected."""
project, package = test_package
content = b"checksum mismatch test"
wrong_hash = "0" * 64
files = {"file": ("test.bin", io.BytesIO(content), "application/octet-stream")}
response = integration_client.post(
f"/api/v1/project/{project}/{package}/upload",
files=files,
headers={"X-Checksum-SHA256": wrong_hash},
)
assert response.status_code == 422
assert "verification failed" in response.json().get("detail", "").lower()

4451
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,18 +6,33 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3"
"react-router-dom": "6.28.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^24.0.0",
"typescript": "^5.3.3",
"vite": "^5.0.12"
"vite": "^5.0.12",
"vitest": "^1.3.1"
},
"overrides": {
"ws": "8.18.0",
"ufo": "1.5.4",
"rollup": "4.52.4",
"caniuse-lite": "1.0.30001692",
"baseline-browser-mapping": "2.9.5"
}
}

View File

@@ -0,0 +1,321 @@
.drag-drop-upload {
width: 100%;
}
/* Offline Banner */
.offline-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--warning-bg, #fff3cd);
border: 1px solid var(--warning-border, #ffc107);
border-radius: 8px;
margin-bottom: 1rem;
color: var(--warning-text, #856404);
font-size: 0.875rem;
}
.offline-banner svg {
flex-shrink: 0;
}
/* Drop Zone */
.drop-zone {
border: 2px dashed var(--border-color, #ddd);
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-secondary, #f9f9f9);
}
.drop-zone:hover {
border-color: var(--accent-color, #007bff);
background: var(--bg-hover, #f0f7ff);
}
.drop-zone--active {
border-color: var(--accent-color, #007bff);
background: var(--bg-active, #e6f0ff);
border-style: solid;
}
.drop-zone__input {
display: none;
}
.drop-zone__content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
color: var(--text-secondary, #666);
}
.drop-zone__content svg {
opacity: 0.5;
}
.drop-zone--active .drop-zone__content svg {
opacity: 1;
color: var(--accent-color, #007bff);
}
.drop-zone__text {
margin: 0;
font-size: 1rem;
}
.drop-zone__text strong {
color: var(--text-primary, #333);
}
.drop-zone__hint {
margin: 0;
font-size: 0.8rem;
opacity: 0.7;
}
/* Upload Queue */
.upload-queue {
margin-top: 1rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
overflow: hidden;
}
.upload-queue__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f9f9f9);
border-bottom: 1px solid var(--border-color, #ddd);
}
.upload-queue__title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.upload-queue__clear {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: none;
background: none;
color: var(--accent-color, #007bff);
cursor: pointer;
}
.upload-queue__clear:hover {
text-decoration: underline;
}
.upload-queue__overall {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary, #f9f9f9);
border-bottom: 1px solid var(--border-color, #ddd);
}
.upload-queue__overall .progress-bar {
flex: 1;
}
.upload-queue__overall .progress-bar__text {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #666);
min-width: 3rem;
text-align: right;
}
.upload-queue__list {
list-style: none;
margin: 0;
padding: 0;
max-height: 300px;
overflow-y: auto;
}
/* Upload Item */
.upload-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color-light, #eee);
}
.upload-item:last-child {
border-bottom: none;
}
.upload-item__icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary, #666);
}
.upload-item--complete .upload-item__icon {
color: var(--success-color, #28a745);
}
.upload-item--failed .upload-item__icon {
color: var(--error-color, #dc3545);
}
.upload-item--uploading .upload-item__icon {
color: var(--accent-color, #007bff);
}
.upload-item--paused .upload-item__icon {
color: var(--warning-color, #ffc107);
}
.upload-item--validating .upload-item__icon {
color: var(--accent-color, #007bff);
}
.spinner-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.upload-item__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.upload-item__name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-item__meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.upload-item__size {
color: var(--text-secondary, #666);
}
.upload-item__speed,
.upload-item__eta {
color: var(--accent-color, #007bff);
}
.upload-item__artifact {
color: var(--success-color, #28a745);
font-family: monospace;
}
.upload-item__error {
color: var(--error-color, #dc3545);
}
.upload-item__retry-count {
color: var(--warning-color, #ffc107);
}
.upload-item__validating {
color: var(--accent-color, #007bff);
font-style: italic;
}
.upload-item__actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.upload-item__btn {
width: 28px;
height: 28px;
border: none;
background: none;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary, #666);
transition: all 0.15s ease;
}
.upload-item__btn:hover {
background: var(--bg-hover, #f0f0f0);
}
.upload-item__btn--retry:hover {
color: var(--accent-color, #007bff);
}
.upload-item__btn--remove:hover {
color: var(--error-color, #dc3545);
}
/* Progress Bar */
.progress-bar {
height: 8px;
background: var(--border-color, #ddd);
border-radius: 4px;
overflow: hidden;
}
.progress-bar--small {
height: 4px;
margin-top: 0.25rem;
}
.progress-bar__fill {
height: 100%;
background: var(--accent-color, #007bff);
border-radius: 4px;
transition: width 0.2s ease;
}
.upload-item--complete .progress-bar__fill {
background: var(--success-color, #28a745);
}
/* Responsive */
@media (max-width: 480px) {
.drop-zone {
padding: 1.5rem 1rem;
}
.upload-item__meta {
flex-direction: column;
gap: 0.125rem;
}
.upload-item__speed,
.upload-item__eta {
display: none;
}
}

View File

@@ -0,0 +1,545 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DragDropUpload } from './DragDropUpload';
function createMockFile(name: string, size: number, type: string): File {
const content = new Array(size).fill('a').join('');
return new File([content], name, { type });
}
function createMockXHR(options: {
status?: number;
response?: object;
progressEvents?: { loaded: number; total: number }[];
shouldError?: boolean;
shouldTimeout?: boolean;
} = {}) {
const {
status = 200,
response = { artifact_id: 'abc123', size: 100 },
progressEvents = [],
shouldError = false,
shouldTimeout = false,
} = options;
return class MockXHR {
status = status;
responseText = JSON.stringify(response);
timeout = 0;
upload = {
addEventListener: vi.fn((event: string, handler: (e: ProgressEvent) => void) => {
if (event === 'progress') {
progressEvents.forEach((p, i) => {
setTimeout(() => {
handler({ lengthComputable: true, loaded: p.loaded, total: p.total } as ProgressEvent);
}, i * 10);
});
}
}),
};
addEventListener = vi.fn((event: string, handler: () => void) => {
if (event === 'load' && !shouldError && !shouldTimeout) {
setTimeout(handler, progressEvents.length * 10 + 10);
}
if (event === 'error' && shouldError) {
setTimeout(handler, 10);
}
if (event === 'timeout' && shouldTimeout) {
setTimeout(handler, 10);
}
});
open = vi.fn();
send = vi.fn();
};
}
describe('DragDropUpload', () => {
const defaultProps = {
projectName: 'test-project',
packageName: 'test-package',
};
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('Rendering', () => {
it('renders drop zone with instructional text', () => {
render(<DragDropUpload {...defaultProps} />);
expect(screen.getByText(/drag files here/i)).toBeInTheDocument();
expect(screen.getByText(/click to browse/i)).toBeInTheDocument();
});
it('renders hidden file input', () => {
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]');
expect(input).toBeInTheDocument();
expect(input).toHaveClass('drop-zone__input');
});
it('shows max file size hint when provided', () => {
render(<DragDropUpload {...defaultProps} maxFileSize={1024 * 1024} />);
expect(screen.getByText(/max file size: 1 mb/i)).toBeInTheDocument();
});
it('shows allowed types hint when provided', () => {
render(<DragDropUpload {...defaultProps} allowedTypes={['.zip', '.tar.gz']} allowAllTypes={false} />);
expect(screen.getByText(/\.zip, \.tar\.gz/i)).toBeInTheDocument();
});
});
describe('Click to Browse', () => {
it('opens file picker when drop zone is clicked', async () => {
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
const dropZone = screen.getByRole('button');
await userEvent.click(dropZone);
expect(clickSpy).toHaveBeenCalled();
});
it('opens file picker on Enter key', () => {
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
const dropZone = screen.getByRole('button');
fireEvent.keyDown(dropZone, { key: 'Enter' });
expect(clickSpy).toHaveBeenCalled();
});
});
describe('Drag and Drop Events', () => {
it('shows visual feedback on drag over', () => {
render(<DragDropUpload {...defaultProps} />);
const dropZone = screen.getByRole('button');
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [{}] },
});
expect(dropZone).toHaveClass('drop-zone--active');
});
it('removes visual feedback on drag leave', () => {
render(<DragDropUpload {...defaultProps} />);
const dropZone = screen.getByRole('button');
fireEvent.dragEnter(dropZone, { dataTransfer: { items: [{}] } });
expect(dropZone).toHaveClass('drop-zone--active');
fireEvent.dragLeave(dropZone);
expect(dropZone).not.toHaveClass('drop-zone--active');
});
it('accepts dropped files', async () => {
const MockXHR = createMockXHR();
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} />);
const dropZone = screen.getByRole('button');
const file = createMockFile('test.txt', 100, 'text/plain');
const dataTransfer = new DataTransfer();
Object.defineProperty(dataTransfer, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.drop(dropZone, { dataTransfer });
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
});
});
});
describe('File Validation', () => {
it('rejects files exceeding max size', async () => {
render(<DragDropUpload {...defaultProps} maxFileSize={100} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('large.txt', 200, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText(/exceeds.*limit/i)).toBeInTheDocument();
});
});
it('rejects files with invalid type when allowAllTypes is false', async () => {
render(<DragDropUpload {...defaultProps} allowedTypes={['.zip']} allowAllTypes={false} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText(/not allowed/i)).toBeInTheDocument();
});
});
it('rejects empty files', async () => {
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('empty.txt', 0, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText(/empty file/i)).toBeInTheDocument();
});
});
it('accepts valid files when allowAllTypes is true', async () => {
const MockXHR = createMockXHR();
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} allowAllTypes={true} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
expect(screen.queryByText(/not allowed/i)).not.toBeInTheDocument();
});
});
});
describe('Upload Queue', () => {
it('shows file in queue after selection', async () => {
const MockXHR = createMockXHR();
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('document.pdf', 1024, 'application/pdf');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText('document.pdf')).toBeInTheDocument();
expect(screen.getByText('1 KB')).toBeInTheDocument();
});
});
it('handles multiple files', async () => {
const MockXHR = createMockXHR();
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const files = [
createMockFile('file1.txt', 100, 'text/plain'),
createMockFile('file2.txt', 200, 'text/plain'),
createMockFile('file3.txt', 300, 'text/plain'),
];
Object.defineProperty(input, 'files', {
value: Object.assign(files, { item: (i: number) => files[i] }),
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText('file1.txt')).toBeInTheDocument();
expect(screen.getByText('file2.txt')).toBeInTheDocument();
expect(screen.getByText('file3.txt')).toBeInTheDocument();
});
});
it('shows overall progress for multiple files', async () => {
const MockXHR = createMockXHR();
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const files = [
createMockFile('file1.txt', 100, 'text/plain'),
createMockFile('file2.txt', 100, 'text/plain'),
];
Object.defineProperty(input, 'files', {
value: Object.assign(files, { item: (i: number) => files[i] }),
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText(/uploading.*of.*files/i)).toBeInTheDocument();
});
});
});
describe('Upload Progress', () => {
it('shows progress bar during upload', async () => {
const MockXHR = createMockXHR({
progressEvents: [
{ loaded: 50, total: 100 },
],
});
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await waitFor(() => {
const progressBar = document.querySelector('.progress-bar__fill');
expect(progressBar).toBeInTheDocument();
});
});
});
describe('Upload Completion', () => {
it('shows success state when upload completes', async () => {
const MockXHR = createMockXHR({
response: { artifact_id: 'abc123def456', size: 100 },
});
vi.stubGlobal('XMLHttpRequest', MockXHR);
const onComplete = vi.fn();
render(<DragDropUpload {...defaultProps} onUploadComplete={onComplete} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await vi.advanceTimersByTimeAsync(100);
await waitFor(() => {
expect(screen.getByText(/abc123def456/i)).toBeInTheDocument();
});
});
it('calls onUploadComplete callback with results', async () => {
const MockXHR = createMockXHR({
response: { artifact_id: 'test-artifact-id', size: 100 },
});
vi.stubGlobal('XMLHttpRequest', MockXHR);
const onComplete = vi.fn();
render(<DragDropUpload {...defaultProps} onUploadComplete={onComplete} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await vi.advanceTimersByTimeAsync(100);
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({ artifact_id: 'test-artifact-id' }),
]);
});
});
});
describe('Upload Errors', () => {
it('shows error state when upload fails after retries exhausted', async () => {
const MockXHR = createMockXHR({
status: 500,
response: { detail: 'Server error' },
shouldError: true,
});
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} maxRetries={0} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await vi.advanceTimersByTimeAsync(100);
await waitFor(() => {
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
it('calls onUploadError callback when retries exhausted', async () => {
const MockXHR = createMockXHR({ shouldError: true });
vi.stubGlobal('XMLHttpRequest', MockXHR);
const onError = vi.fn();
render(<DragDropUpload {...defaultProps} maxRetries={0} onUploadError={onError} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await vi.advanceTimersByTimeAsync(100);
await waitFor(() => {
expect(onError).toHaveBeenCalled();
});
});
});
describe('Queue Actions', () => {
it('removes item from queue when remove button clicked', async () => {
const MockXHR = createMockXHR();
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await vi.advanceTimersByTimeAsync(100);
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
});
const removeButton = screen.getByTitle('Remove');
fireEvent.click(removeButton);
await waitFor(() => {
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
});
});
it('clears completed items when clear button clicked', async () => {
const MockXHR = createMockXHR();
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await vi.advanceTimersByTimeAsync(100);
await waitFor(() => {
const clearButton = screen.queryByText(/clear finished/i);
if (clearButton) {
fireEvent.click(clearButton);
}
});
});
});
describe('Tag Support', () => {
it('includes tag in upload request', async () => {
let capturedFormData: FormData | null = null;
class MockXHR {
status = 200;
responseText = JSON.stringify({ artifact_id: 'abc123', size: 100 });
timeout = 0;
upload = { addEventListener: vi.fn() };
addEventListener = vi.fn((event: string, handler: () => void) => {
if (event === 'load') setTimeout(handler, 10);
});
open = vi.fn();
send = vi.fn((data: FormData) => {
capturedFormData = data;
});
}
vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} tag="v1.0.0" />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain');
Object.defineProperty(input, 'files', {
value: Object.assign([file], { item: (i: number) => [file][i] }),
});
fireEvent.change(input);
await vi.advanceTimersByTimeAsync(100);
await waitFor(() => {
expect(capturedFormData?.get('tag')).toBe('v1.0.0');
});
});
});
});

View File

@@ -0,0 +1,893 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import './DragDropUpload.css';
const CHUNK_SIZE = 10 * 1024 * 1024;
const CHUNKED_UPLOAD_THRESHOLD = 100 * 1024 * 1024;
const UPLOAD_STATE_PREFIX = 'orchard_upload_';
interface StoredUploadState {
uploadId: string;
fileHash: string;
filename: string;
fileSize: number;
completedParts: number[];
project: string;
package: string;
tag?: string;
createdAt: number;
}
function getUploadStateKey(project: string, pkg: string, fileHash: string): string {
return `${UPLOAD_STATE_PREFIX}${project}_${pkg}_${fileHash}`;
}
function saveUploadState(state: StoredUploadState): void {
try {
const key = getUploadStateKey(state.project, state.package, state.fileHash);
localStorage.setItem(key, JSON.stringify(state));
} catch {
// localStorage might be full or unavailable
}
}
function loadUploadState(project: string, pkg: string, fileHash: string): StoredUploadState | null {
try {
const key = getUploadStateKey(project, pkg, fileHash);
const stored = localStorage.getItem(key);
if (!stored) return null;
const state = JSON.parse(stored) as StoredUploadState;
const oneDay = 24 * 60 * 60 * 1000;
if (Date.now() - state.createdAt > oneDay) {
localStorage.removeItem(key);
return null;
}
return state;
} catch {
return null;
}
}
function clearUploadState(project: string, pkg: string, fileHash: string): void {
try {
const key = getUploadStateKey(project, pkg, fileHash);
localStorage.removeItem(key);
} catch {
// ignore
}
}
// Types
export type UploadStatus = 'pending' | 'uploading' | 'complete' | 'failed' | 'validating' | 'paused';
export interface UploadItem {
id: string;
file: File;
status: UploadStatus;
progress: number;
speed: number; // bytes per second
error?: string;
artifactId?: string;
retryCount: number;
startTime?: number;
}
export interface UploadResult {
artifact_id: string;
size: number;
deduplicated?: boolean;
}
export interface DragDropUploadProps {
projectName: string;
packageName: string;
onUploadComplete?: (results: UploadResult[]) => void;
onUploadError?: (error: string) => void;
allowedTypes?: string[]; // e.g., ['.tar.gz', '.zip', '.deb']
allowAllTypes?: boolean;
maxFileSize?: number; // in bytes
maxConcurrentUploads?: number;
maxRetries?: number;
tag?: string;
className?: string;
}
// Utility functions
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatSpeed(bytesPerSecond: number): string {
return `${formatBytes(bytesPerSecond)}/s`;
}
function formatTimeRemaining(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '--:--';
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function getFileExtension(filename: string): string {
const parts = filename.toLowerCase().split('.');
if (parts.length >= 3 && parts[parts.length - 2] === 'tar') {
return `.${parts.slice(-2).join('.')}`;
}
return parts.length > 1 ? `.${parts[parts.length - 1]}` : '';
}
async function computeSHA256(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Icons
function UploadIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
);
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function ErrorIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
}
function RetryIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
);
}
function RemoveIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}
function FileIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
}
function PauseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
);
}
function WifiOffIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55" />
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39" />
<path d="M10.71 5.05A16 16 0 0 1 22.58 9" />
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<line x1="12" y1="20" x2="12.01" y2="20" />
</svg>
);
}
function SpinnerIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="spinner-icon">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" />
</svg>
);
}
export function DragDropUpload({
projectName,
packageName,
onUploadComplete,
onUploadError,
allowedTypes,
allowAllTypes = true,
maxFileSize,
maxConcurrentUploads = 3,
maxRetries = 3,
tag,
className = '',
}: DragDropUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]);
const [isOnline, setIsOnline] = useState(navigator.onLine);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragCounterRef = useRef(0);
const activeUploadsRef = useRef(0);
const xhrMapRef = useRef<Map<string, XMLHttpRequest>>(new Map());
// Online/Offline detection
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
// Resume paused uploads
setUploadQueue(prev => prev.map(item =>
item.status === 'paused'
? { ...item, status: 'pending' as UploadStatus, error: undefined }
: item
));
};
const handleOffline = () => {
setIsOnline(false);
// Pause uploading items and cancel their XHR requests
setUploadQueue(prev => prev.map(item => {
if (item.status === 'uploading') {
// Abort the XHR request
const xhr = xhrMapRef.current.get(item.id);
if (xhr) {
xhr.abort();
xhrMapRef.current.delete(item.id);
}
return { ...item, status: 'paused' as UploadStatus, error: 'Network offline - will resume when connection is restored', progress: 0 };
}
if (item.status === 'pending') {
return { ...item, status: 'paused' as UploadStatus, error: 'Network offline - waiting for connection' };
}
return item;
}));
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Validate a single file
const validateFile = useCallback((file: File): string | null => {
// Check file size
if (maxFileSize && file.size > maxFileSize) {
return `File exceeds ${formatBytes(maxFileSize)} limit`;
}
// Check file type if not allowing all types
if (!allowAllTypes && allowedTypes && allowedTypes.length > 0) {
const ext = getFileExtension(file.name);
if (!allowedTypes.some(t => t.toLowerCase() === ext)) {
return `File type ${ext || 'unknown'} not allowed. Accepted: ${allowedTypes.join(', ')}`;
}
}
// Check for empty file
if (file.size === 0) {
return 'Cannot upload empty file';
}
return null;
}, [allowedTypes, allowAllTypes, maxFileSize]);
// Add files to queue
const addFiles = useCallback((files: FileList | File[]) => {
const newItems: UploadItem[] = Array.from(files).map(file => {
const validationError = validateFile(file);
return {
id: generateId(),
file,
status: validationError ? 'failed' : 'pending',
progress: 0,
speed: 0,
error: validationError || undefined,
retryCount: 0,
};
});
setUploadQueue(prev => [...prev, ...newItems]);
}, [validateFile]);
const uploadFileChunked = useCallback(async (item: UploadItem): Promise<UploadResult> => {
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, status: 'validating' as UploadStatus, startTime: Date.now() }
: u
));
const fileHash = await computeSHA256(item.file);
const storedState = loadUploadState(projectName, packageName, fileHash);
let uploadId: string;
let completedParts: number[] = [];
if (storedState && storedState.fileSize === item.file.size && storedState.filename === item.file.name) {
try {
const statusResponse = await fetch(
`/api/v1/project/${projectName}/${packageName}/upload/${storedState.uploadId}/status`
);
if (statusResponse.ok) {
const statusData = await statusResponse.json();
uploadId = storedState.uploadId;
completedParts = statusData.uploaded_parts || [];
} else {
throw new Error('Stored upload no longer valid');
}
} catch {
clearUploadState(projectName, packageName, fileHash);
uploadId = await initNewUpload();
}
} else {
uploadId = await initNewUpload();
}
async function initNewUpload(): Promise<string> {
const initResponse = await fetch(
`/api/v1/project/${projectName}/${packageName}/upload/init`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expected_hash: fileHash,
filename: item.file.name,
size: item.file.size,
tag: tag || undefined,
}),
}
);
if (!initResponse.ok) {
const error = await initResponse.json().catch(() => ({}));
throw new Error(error.detail || `Init failed: ${initResponse.status}`);
}
const initData = await initResponse.json();
if (initData.already_exists) {
throw { deduplicated: true, artifact_id: initData.artifact_id };
}
saveUploadState({
uploadId: initData.upload_id,
fileHash,
filename: item.file.name,
fileSize: item.file.size,
completedParts: [],
project: projectName,
package: packageName,
tag: tag || undefined,
createdAt: Date.now(),
});
return initData.upload_id;
}
const totalChunks = Math.ceil(item.file.size / CHUNK_SIZE);
let uploadedBytes = completedParts.length * CHUNK_SIZE;
if (uploadedBytes > item.file.size) uploadedBytes = item.file.size - (item.file.size % CHUNK_SIZE);
const startTime = Date.now();
for (let partNumber = 1; partNumber <= totalChunks; partNumber++) {
if (completedParts.includes(partNumber)) {
continue;
}
if (!isOnline) {
throw new Error('Network offline');
}
const start = (partNumber - 1) * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, item.file.size);
const chunk = item.file.slice(start, end);
const partResponse = await fetch(
`/api/v1/project/${projectName}/${packageName}/upload/${uploadId}/part/${partNumber}`,
{
method: 'PUT',
body: chunk,
}
);
if (!partResponse.ok) {
throw new Error(`Part ${partNumber} upload failed: ${partResponse.status}`);
}
completedParts.push(partNumber);
saveUploadState({
uploadId,
fileHash,
filename: item.file.name,
fileSize: item.file.size,
completedParts,
project: projectName,
package: packageName,
tag: tag || undefined,
createdAt: Date.now(),
});
uploadedBytes += chunk.size;
const elapsed = (Date.now() - startTime) / 1000;
const speed = elapsed > 0 ? uploadedBytes / elapsed : 0;
const progress = Math.round((uploadedBytes / item.file.size) * 100);
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, progress, speed, status: 'uploading' as UploadStatus }
: u
));
}
const completeResponse = await fetch(
`/api/v1/project/${projectName}/${packageName}/upload/${uploadId}/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag: tag || undefined }),
}
);
if (!completeResponse.ok) {
throw new Error(`Complete failed: ${completeResponse.status}`);
}
clearUploadState(projectName, packageName, fileHash);
const completeData = await completeResponse.json();
return {
artifact_id: completeData.artifact_id,
size: completeData.size,
deduplicated: false,
};
}, [projectName, packageName, tag, isOnline]);
const uploadFileSimple = useCallback((item: UploadItem): Promise<UploadResult> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhrMapRef.current.set(item.id, xhr);
const formData = new FormData();
formData.append('file', item.file);
if (tag) {
formData.append('tag', tag);
}
let lastLoaded = 0;
let lastTime = Date.now();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const now = Date.now();
const timeDiff = (now - lastTime) / 1000;
const loadedDiff = e.loaded - lastLoaded;
const speed = timeDiff > 0 ? loadedDiff / timeDiff : 0;
const progress = Math.round((e.loaded / e.total) * 100);
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, progress, speed, status: 'uploading' as UploadStatus }
: u
));
lastLoaded = e.loaded;
lastTime = now;
}
});
xhr.addEventListener('load', () => {
xhrMapRef.current.delete(item.id);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = JSON.parse(xhr.responseText) as UploadResult;
resolve(result);
} catch {
reject(new Error('Invalid response from server'));
}
} else {
try {
const error = JSON.parse(xhr.responseText);
reject(new Error(error.detail || `Upload failed: ${xhr.status}`));
} catch {
reject(new Error(`Upload failed: ${xhr.status}`));
}
}
});
xhr.addEventListener('error', () => {
xhrMapRef.current.delete(item.id);
reject(new Error('Network error - check your connection'));
});
xhr.addEventListener('timeout', () => {
xhrMapRef.current.delete(item.id);
reject(new Error('Upload timed out'));
});
xhr.addEventListener('abort', () => {
xhrMapRef.current.delete(item.id);
reject(new Error('Upload cancelled'));
});
xhr.open('POST', `/api/v1/project/${projectName}/${packageName}/upload`);
xhr.timeout = 300000;
xhr.send(formData);
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, status: 'uploading' as UploadStatus, startTime: Date.now() }
: u
));
});
}, [projectName, packageName, tag]);
const uploadFile = useCallback((item: UploadItem): Promise<UploadResult> => {
if (item.file.size >= CHUNKED_UPLOAD_THRESHOLD) {
return uploadFileChunked(item);
}
return uploadFileSimple(item);
}, [uploadFileChunked, uploadFileSimple]);
const processQueue = useCallback(async () => {
if (!isOnline) return;
const pendingItems = uploadQueue.filter(item => item.status === 'pending');
for (const item of pendingItems) {
if (activeUploadsRef.current >= maxConcurrentUploads) {
break;
}
activeUploadsRef.current++;
// Start upload
setUploadQueue(prev => prev.map(u =>
u.id === item.id ? { ...u, status: 'uploading' as UploadStatus } : u
));
try {
const result = await uploadFile(item);
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, status: 'complete' as UploadStatus, progress: 100, artifactId: result.artifact_id }
: u
));
} catch (err: unknown) {
const dedupErr = err as { deduplicated?: boolean; artifact_id?: string };
if (dedupErr.deduplicated && dedupErr.artifact_id) {
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, status: 'complete' as UploadStatus, progress: 100, artifactId: dedupErr.artifact_id }
: u
));
} else {
const errorMessage = err instanceof Error ? err.message : 'Upload failed';
const shouldRetry = item.retryCount < maxRetries &&
(errorMessage.includes('Network') || errorMessage.includes('timeout'));
if (shouldRetry) {
const delay = Math.pow(2, item.retryCount) * 1000;
setTimeout(() => {
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, status: 'pending' as UploadStatus, retryCount: u.retryCount + 1, progress: 0 }
: u
));
}, delay);
} else {
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, status: 'failed' as UploadStatus, error: errorMessage }
: u
));
onUploadError?.(errorMessage);
}
}
} finally {
activeUploadsRef.current--;
}
}
}, [uploadQueue, maxConcurrentUploads, maxRetries, uploadFile, onUploadError, isOnline]);
useEffect(() => {
const hasPending = uploadQueue.some(item => item.status === 'pending');
if (hasPending && activeUploadsRef.current < maxConcurrentUploads && isOnline) {
processQueue();
}
const allComplete = uploadQueue.length > 0 &&
uploadQueue.every(item => item.status === 'complete' || item.status === 'failed');
if (allComplete) {
const completedResults = uploadQueue
.filter(item => item.status === 'complete' && item.artifactId)
.map(item => ({
artifact_id: item.artifactId!,
size: item.file.size,
}));
if (completedResults.length > 0) {
onUploadComplete?.(completedResults);
}
}
}, [uploadQueue, maxConcurrentUploads, processQueue, onUploadComplete, isOnline]);
// Drag event handlers
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
dragCounterRef.current = 0;
const files = e.dataTransfer.files;
if (files && files.length > 0) {
addFiles(files);
}
}, [addFiles]);
// Click to browse
const handleClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
addFiles(files);
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [addFiles]);
// Remove item from queue
const removeItem = useCallback((id: string) => {
setUploadQueue(prev => prev.filter(item => item.id !== id));
}, []);
// Retry failed upload
const retryItem = useCallback((id: string) => {
setUploadQueue(prev => prev.map(item =>
item.id === id
? { ...item, status: 'pending' as UploadStatus, error: undefined, progress: 0, retryCount: 0 }
: item
));
}, []);
// Clear completed/failed items
const clearCompleted = useCallback(() => {
setUploadQueue(prev => prev.filter(item =>
item.status !== 'complete' && item.status !== 'failed'
));
}, []);
// Calculate overall progress
const overallProgress = uploadQueue.length > 0
? Math.round(uploadQueue.reduce((sum, item) => sum + item.progress, 0) / uploadQueue.length)
: 0;
const completedCount = uploadQueue.filter(item => item.status === 'complete').length;
const failedCount = uploadQueue.filter(item => item.status === 'failed').length;
const uploadingCount = uploadQueue.filter(item => item.status === 'uploading').length;
const pausedCount = uploadQueue.filter(item => item.status === 'paused').length;
return (
<div className={`drag-drop-upload ${className}`}>
{!isOnline && (
<div className="offline-banner">
<WifiOffIcon />
<span>You're offline. Uploads will resume when connection is restored.</span>
</div>
)}
<div
className={`drop-zone ${isDragOver ? 'drop-zone--active' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
className="drop-zone__input"
accept={!allowAllTypes && allowedTypes ? allowedTypes.join(',') : undefined}
/>
<div className="drop-zone__content">
<UploadIcon />
<p className="drop-zone__text">
<strong>Drag files here</strong> or click to browse
</p>
<p className="drop-zone__hint">
{maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`}
{!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`}
</p>
</div>
</div>
{/* Upload Queue */}
{uploadQueue.length > 0 && (
<div className="upload-queue">
<div className="upload-queue__header">
<span className="upload-queue__title">
{pausedCount > 0 && !isOnline
? `${pausedCount} uploads paused (offline)`
: uploadingCount > 0
? `Uploading ${uploadingCount} of ${uploadQueue.length} files`
: `${completedCount} of ${uploadQueue.length} files uploaded`
}
{failedCount > 0 && ` (${failedCount} failed)`}
</span>
{(completedCount > 0 || failedCount > 0) && (
<button
className="upload-queue__clear"
onClick={clearCompleted}
type="button"
>
Clear finished
</button>
)}
</div>
{/* Overall progress bar */}
{uploadingCount > 0 && (
<div className="upload-queue__overall">
<div className="progress-bar">
<div
className="progress-bar__fill"
style={{ width: `${overallProgress}%` }}
/>
</div>
<span className="progress-bar__text">{overallProgress}%</span>
</div>
)}
{/* Individual file items */}
<ul className="upload-queue__list">
{uploadQueue.map(item => (
<li key={item.id} className={`upload-item upload-item--${item.status}`}>
<div className="upload-item__icon">
{item.status === 'complete' ? <CheckIcon /> :
item.status === 'failed' ? <ErrorIcon /> :
item.status === 'paused' ? <PauseIcon /> :
item.status === 'validating' ? <SpinnerIcon /> :
<FileIcon />}
</div>
<div className="upload-item__info">
<div className="upload-item__name" title={item.file.name}>
{item.file.name}
</div>
<div className="upload-item__meta">
<span className="upload-item__size">{formatBytes(item.file.size)}</span>
{item.status === 'uploading' && item.speed > 0 && (
<>
<span className="upload-item__speed">{formatSpeed(item.speed)}</span>
{item.startTime && (
<span className="upload-item__eta">
{formatTimeRemaining(
(item.file.size - (item.file.size * item.progress / 100)) / item.speed
)} remaining
</span>
)}
</>
)}
{item.status === 'complete' && item.artifactId && (
<span className="upload-item__artifact">
ID: {item.artifactId.substring(0, 12)}...
</span>
)}
{item.error && (
<span className="upload-item__error">{item.error}</span>
)}
{item.retryCount > 0 && item.status === 'uploading' && (
<span className="upload-item__retry-count">Retry {item.retryCount}</span>
)}
{item.status === 'validating' && (
<span className="upload-item__validating">Computing hash...</span>
)}
</div>
{item.status === 'uploading' && (
<div className="progress-bar progress-bar--small">
<div
className="progress-bar__fill"
style={{ width: `${item.progress}%` }}
/>
</div>
)}
</div>
<div className="upload-item__actions">
{(item.status === 'failed' || (item.status === 'paused' && isOnline)) && (
<button
className="upload-item__btn upload-item__btn--retry"
onClick={() => retryItem(item.id)}
title="Retry upload"
type="button"
>
<RetryIcon />
</button>
)}
{(item.status === 'complete' || item.status === 'failed' || item.status === 'pending' || item.status === 'paused') && (
<button
className="upload-item__btn upload-item__btn--remove"
onClick={() => removeItem(item.id)}
title="Remove"
type="button"
>
<RemoveIcon />
</button>
)}
</div>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -10,3 +10,5 @@ export { FilterChip, FilterChipGroup } from './FilterChip';
export { DataTable } from './DataTable';
export { Pagination } from './Pagination';
export { GlobalSearch } from './GlobalSearch';
export { DragDropUpload } from './DragDropUpload';
export type { DragDropUploadProps, UploadItem, UploadResult, UploadStatus } from './DragDropUpload';

View File

@@ -127,6 +127,58 @@ h2 {
font-size: 0.75rem;
}
/* Download by Artifact ID Section */
.download-by-id-section {
margin-top: 32px;
background: var(--bg-secondary);
}
.download-by-id-section h3 {
margin-bottom: 12px;
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
}
.download-by-id-form {
display: flex;
gap: 12px;
align-items: center;
}
.artifact-id-input {
flex: 1;
padding: 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: var(--text-primary);
}
.artifact-id-input::placeholder {
color: var(--text-muted);
}
.artifact-id-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.btn-disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.validation-hint {
margin-top: 8px;
margin-bottom: 0;
font-size: 0.75rem;
color: var(--warning-color, #f59e0b);
}
/* Usage Section */
.usage-section {
margin-top: 32px;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { TagDetail, Package, PaginatedResponse } from '../types';
import { listTags, uploadArtifact, getDownloadUrl, getPackage } from '../api';
import { listTags, getDownloadUrl, getPackage } from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
@@ -9,6 +9,7 @@ import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { DataTable } from '../components/DataTable';
import { Pagination } from '../components/Pagination';
import { DragDropUpload, UploadResult } from '../components/DragDropUpload';
import './Home.css';
import './PackagePage.css';
@@ -61,10 +62,9 @@ function PackagePage() {
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<string | null>(null);
const [tag, setTag] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadTag, setUploadTag] = useState('');
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [artifactIdInput, setArtifactIdInput] = useState('');
// Get params from URL
const page = parseInt(searchParams.get('page') || '1', 10);
@@ -122,30 +122,22 @@ function PackagePage() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [navigate, projectName]);
async function handleUpload(e: React.FormEvent) {
e.preventDefault();
const file = fileInputRef.current?.files?.[0];
if (!file) {
setError('Please select a file');
return;
}
const handleUploadComplete = useCallback((results: UploadResult[]) => {
const count = results.length;
const message = count === 1
? `Uploaded successfully! Artifact ID: ${results[0].artifact_id}`
: `${count} files uploaded successfully!`;
setUploadSuccess(message);
setUploadTag('');
loadData();
// Auto-dismiss success message after 5 seconds
setTimeout(() => setUploadSuccess(null), 5000);
}, [loadData]);
try {
setUploading(true);
setError(null);
const result = await uploadArtifact(projectName!, packageName!, file, tag || undefined);
setUploadResult(`Uploaded successfully! Artifact ID: ${result.artifact_id}`);
setTag('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
}
}
const handleUploadError = useCallback((errorMsg: string) => {
setError(errorMsg);
}, []);
const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' });
@@ -292,29 +284,29 @@ function PackagePage() {
</div>
{error && <div className="error-message">{error}</div>}
{uploadResult && <div className="success-message">{uploadResult}</div>}
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
<div className="upload-section card">
<h3>Upload Artifact</h3>
<form onSubmit={handleUpload} className="upload-form">
<div className="upload-form">
<div className="form-group">
<label htmlFor="file">File</label>
<input id="file" type="file" ref={fileInputRef} required />
</div>
<div className="form-group">
<label htmlFor="tag">Tag (optional)</label>
<label htmlFor="upload-tag">Tag (optional)</label>
<input
id="tag"
id="upload-tag"
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
value={uploadTag}
onChange={(e) => setUploadTag(e.target.value)}
placeholder="v1.0.0, latest, stable..."
/>
</div>
<button type="submit" className="btn btn-primary" disabled={uploading}>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</form>
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
tag={uploadTag || undefined}
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
</div>
</div>
<div className="section-header">
@@ -367,6 +359,34 @@ function PackagePage() {
/>
)}
<div className="download-by-id-section card">
<h3>Download by Artifact ID</h3>
<div className="download-by-id-form">
<input
type="text"
value={artifactIdInput}
onChange={(e) => setArtifactIdInput(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
placeholder="Enter SHA256 artifact ID (64 hex characters)"
className="artifact-id-input"
/>
<a
href={artifactIdInput.length === 64 ? getDownloadUrl(projectName!, packageName!, `artifact:${artifactIdInput}`) : '#'}
className={`btn btn-primary ${artifactIdInput.length !== 64 ? 'btn-disabled' : ''}`}
download
onClick={(e) => {
if (artifactIdInput.length !== 64) {
e.preventDefault();
}
}}
>
Download
</a>
</div>
{artifactIdInput.length > 0 && artifactIdInput.length !== 64 && (
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({artifactIdInput.length}/64)</p>
)}
</div>
<div className="usage-section card">
<h3>Usage</h3>
<p>Download artifacts using:</p>

View File

@@ -0,0 +1,37 @@
import '@testing-library/jest-dom';
class MockDataTransfer implements DataTransfer {
dropEffect: DataTransfer['dropEffect'] = 'none';
effectAllowed: DataTransfer['effectAllowed'] = 'all';
files: FileList = Object.assign([], { item: (i: number) => this.files[i] || null });
items: DataTransferItemList = Object.assign([], {
add: () => null,
remove: () => {},
clear: () => {},
item: () => null,
}) as unknown as DataTransferItemList;
types: readonly string[] = [];
clearData(): void {}
getData(): string { return ''; }
setData(): void {}
setDragImage(): void {}
}
Object.defineProperty(globalThis, 'DataTransfer', {
value: MockDataTransfer,
});
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});

View File

@@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
@@ -9,5 +10,11 @@ export default defineConfig({
'/health': 'http://localhost:8080',
'/project': 'http://localhost:8080',
}
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
}
})