Files
cista-storage/frontend/src/components/UploadButton.vue
T
LeoVasanko b5a94b5eee Frontend upload batch space check with 512 MiB margin
- Verify single queue: uploads append to upqueue and are processed
  sequentially by one worker (parallel only within a single file).
- Before accepting a new batch, calculate total size minus existing
  files that will be overwritten.
- Reject the whole batch if free space < net need + 512 MiB margin.
- Show a toast with human-readable needed vs available space.
2026-05-05 02:13:18 +00:00

561 lines
17 KiB
Vue

<template>
<template>
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template>
<SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
</template>
<script setup lang="ts">
import { Doc } from '@/repositories/Document'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import { collator, formatSize } from '@/utils'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const fileInput = ref()
const folderInput = ref()
const store = useMainStore()
const props = defineProps({
path: Array<string>
})
type CloudFile = {
file: File
cloudName: string
cloudPos: number
}
type UploadRange = {
name: string
size: number
start: number
end: number
}
type InflightBlock = {
name: string
start: number
end: number
startedAt: number
}
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
const UPLOAD_MARGIN_BYTES = 512 * 1024 * 1024 // 512 MiB
function pasteHandler(event: ClipboardEvent) {
const items = Array.from(event.clipboardData?.items ?? [])
const infiles = [] as File[]
const dirs = [] as FileSystemDirectoryEntry[]
for (const item of items) {
if (item.kind !== 'file') continue
const entry = item.webkitGetAsEntry()
if (entry?.isFile) {
const file = item.getAsFile()
if (file) infiles.push(file)
} else if (entry?.isDirectory) {
dirs.push(entry as FileSystemDirectoryEntry)
}
}
if (infiles.length || dirs.length) {
event.preventDefault()
uploadFiles(infiles)
const base = props.path!.join('/')
for (const entry of dirs)
pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
}
}
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
const reader = entry.createReader()
const entries = await new Promise<any[]>(resolve => reader.readEntries(resolve))
const cloudfiles = [] as CloudFile[]
for (const entry of entries) {
const cloudName = `${loc}/${entry.name}`
if (entry.isFile) {
const file = (await new Promise(resolve => entry.file(resolve))) as File
cloudfiles.push({ file, cloudName, cloudPos: 0 })
} else if (entry.isDirectory) {
await pasteDirectory(entry, cloudName)
}
}
if (cloudfiles.length) uploadCloudFiles(cloudfiles)
}
function uploadHandler(event: Event) {
event.preventDefault()
// @ts-ignore
const input = event.target as HTMLInputElement | null
const infiles = Array.from(
(input ?? (event as DragEvent).dataTransfer)?.files ?? []
) as File[]
if (input) input.value = ''
if (infiles.length) uploadFiles(infiles)
}
const uploadFiles = (infiles: File[]) => {
const loc = props.path!.join('/')
let files = []
let folderName = ''
for (const file of infiles) {
const relPath = file.webkitRelativePath || file.name
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
files.push({
file,
cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
cloudPos: 0
})
}
uploadCloudFiles(files)
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
}
const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) {
store.showToast("Won't upload dotfiles")
files = files.filter(f => !f.cloudName.includes('/.'))
}
if (!files.length) return
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// Space check: reject the whole batch if there isn't enough free space.
const batchTotal = files.reduce((sum, f) => sum + f.file.size, 0)
const allDocs = getDocuments()
const docByPath = new Map<string, Doc>()
for (const d of allDocs) {
const path = d.loc ? `${d.loc}/${d.name}` : d.name
docByPath.set(path, d)
}
let overwriteSize = 0
for (const f of files) {
const existing = docByPath.get(f.cloudName)
if (existing && !existing.dir) overwriteSize += existing.size
}
const netNeed = batchTotal - overwriteSize
if (store.space.free < netNeed + UPLOAD_MARGIN_BYTES) {
store.showToast(
`Not enough free space (need ${formatSize(netNeed + UPLOAD_MARGIN_BYTES)}, have ${formatSize(store.space.free)})`
)
return
}
// Optimistic update: ghost folders and files
const now = Math.floor(Date.now() / 1000)
const docs = getDocuments()
const byPath = new Map(docs.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
// Also check existing ghosts
for (const g of store.ghosts) {
byPath.set(g.loc ? `${g.loc}/${g.name}` : g.name, g)
}
const added = new Set<string>()
for (const f of files) {
const lastSlash = f.cloudName.lastIndexOf('/')
const loc = lastSlash > 0 ? f.cloudName.slice(0, lastSlash) : ''
const name = f.cloudName.slice(lastSlash + 1)
// Ghost folders for intermediate directories
const parts = loc.split('/')
for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
store.addGhost(
new Doc({
loc: parts.slice(0, i).join('/'),
name: parts[i],
key: crypto.randomUUID(),
size: 0,
allocated: 0,
mtime: now,
dir: true
})
)
added.add(folderPath)
}
}
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
const existing = byPath.get(f.cloudName)
if (!existing)
store.addGhost(
new Doc({
loc,
name,
key: crypto.randomUUID(),
size: f.file.size,
allocated: 0,
mtime: now,
dir: false
})
)
}
// @ts-ignore
upqueue = [...upqueue, ...files]
statsAdd(files)
startWorker()
}
const cancelUploads = () => {
uploadRunId += 1
upqueue = []
blockQueue = []
inflightBlocks.clear()
uploadedBytes.clear()
store.uprogress.status = 'idle'
statReset()
}
const uprogress_init = {
total: 0,
xfer: 0,
t0: 0,
tlast: 0,
statbytes: 0,
statdur: 0,
files: [] as CloudFile[],
filestart: 0,
fileidx: 0,
filecount: 0,
filename: '',
filesize: 0,
filepos: 0,
status: 'idle'
}
store.uprogress = { ...uprogress_init }
// Track uploaded bytes for each file to handle out-of-order uploads
const uploadedBytes = new Map<string, Set<number>>()
const inflightBlocks = new Map<string, InflightBlock>()
let smoothedBlockMs = 1500
let lastProgressTick = Date.now()
let lastVisualUploaded = 0
const inflightKey = (name: string, start: number) => `${name}:${start}`
const completedUploadedBytes = (name: string, size: number) => {
const uploaded = uploadedBytes.get(name)
if (!uploaded) return 0
const blockSize = UPLOAD_BLOCK_SIZE
let total = 0
for (let i = 0; i < size; i += blockSize) {
if (uploaded.has(i)) total += Math.min(blockSize, size - i)
}
return total
}
const simulatedInflightBytes = (name: string, now: number) => {
let total = 0
for (const block of inflightBlocks.values()) {
if (block.name !== name) continue
const size = block.end - block.start
const elapsed = Math.max(0, now - block.startedAt)
const fraction = Math.min(0.98, elapsed / Math.max(200, smoothedBlockMs))
total += size * fraction
}
return total
}
const refreshProgress = (now: number) => {
const name = store.uprogress.filename
const size = store.uprogress.filesize
if (!name || !size) {
lastProgressTick = now
return 0
}
const completed = completedUploadedBytes(name, size)
const estimated = simulatedInflightBytes(name, now)
const visualUploaded = Math.min(size, Math.round(completed + estimated))
const delta = Math.max(0, visualUploaded - lastVisualUploaded)
const dt = Math.max(1, now - lastProgressTick)
store.uprogress.filepos = visualUploaded
store.uprogress.xfer = store.uprogress.filestart + visualUploaded
if (delta > 0) {
store.uprogress.statbytes += delta
store.uprogress.statdur += dt
store.uprogress.tlast = now
}
lastVisualUploaded = visualUploaded
lastProgressTick = now
return delta
}
setInterval(() => {
const now = Date.now()
const delta = refreshProgress(now)
if (delta > 0) return
if (now - store.uprogress.tlast > 3000) {
store.uprogress.statbytes = 0
store.uprogress.statdur = 1
} else {
store.uprogress.statbytes *= 0.95
store.uprogress.statdur *= 0.95
}
}, 100)
const statUpdate = ({ name, size, start, end }: UploadRange) => {
if (name !== store.uprogress.filename) return // If stats have been reset
// Track which bytes have been uploaded (using start to end range)
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
const uploaded = uploadedBytes.get(name)!
const blockSize = UPLOAD_BLOCK_SIZE
// Mark all bytes in this block as uploaded
for (let i = start; i < end; i += blockSize) {
uploaded.add(i)
}
refreshProgress(Date.now())
// Check if file is fully uploaded by examining the block queue
const currentUpload = blockQueue[0]
if (!currentUpload) return
if (
currentUpload.file.cloudName === name &&
currentUpload.completed >= currentUpload.blocks.length
) {
// All blocks for this file have been uploaded
uploadedBytes.delete(name) // Clean up tracking
store.uprogress.filestart += size
statNextFile()
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
}
}
const statNextFile = () => {
const f = store.uprogress.files.shift()
if (!f) return statReset()
store.uprogress.filepos = 0
store.uprogress.filesize = f.file.size
store.uprogress.filename = f.cloudName
lastVisualUploaded = 0
lastProgressTick = Date.now()
}
const statReset = () => {
Object.assign(store.uprogress, uprogress_init)
store.uprogress.t0 = Date.now()
store.uprogress.tlast = store.uprogress.t0 + 1
lastVisualUploaded = 0
lastProgressTick = store.uprogress.t0
}
const statsAdd = (f: CloudFile[]) => {
if (store.uprogress.files.length === 0) statReset()
store.uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
store.uprogress.filecount += f.length
store.uprogress.files = [...store.uprogress.files, ...f]
statNextFile()
}
let upqueue = [] as CloudFile[]
const MAX_PARALLEL_REQUESTS = 4
const RETRY_DELAY_MS = 400
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
const getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
const FINAL_BLOCKS_COUNT = 2
const fileSize = file.file.size
const blocks: { start: number; end: number }[] = []
if (fileSize >= MIN_SIZE_FOR_REORDER) {
// File is large enough, prioritize final blocks
const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
// Add final blocks first
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
const start = finalBlocksStart + i * BLOCK_SIZE
const end = Math.min(start + BLOCK_SIZE, fileSize)
blocks.push({ start, end })
}
// Add remaining blocks from beginning
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
blocks.push({ start, end })
}
} else {
// File is smaller, use sequential upload
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
const end = Math.min(start + BLOCK_SIZE, fileSize)
blocks.push({ start, end })
}
}
return blocks
}
type BlockUpload = {
file: CloudFile
blocks: { start: number; end: number }[]
nextIndex: number
completed: number
runId: number
}
let blockQueue = [] as BlockUpload[]
let workerRunning = false
let uploadRunId = 0
const enqueuePendingUploads = () => {
while (upqueue.length) {
const file = upqueue.shift()!
const blocks = getUploadBlocks(file)
blockQueue.push({ file, blocks, nextIndex: 0, completed: 0, runId: uploadRunId })
}
}
const uploadUrlForFile = (cloudName: string) => {
const normalized = cloudName.replace(/^\/+/, '')
const encoded = normalized.split('/').map(encodeURIComponent).join('/')
return `/files/${encoded}`
}
const uploadBlock = async (
upload: BlockUpload,
block: { start: number; end: number }
) => {
const body = upload.file.file.slice(block.start, block.end)
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
const fallbackReq = {
name: upload.file.cloudName,
size: upload.file.file.size,
start: block.start,
end: block.end
}
let attempt = 0
while (true) {
attempt += 1
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
try {
const res = await fetch(uploadUrlForFile(upload.file.cloudName), {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Range': range
},
body
})
if (!res.ok) {
const message = await res.text().catch(() => '')
const retryable = res.status >= 500 || res.status === 408 || res.status === 429
if (!retryable) throw new Error(message || `HTTP ${res.status}`)
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
continue
}
const payload = await res.json().catch(() => null)
return payload?.status === 'ack' && payload.req ? payload.req : fallbackReq
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'Upload cancelled') throw err
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
if (attempt % 10 === 0) {
console.warn(`Upload retry ${attempt} for ${upload.file.cloudName}: ${message}`)
}
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
}
}
}
const startInflightBlock = (name: string, block: { start: number; end: number }) => {
inflightBlocks.set(inflightKey(name, block.start), {
name,
start: block.start,
end: block.end,
startedAt: Date.now()
})
}
const finishInflightBlock = (name: string, block: { start: number; end: number }) => {
const key = inflightKey(name, block.start)
const info = inflightBlocks.get(key)
if (!info) return
const elapsed = Math.max(1, Date.now() - info.startedAt)
smoothedBlockMs = smoothedBlockMs * 0.85 + elapsed * 0.15
inflightBlocks.delete(key)
}
const worker = async (runId: number) => {
try {
while (runId === uploadRunId) {
enqueuePendingUploads()
if (!blockQueue.length) break
const upload = blockQueue[0]!
const inflight = new Set<Promise<void>>()
while (runId === uploadRunId && upload.completed < upload.blocks.length) {
while (
runId === uploadRunId &&
upload.nextIndex < upload.blocks.length &&
inflight.size < MAX_PARALLEL_REQUESTS
) {
const block = upload.blocks[upload.nextIndex++]!
store.uprogress.status = 'uploading'
startInflightBlock(upload.file.cloudName, block)
let task: Promise<void>
task = uploadBlock(upload, block)
.then(req => {
finishInflightBlock(upload.file.cloudName, block)
upload.completed += 1
statUpdate(req)
})
.catch(err => {
finishInflightBlock(upload.file.cloudName, block)
throw err
})
.finally(() => {
inflight.delete(task)
})
inflight.add(task)
}
if (!inflight.size) break
await Promise.race(inflight)
}
if (runId !== uploadRunId) return
if (upload.completed >= upload.blocks.length) {
blockQueue.shift()
} else {
break
}
}
} catch (err: any) {
if (runId !== uploadRunId) return
console.error('Upload error', err)
store.error = err?.message || 'Upload failed'
uploadRunId += 1
upqueue = []
blockQueue = []
inflightBlocks.clear()
} finally {
store.uprogress.status = 'idle'
workerRunning = false
if (upqueue.length) startWorker()
}
}
const startWorker = () => {
if (workerRunning) return
workerRunning = true
const runId = uploadRunId
setTimeout(() => {
void worker(runId)
}, 0)
}
onMounted(() => {
// Need to prevent both to prevent browser from opening the file
addEventListener('dragover', uploadHandler)
addEventListener('drop', uploadHandler)
addEventListener('paste', pasteHandler)
})
onUnmounted(() => {
removeEventListener('paste', pasteHandler)
removeEventListener('dragover', uploadHandler)
removeEventListener('drop', uploadHandler)
})
</script>