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
2 changes: 2 additions & 0 deletions src/features/status-bar/components/StatusBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { helpMenuItems, helpMenuUrls } from 'features/status-bar/constants';
import { HelpMenuItemsLabel } from 'features/status-bar/components/help-menu-items';
import { PLOT_SCRIPTS, VIEW_MAP_RESULTS } from 'features/plots/constants';
import JobInfoModal from 'features/jobs/components/Jobs/JobInfoModal';
import DownloadManager from 'features/upload-download/components/DownloadManager';

const StatusBar = () => {
return (
Expand All @@ -23,6 +24,7 @@ const StatusBar = () => {
</div>
<div id="cea-status-bar-right">
<JobStatusBar />
<DownloadManager />
<div className="cea-status-bar-button primary">
<DropdownMenu />
</div>
Expand Down
182 changes: 47 additions & 135 deletions src/features/upload-download/components/DownloadForm.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Button, Checkbox, Form, message, Progress } from 'antd';
import { Button, Checkbox, Form, message } from 'antd';
import { useProjectStore } from 'features/project/stores/projectStore';
import { useState } from 'react';
import { CloudDownloadIcon } from 'assets/icons';
import useDownloadStore from '../stores/downloadStore';

const DownloadForm = () => {
return (
Expand All @@ -10,7 +11,10 @@ const DownloadForm = () => {
<h1 style={{ display: 'flex', gap: 12 }}>
<CloudDownloadIcon style={{ fontSize: 36 }} /> Download Scenario(s)
</h1>
<p>Select one or more Scenarios to download.</p>
<p>
Select one or more Scenarios to download. Downloads will be prepared
in the background and you can track their progress in the status bar.
</p>
</div>

<FormContent />
Expand Down Expand Up @@ -91,146 +95,57 @@ const validateFileSelection = ({ getFieldValue }) => ({

const FormContent = () => {
const [form] = Form.useForm();

const [isSubmitting, setIsSubmitting] = useState(false);
const currentProject = useProjectStore((state) => state.project);

const [downloadStatus, setDownloadStatus] = useState({
status: null,
percent: null,
});
const [downloadSize, setDownloadSize] = useState({
loaded: null,
total: null,
});

const resetDownloadState = () => {
setDownloadStatus({ status: null, percent: null });
setDownloadSize({ loaded: null, total: null });
};

const disableForm = downloadStatus.status !== null;
const prepareDownload = useDownloadStore((state) => state.prepareDownload);

const onFinish = async (values) => {
if (!values.scenarios.length) return;
setDownloadStatus({ status: 'preparing', percent: null });

// TODO: Cancel on unmount
const xhr = new XMLHttpRequest();
xhr.responseType = 'blob';
xhr.onprogress = (event) => {
if (event.lengthComputable) {
setDownloadSize({
loaded: (event.loaded / 1024 / 1024).toFixed(2),
total: (event.total / 1024 / 1024).toFixed(2),
});
const percentComplete = Math.round((event.loaded / event.total) * 100);
// Update UI with download progress
setDownloadStatus({ status: 'downloading', percent: percentComplete });
}
};
xhr.onload = () => {
if (xhr.status === 200) {
// Extract filename from Content-Disposition header if available
const contentDisposition = xhr.getResponseHeader('Content-Disposition');
let filename = 'scenarios.zip';
if (contentDisposition) {
const match = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition,
);
if (match && match[1]) {
filename = match[1].replace(/['"]/g, '');
}
}

// Create a download from the response without keeping the whole blob in memory
const url = window.URL.createObjectURL(xhr.response);
const a = document.createElement('a');
a.href = url;
a.download = filename;

// Use click() directly instead of appending to document
a.style.display = 'none';
document.body.appendChild(a);
a.click();
if (!currentProject) {
message.error('No project selected');
return;
}

// Clean up to free memory
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
} else {
// Handle download errors
console.error('Download failed with status:', xhr.status);
try {
// Try to parse error response
const errorBlob = xhr.response;
const reader = new FileReader();
reader.onload = () => {
try {
const errorJson = JSON.parse(reader.result);
message.error(
`Download failed: ${errorJson.detail || errorJson.message || 'Unknown error'}`,
);
} catch (e) {
message.error(`Download failed with status: ${xhr.status}`);
}
};
reader.readAsText(errorBlob);
} catch (e) {
message.error(`Download failed with status: ${xhr.status}`);
setIsSubmitting(true);
try {
await prepareDownload(
currentProject,
values.scenarios,
values.inputFiles,
values.outputFiles,
);

message.success(
'Download preparation started! Track progress in the status bar.',
);
form.resetFields();
} catch (error) {
console.error('Failed to start download:', error);
const detail = error.response?.data?.detail;

let errorMessage = 'Failed to start download';
if (typeof detail === 'string') {
errorMessage = detail;
} else if (detail?.message) {
errorMessage = detail.message;
// Add suggestion if available for better user guidance
if (detail.suggestion) {
errorMessage += `. ${detail.suggestion}`;
}
}
resetDownloadState();
};

// Add credentials for consistent auth
xhr.withCredentials = true;

// Add error handler
xhr.onerror = () => {
message.error('Network error occurred during download');
resetDownloadState();
};
xhr.open(
'POST',
`${import.meta.env.VITE_CEA_URL}/api/contents/scenario/download`,
true,
);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(
JSON.stringify({
project: currentProject,
scenarios: values.scenarios,
input_files: values.inputFiles,
output_files: values.outputFiles,
}),
);
message.error(errorMessage);
} finally {
setIsSubmitting(false);
}
};

return (
<Form form={form} onFinish={onFinish}>
<Button type="primary" htmlType="submit" loading={disableForm}>
Download
<Button type="primary" htmlType="submit" loading={isSubmitting}>
Prepare Download
</Button>

{downloadStatus.status === 'preparing' && (
<div style={{ marginTop: 16 }}>Preparing download...</div>
)}

{downloadStatus.status === 'downloading' && (
<div style={{ marginTop: 16 }}>
<Progress percent={downloadStatus.percent} status="active" />

<div style={{ marginTop: 8 }}>
<span>Downloaded: </span>
<span>{downloadSize.loaded}</span>
<span>/</span>
<span>{downloadSize.total}</span>
<span>MB</span>
</div>
</div>
)}

<div style={{ display: 'flex', flexDirection: 'column', margin: 12 }}>
<div>
<h2>Download Options</h2>
Expand All @@ -248,7 +163,7 @@ const FormContent = () => {
dependencies={['outputFiles']}
rules={[validateFileSelection]}
>
<Checkbox disabled={disableForm}>
<Checkbox disabled={isSubmitting}>
<div>
Database, Building geometries, properties, streets, terrain &
weather
Expand All @@ -265,7 +180,7 @@ const FormContent = () => {
rules={[validateFileSelection]}
>
<Checkbox.Group
disabled={disableForm}
disabled={isSubmitting}
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
>
<Checkbox value="summary">
Expand All @@ -280,10 +195,7 @@ const FormContent = () => {
<Checkbox value="detailed">
<div>
Output Data -{' '}
<small>
Includes all output data in CSV format{' '}
<i>(would take longer to download)</i>
</small>
<small>Includes all output data in CSV format</small>
</div>
</Checkbox>
<Checkbox value="export">
Expand Down Expand Up @@ -316,7 +228,7 @@ const FormContent = () => {
},
]}
>
<ScenarioCheckboxes disabled={disableForm} />
<ScenarioCheckboxes disabled={isSubmitting} />
</Form.Item>
</div>
</div>
Expand Down
Loading