quick fix
This commit is contained in:
@@ -177,6 +177,43 @@ This document contains the task list for the development of OxiCloud, a minimali
|
|||||||
- [x] Add content-aware compression by file format
|
- [x] Add content-aware compression by file format
|
||||||
- [ ] Implement dynamic thumbnail resizing based on viewport
|
- [ ] Implement dynamic thumbnail resizing based on viewport
|
||||||
|
|
||||||
|
### Bandwidth & Transfer Optimization
|
||||||
|
- [ ] **Sub-file chunked dedup (Restic/Borg style)**
|
||||||
|
- [ ] Implement Content-Defined Chunking (CDC) with FastCDC/Rabin rolling hash
|
||||||
|
- [ ] Variable-size chunks (target 1-4 MB) instead of whole-file blobs
|
||||||
|
- [ ] Per-chunk BLAKE3 hashing and dedup (saves storage + bandwidth on similar files)
|
||||||
|
- [ ] Chunk-level Zstd compression (better ratio than whole-file)
|
||||||
|
- [ ] Migrate existing whole-file blobs to chunked storage
|
||||||
|
- [ ] **Delta sync / rsync-style transfers**
|
||||||
|
- [ ] Implement rolling checksum algorithm for block-level diffing
|
||||||
|
- [ ] Client sends only changed blocks on re-upload (not the full file)
|
||||||
|
- [ ] Server-side block assembly from delta + existing chunks
|
||||||
|
- [ ] Huge savings for large files with small edits (VMs, databases, ISOs)
|
||||||
|
- [ ] **Resumable uploads & downloads (RFC 7233 / tus.io)**
|
||||||
|
- [ ] Server tracks partial upload state; client resumes from last byte on failure
|
||||||
|
- [ ] HTTP Range responses for download resume after network drops
|
||||||
|
- [ ] tus.io protocol support for cross-client compatibility
|
||||||
|
- [ ] **Client-side optimization before upload**
|
||||||
|
- [ ] Resize images to configurable max dimensions before upload (e.g. 4K cap)
|
||||||
|
- [ ] Re-encode videos to efficient codec (H.265/AV1) client-side before upload
|
||||||
|
- [ ] ⚡ **HIGH IMPACT / QUICK WIN** — Pre-compute BLAKE3 hash client-side (WASM); query server before upload; skip transfer entirely if blob already exists (instant dedup, zero bandwidth)
|
||||||
|
- [ ] **Server-side on-demand transcoding**
|
||||||
|
- [ ] Store originals; serve WebP/AVIF for images on request (saves download BW)
|
||||||
|
- [ ] Adaptive video streaming (HLS/DASH) from stored originals
|
||||||
|
- [ ] Lazy generation + cache of transcoded variants
|
||||||
|
- [ ] **Smart sync (placeholder/on-demand files)**
|
||||||
|
- [ ] Sync client downloads metadata only; fetch file content on first open
|
||||||
|
- [ ] Pin/unpin files for offline availability
|
||||||
|
- [ ] Automatic eviction of least-recently-used local copies
|
||||||
|
- [ ] **Transfer-level compression**
|
||||||
|
- [ ] Zstd streaming compression for HTTP responses (better than gzip for large files)
|
||||||
|
- [ ] Brotli for static assets; Zstd for dynamic/binary content
|
||||||
|
- [ ] Content-aware: skip compression for already-compressed formats (JPEG, ZIP, etc.)
|
||||||
|
- [ ] **Batched & multiplexed operations**
|
||||||
|
- [ ] Batch small file uploads into single request (tar-stream or multipart bundle)
|
||||||
|
- [ ] HTTP/2 multiplexing for parallel chunk transfers on single connection
|
||||||
|
- [ ] Server-side ZIP streaming for multi-file download (already partial)
|
||||||
|
|
||||||
## Infrastructure and Deployment
|
## Infrastructure and Deployment
|
||||||
|
|
||||||
- [x] Create Docker configuration
|
- [x] Create Docker configuration
|
||||||
|
|||||||
+32
@@ -158,3 +158,35 @@ OXICLOUD_WOPI_ENABLED=false
|
|||||||
|
|
||||||
# WOPI lock expiration in seconds (default: 1800 = 30 minutes)
|
# WOPI lock expiration in seconds (default: 1800 = 30 minutes)
|
||||||
#OXICLOUD_WOPI_LOCK_TTL_SECS=1800
|
#OXICLOUD_WOPI_LOCK_TTL_SECS=1800
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# MEMORY ALLOCATOR TUNING (IMPORTANT FOR RAM USAGE)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# OxiCloud uses mimalloc as its global memory allocator for performance.
|
||||||
|
# By default, mimalloc RETAINS freed memory in internal free-lists instead of
|
||||||
|
# returning it to the operating system. This causes the process RSS to grow
|
||||||
|
# over time (e.g., after large file uploads or password hashing) and never
|
||||||
|
# shrink back — even though the application has already freed that memory.
|
||||||
|
#
|
||||||
|
# In containerized / memory-constrained environments (Docker, K8s, VPS with
|
||||||
|
# limited RAM), this is critical: without these settings, the container can
|
||||||
|
# appear to "leak" hundreds of MiB that are actually just retained by the
|
||||||
|
# allocator.
|
||||||
|
#
|
||||||
|
# These variables are read directly by the mimalloc C library at startup.
|
||||||
|
# They are NOT OxiCloud-specific — they are part of mimalloc's official API.
|
||||||
|
# Docs: https://microsoft.github.io/mimalloc/environment.html
|
||||||
|
|
||||||
|
# MIMALLOC_PURGE_DELAY: Delay (in ms) before freed memory is returned to the OS.
|
||||||
|
# 0 = return immediately (RECOMMENDED for Docker / limited RAM)
|
||||||
|
# -1 = never return (maximum performance, highest RAM usage)
|
||||||
|
# 10 = mimalloc default (slight delay for reuse optimization)
|
||||||
|
# Setting this to 0 can reduce idle RAM by 80-120 MiB in typical deployments.
|
||||||
|
MIMALLOC_PURGE_DELAY=0
|
||||||
|
|
||||||
|
# MIMALLOC_ALLOW_LARGE_OS_PAGES: Use 2 MiB huge pages for allocations.
|
||||||
|
# 0 = disabled (RECOMMENDED for Docker — avoids RSS inflation from THP)
|
||||||
|
# 1 = enabled (better TLB performance on bare-metal servers with plenty of RAM)
|
||||||
|
# When enabled with Linux Transparent Huge Pages (THP), partially-used 2 MiB
|
||||||
|
# pages inflate the reported RSS by up to 20-30 MiB.
|
||||||
|
MIMALLOC_ALLOW_LARGE_OS_PAGES=0
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -158,15 +158,36 @@ impl DedupHandler {
|
|||||||
.unwrap_or("application/octet-stream")
|
.unwrap_or("application/octet-stream")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Collect all chunks
|
// Collect all chunks — explicit match to detect client disconnection
|
||||||
let mut chunks: Vec<Bytes> = Vec::new();
|
let mut chunks: Vec<Bytes> = Vec::new();
|
||||||
let mut total_size: usize = 0;
|
let mut total_size: usize = 0;
|
||||||
let mut field = field;
|
let mut field = field;
|
||||||
|
|
||||||
while let Ok(Some(chunk)) = field.chunk().await {
|
loop {
|
||||||
|
match field.chunk().await {
|
||||||
|
Ok(Some(chunk)) => {
|
||||||
total_size += chunk.len();
|
total_size += chunk.len();
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
}
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Connection lost during dedup upload (received {} bytes): {}",
|
||||||
|
total_size,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::BAD_REQUEST)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(format!(
|
||||||
|
r#"{{"error": "Connection lost during upload: {}"}}"#,
|
||||||
|
e
|
||||||
|
)))
|
||||||
|
.unwrap()
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if chunks.is_empty() {
|
if chunks.is_empty() {
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
|
|||||||
@@ -106,11 +106,7 @@ impl FileHandler {
|
|||||||
if let Some(ref fid) = folder_id {
|
if let Some(ref fid) = folder_id {
|
||||||
use crate::application::ports::inbound::FolderUseCase;
|
use crate::application::ports::inbound::FolderUseCase;
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
if folder_service
|
if folder_service.get_folder_owned(fid, &auth_user.id).await.is_err() {
|
||||||
.get_folder_owned(fid, &auth_user.id)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user",
|
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user",
|
||||||
auth_user.username,
|
auth_user.username,
|
||||||
@@ -169,13 +165,27 @@ impl FileHandler {
|
|||||||
// 512 KB buffer — 8× fewer write syscalls than 64 KB
|
// 512 KB buffer — 8× fewer write syscalls than 64 KB
|
||||||
let mut writer = tokio::io::BufWriter::with_capacity(524_288, file);
|
let mut writer = tokio::io::BufWriter::with_capacity(524_288, file);
|
||||||
let mut field = field;
|
let mut field = field;
|
||||||
while let Ok(Some(chunk)) = field.chunk().await {
|
// IMPORTANT: use explicit match instead of `while let Ok(Some(..))`.
|
||||||
|
// The old pattern silently swallowed Err (client disconnect)
|
||||||
|
// and accepted partially received data as a complete upload.
|
||||||
|
loop {
|
||||||
|
match field.chunk().await {
|
||||||
|
Ok(Some(chunk)) => {
|
||||||
total_size += chunk.len() as u64;
|
total_size += chunk.len() as u64;
|
||||||
hasher.update(&chunk);
|
hasher.update(&chunk);
|
||||||
tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk)
|
tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to write chunk: {}", e))?;
|
.map_err(|e| format!("Failed to write chunk: {}", e))?;
|
||||||
}
|
}
|
||||||
|
Ok(None) => break, // End of field — upload complete
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Connection lost during upload (received {} bytes): {}",
|
||||||
|
total_size, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tokio::io::AsyncWriteExt::flush(&mut writer)
|
tokio::io::AsyncWriteExt::flush(&mut writer)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to flush temp file: {}", e))?;
|
.map_err(|e| format!("Failed to flush temp file: {}", e))?;
|
||||||
@@ -326,28 +336,22 @@ impl FileHandler {
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the actual blob path on disk (not the logical file path).
|
// Resolve the physical blob path (content-addressable storage)
|
||||||
let blob_hash = match state
|
let blob_hash = match state
|
||||||
.repositories
|
.repositories
|
||||||
.file_read_repository
|
.file_read_repository
|
||||||
.get_blob_hash(&id)
|
.get_blob_hash(&id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(h) => h,
|
Ok(hash) => hash,
|
||||||
Err(err) => {
|
Err(_) => {
|
||||||
return (
|
return AppError::internal_error("File blob not found").into_response();
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Json(serde_json::json!({
|
|
||||||
"error": format!("File content not found: {}", err)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let blob_path = state.core.dedup_service.blob_path(&blob_hash);
|
let file_path = state.core.dedup_service.blob_path(&blob_hash);
|
||||||
|
|
||||||
match thumbnail_service
|
match thumbnail_service
|
||||||
.get_thumbnail(&id, thumb_size.into(), &blob_path)
|
.get_thumbnail(&id, thumb_size.into(), &file_path)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
@@ -362,8 +366,10 @@ impl FileHandler {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
Err(err) => AppError::internal_error(format!("Thumbnail generation failed: {}", err))
|
Err(err) => {
|
||||||
.into_response(),
|
AppError::internal_error(format!("Thumbnail generation failed: {}", err))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +542,9 @@ impl FileHandler {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response(),
|
.into_response(),
|
||||||
},
|
},
|
||||||
Err(err) => AppError::from(err).into_response(),
|
Err(err) => {
|
||||||
|
AppError::from(err).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +594,9 @@ impl FileHandler {
|
|||||||
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
Err(err) => AppError::from(err).into_response(),
|
Err(err) => {
|
||||||
|
AppError::from(err).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +615,7 @@ impl FileHandler {
|
|||||||
Err(response) => return response.into_response(),
|
Err(response) => return response.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate thumbnails and extract EXIF metadata for supported images in background
|
// Generate thumbnails for supported images in background
|
||||||
if state
|
if state
|
||||||
.core
|
.core
|
||||||
.thumbnail_service
|
.thumbnail_service
|
||||||
@@ -613,37 +623,25 @@ impl FileHandler {
|
|||||||
{
|
{
|
||||||
let file_id = file.id.clone();
|
let file_id = file.id.clone();
|
||||||
let thumbnail_service = state.core.thumbnail_service.clone();
|
let thumbnail_service = state.core.thumbnail_service.clone();
|
||||||
let dedup_service = state.core.dedup_service.clone();
|
|
||||||
let file_read = state.repositories.file_read_repository.clone();
|
|
||||||
let metadata_repo = state.repositories.file_metadata_repository.clone();
|
|
||||||
|
|
||||||
|
// Resolve physical blob path before spawning
|
||||||
|
match state
|
||||||
|
.repositories
|
||||||
|
.file_read_repository
|
||||||
|
.get_blob_hash(&file_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(blob_hash) => {
|
||||||
|
let file_path = state.core.dedup_service.blob_path(&blob_hash);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Resolve the actual blob path on disk (not the logical file path,
|
tracing::info!("🖼️ Generating thumbnails for: {}", file_id);
|
||||||
// which doesn't exist when using blob storage).
|
thumbnail_service
|
||||||
let blob_hash = match file_read.get_blob_hash(&file_id).await {
|
.generate_all_sizes_background(file_id, file_path);
|
||||||
Ok(h) => h,
|
});
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Skipping thumbnails for {}: {}", file_id, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let file_path = dedup_service.blob_path(&blob_hash);
|
|
||||||
|
|
||||||
// Extract EXIF metadata (reads only header bytes, very fast).
|
|
||||||
// Runs before thumbnail generation so the OS page cache is primed.
|
|
||||||
{
|
|
||||||
use crate::infrastructure::services::exif_service::ExifService;
|
|
||||||
match tokio::fs::read(&file_path).await {
|
|
||||||
Ok(data) => {
|
|
||||||
if let Some(meta) = ExifService::extract(&data)
|
|
||||||
&& let Err(e) = metadata_repo.upsert(&file_id, &meta).await
|
|
||||||
{
|
|
||||||
tracing::warn!("Failed to store EXIF for {}: {}", file_id, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Failed to read file for EXIF extraction {}: {}",
|
"⚠️ Cannot generate thumbnails for {}: blob hash not found: {}",
|
||||||
file_id,
|
file_id,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
@@ -651,11 +649,6 @@ impl FileHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("🖼️ Generating thumbnails for: {}", file_id);
|
|
||||||
thumbnail_service.generate_all_sizes_background(file_id, file_path);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::created_json_response(&file).into_response()
|
Self::created_json_response(&file).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,9 +667,10 @@ impl FileHandler {
|
|||||||
// Verify ownership
|
// Verify ownership
|
||||||
let file_read = &state.repositories.file_read_repository;
|
let file_read = &state.repositories.file_read_repository;
|
||||||
if let Err(e) = file_read.verify_file_owner(&file_id, &auth_user.id).await {
|
if let Err(e) = file_read.verify_file_owner(&file_id, &auth_user.id).await {
|
||||||
|
let msg = e.to_string();
|
||||||
return (
|
return (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
Json(serde_json::json!({ "error": e.to_string() })),
|
Json(serde_json::json!({ "error": msg })),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -732,7 +726,7 @@ impl FileHandler {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(err) => AppError::from(err).into_response(),
|
Err(err) => AppError::from(err).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,7 +758,7 @@ impl FileHandler {
|
|||||||
let mgmt = &state.applications.file_management_service;
|
let mgmt = &state.applications.file_management_service;
|
||||||
match mgmt.rename_file_owned(&id, &auth_user.id, &new_name).await {
|
match mgmt.rename_file_owned(&id, &auth_user.id, &new_name).await {
|
||||||
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response(),
|
Err(err) => AppError::from(err).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,7 +778,7 @@ impl FileHandler {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
|
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response(),
|
Err(err) => AppError::from(err).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,7 +797,7 @@ impl FileHandler {
|
|||||||
let mgmt = &state.applications.file_management_service;
|
let mgmt = &state.applications.file_management_service;
|
||||||
match mgmt.move_file_owned(&id, &auth_user.id, folder_id).await {
|
match mgmt.move_file_owned(&id, &auth_user.id, folder_id).await {
|
||||||
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response(),
|
Err(err) => AppError::from(err).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,7 +856,9 @@ impl FileHandler {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
|
format!(
|
||||||
|
"{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a 201 Created JSON response.
|
/// Build a 201 Created JSON response.
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -204,21 +204,34 @@ const notifications = (() => {
|
|||||||
|
|
||||||
if (status === 'error') batch.errorCount = (batch.errorCount || 0) + 1;
|
if (status === 'error') batch.errorCount = (batch.errorCount || 0) + 1;
|
||||||
|
|
||||||
// Only update the current-file label (single DOM element)
|
// Update the current-file label AND progress bar during upload
|
||||||
const curEl = $(batchId + '-current');
|
if (status === 'uploading') {
|
||||||
if (curEl && status === 'uploading') {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const fileChanged = batch.lastLabelFile !== fileName;
|
const fileChanged = batch.lastLabelFile !== fileName;
|
||||||
|
// Throttle DOM updates to avoid reflow storms (every 300ms or on file change)
|
||||||
const shouldUpdate = fileChanged || now - (batch.lastLabelUpdateTs || 0) >= 300 || pct >= 100;
|
const shouldUpdate = fileChanged || now - (batch.lastLabelUpdateTs || 0) >= 300 || pct >= 100;
|
||||||
if (!shouldUpdate) return;
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
// Show just the file name being uploaded (truncate long paths)
|
// Show just the file name being uploaded (truncate long paths)
|
||||||
|
const curEl = $(batchId + '-current');
|
||||||
|
if (curEl) {
|
||||||
const shortName = fileName.length > 50
|
const shortName = fileName.length > 50
|
||||||
? '…' + fileName.slice(-49)
|
? '…' + fileName.slice(-49)
|
||||||
: fileName;
|
: fileName;
|
||||||
curEl.textContent = shortName;
|
curEl.textContent = shortName;
|
||||||
|
}
|
||||||
batch.lastLabelFile = fileName;
|
batch.lastLabelFile = fileName;
|
||||||
batch.lastLabelUpdateTs = now;
|
batch.lastLabelUpdateTs = now;
|
||||||
|
|
||||||
|
// Update progress bar with per-file granularity:
|
||||||
|
// overall% = (completed_files + current_file_fraction) / total_files
|
||||||
|
const overallPct = Math.round(
|
||||||
|
((batch.completed + (pct / 100)) / batch.totalFiles) * 100
|
||||||
|
);
|
||||||
|
const fillEl = $(batchId + '-fill');
|
||||||
|
const pctEl = $(batchId + '-pct');
|
||||||
|
if (fillEl) fillEl.style.width = overallPct + '%';
|
||||||
|
if (pctEl) pctEl.textContent = overallPct + '%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,12 @@ const fileOps = {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
const notif = window.notifications;
|
const notif = window.notifications;
|
||||||
xhr.timeout = timeoutMs;
|
// Do NOT set xhr.timeout — it is a TOTAL deadline from send() to
|
||||||
const hardDeadlineMs = Math.max(timeoutMs * 2, 180000);
|
// response and would kill large uploads even while data is flowing.
|
||||||
|
// Instead we rely on the stall timer (no progress for N seconds)
|
||||||
|
// and a generous hard deadline that scales with file size.
|
||||||
|
xhr.timeout = 0;
|
||||||
|
const hardDeadlineMs = Math.max(timeoutMs * 4, 600000); // min 10 min
|
||||||
let lastProgressPctSent = -1;
|
let lastProgressPctSent = -1;
|
||||||
|
|
||||||
let isSettled = false;
|
let isSettled = false;
|
||||||
@@ -121,8 +125,8 @@ const fileOps = {
|
|||||||
resetStallTimer();
|
resetStallTimer();
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
const pct = Math.round((e.loaded / e.total) * 100);
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
// Throttle UI updates from very chatty progress events
|
// Throttle UI updates: every 2% for smooth progress on large files
|
||||||
if (pct === 100 || pct - lastProgressPctSent >= 10) {
|
if (pct === 100 || pct - lastProgressPctSent >= 2) {
|
||||||
lastProgressPctSent = pct;
|
lastProgressPctSent = pct;
|
||||||
safeUpdateFile(pct, 'uploading');
|
safeUpdateFile(pct, 'uploading');
|
||||||
}
|
}
|
||||||
@@ -320,7 +324,11 @@ const fileOps = {
|
|||||||
file: file.name, size: file.size
|
file: file.name, size: file.size
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await this._uploadFileXHR(formData, batchId, file.name);
|
// Scale stall timeout with file size:
|
||||||
|
// base 120s + 60s per GB, so a 7 GB file gets ~540s stall limit
|
||||||
|
const sizeGB = file.size / (1024 * 1024 * 1024);
|
||||||
|
const dynamicTimeout = Math.max(120000, 120000 + Math.ceil(sizeGB) * 60000);
|
||||||
|
const result = await this._uploadFileXHR(formData, batchId, file.name, dynamicTimeout);
|
||||||
|
|
||||||
uploadedCount++;
|
uploadedCount++;
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// OxiCloud Service Worker
|
// OxiCloud Service Worker
|
||||||
const CACHE_NAME = 'oxicloud-cache-v15';
|
const CACHE_NAME = 'oxicloud-cache-v16';
|
||||||
const ASSETS_TO_CACHE = [
|
const ASSETS_TO_CACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user