(function() { 'use strict'; const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || { formatBytes: (bytes) => { if (!Number.isFinite(bytes)) return `${bytes} bytes`; const units = ['bytes', 'KB', 'MB', 'GB', 'TB']; let i = 0; let size = bytes; while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; }, escapeHtml: (value) => { if (value === null || value === undefined) return ''; return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, fallbackCopy: () => false, setupJsonAutoIndent: () => {} }; setupJsonAutoIndent(document.getElementById('policyDocument')); const selectAllCheckbox = document.querySelector('[data-select-all]'); const bulkDeleteButton = document.querySelector('[data-bulk-delete-trigger]'); const bulkDeleteLabel = bulkDeleteButton?.querySelector('[data-bulk-delete-label]'); const bulkDeleteModalEl = document.getElementById('bulkDeleteModal'); const bulkDeleteModal = bulkDeleteModalEl ? new bootstrap.Modal(bulkDeleteModalEl) : null; const bulkDeleteList = document.getElementById('bulkDeleteList'); const bulkDeleteCount = document.getElementById('bulkDeleteCount'); const bulkDeleteStatus = document.getElementById('bulkDeleteStatus'); const bulkDeleteConfirm = document.getElementById('bulkDeleteConfirm'); const bulkDeletePurge = document.getElementById('bulkDeletePurge'); const previewPanel = document.getElementById('preview-panel'); const previewEmpty = document.getElementById('preview-empty'); const previewKey = document.getElementById('preview-key'); const previewSize = document.getElementById('preview-size'); const previewModified = document.getElementById('preview-modified'); const previewEtag = document.getElementById('preview-etag'); const previewMetadata = document.getElementById('preview-metadata'); const previewMetadataList = document.getElementById('preview-metadata-list'); const previewPlaceholder = document.getElementById('preview-placeholder'); const previewImage = document.getElementById('preview-image'); const previewVideo = document.getElementById('preview-video'); const previewIframe = document.getElementById('preview-iframe'); const downloadButton = document.getElementById('downloadButton'); const presignButton = document.getElementById('presignButton'); const presignModalEl = document.getElementById('presignModal'); const presignModal = presignModalEl ? new bootstrap.Modal(presignModalEl) : null; const presignMethod = document.getElementById('presignMethod'); const presignTtl = document.getElementById('presignTtl'); const presignLink = document.getElementById('presignLink'); const copyPresignLink = document.getElementById('copyPresignLink'); const copyPresignDefaultLabel = copyPresignLink?.textContent?.trim() || 'Copy'; const generatePresignButton = document.getElementById('generatePresignButton'); const policyForm = document.getElementById('bucketPolicyForm'); const policyTextarea = document.getElementById('policyDocument'); const policyPreset = document.getElementById('policyPreset'); const policyMode = document.getElementById('policyMode'); const uploadForm = document.querySelector('[data-upload-form]'); const uploadModalEl = document.getElementById('uploadModal'); const uploadModal = uploadModalEl ? bootstrap.Modal.getOrCreateInstance(uploadModalEl) : null; const uploadFileInput = uploadForm?.querySelector('input[name="object"]'); const uploadDropZone = uploadForm?.querySelector('[data-dropzone]'); const uploadDropZoneLabel = uploadDropZone?.querySelector('[data-dropzone-label]'); const messageModalEl = document.getElementById('messageModal'); const messageModal = messageModalEl ? new bootstrap.Modal(messageModalEl) : null; const messageModalTitle = document.getElementById('messageModalTitle'); const messageModalBody = document.getElementById('messageModalBody'); const messageModalAction = document.getElementById('messageModalAction'); let messageModalActionHandler = null; let isGeneratingPresign = false; const objectsContainer = document.querySelector('.objects-table-container[data-bucket]'); const bulkDeleteEndpoint = objectsContainer?.dataset.bulkDeleteEndpoint || ''; const objectsApiUrl = objectsContainer?.dataset.objectsApi || ''; const objectsStreamUrl = objectsContainer?.dataset.objectsStream || ''; const versionPanel = document.getElementById('version-panel'); const versionList = document.getElementById('version-list'); const refreshVersionsButton = document.getElementById('refreshVersionsButton'); const archivedCard = document.getElementById('archived-objects-card'); const archivedBody = archivedCard?.querySelector('[data-archived-body]'); const archivedCountBadge = archivedCard?.querySelector('[data-archived-count]'); const archivedRefreshButton = archivedCard?.querySelector('[data-archived-refresh]'); const archivedEndpoint = archivedCard?.dataset.archivedEndpoint; let versioningEnabled = objectsContainer?.dataset.versioning === 'true'; const versionsCache = new Map(); let activeRow = null; const selectedRows = new Map(); let bulkDeleting = false; if (presignButton) presignButton.disabled = true; if (generatePresignButton) generatePresignButton.disabled = true; if (downloadButton) downloadButton.classList.add('disabled'); const objectCountBadge = document.getElementById('object-count-badge'); const loadMoreContainer = document.getElementById('load-more-container'); const loadMoreSpinner = document.getElementById('load-more-spinner'); const loadMoreStatus = document.getElementById('load-more-status'); const objectsLoadingRow = document.getElementById('objects-loading-row'); let nextContinuationToken = null; let totalObjectCount = 0; let loadedObjectCount = 0; let isLoadingObjects = false; let hasMoreObjects = false; let currentFilterTerm = ''; let pageSize = 5000; let currentPrefix = ''; let allObjects = []; let urlTemplates = null; let streamAbortController = null; let useStreaming = !!objectsStreamUrl; let streamingComplete = false; const STREAM_RENDER_BATCH = 500; let pendingStreamObjects = []; let streamRenderScheduled = false; const buildUrlFromTemplate = (template, key) => { if (!template) return ''; return template.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/')); }; const ROW_HEIGHT = 53; const BUFFER_ROWS = 10; let visibleItems = []; let renderedRange = { start: 0, end: 0 }; const createObjectRow = (obj, displayKey = null) => { const tr = document.createElement('tr'); tr.dataset.objectRow = ''; tr.dataset.key = obj.key; tr.dataset.size = obj.size; tr.dataset.lastModified = obj.lastModified || obj.last_modified; tr.dataset.etag = obj.etag; tr.dataset.previewUrl = obj.previewUrl || obj.preview_url; tr.dataset.downloadUrl = obj.downloadUrl || obj.download_url; tr.dataset.presignEndpoint = obj.presignEndpoint || obj.presign_endpoint; tr.dataset.deleteEndpoint = obj.deleteEndpoint || obj.delete_endpoint; tr.dataset.metadata = typeof obj.metadata === 'string' ? obj.metadata : JSON.stringify(obj.metadata || {}); tr.dataset.versionsEndpoint = obj.versionsEndpoint || obj.versions_endpoint; tr.dataset.restoreTemplate = obj.restoreTemplate || obj.restore_template; tr.dataset.tagsUrl = obj.tagsUrl || obj.tags_url; tr.dataset.copyUrl = obj.copyUrl || obj.copy_url; tr.dataset.moveUrl = obj.moveUrl || obj.move_url; const keyToShow = displayKey || obj.key; const lastModDisplay = obj.lastModifiedDisplay || obj.last_modified_display || new Date(obj.lastModified || obj.last_modified).toLocaleDateString(); tr.innerHTML = `
${escapeHtml(keyToShow)}
Modified ${escapeHtml(lastModDisplay)}
${formatBytes(obj.size)}
`; return tr; }; const showEmptyState = () => { if (!objectsTableBody) return; objectsTableBody.innerHTML = `
No objects yet

Drag and drop files here or click Upload to get started.

`; }; const showLoadError = (message) => { if (!objectsTableBody) return; objectsTableBody.innerHTML = `

Failed to load objects

${escapeHtml(message)}

`; }; const updateObjectCountBadge = () => { if (!objectCountBadge) return; if (totalObjectCount === 0) { objectCountBadge.textContent = '0 objects'; } else { objectCountBadge.textContent = `${totalObjectCount.toLocaleString()} object${totalObjectCount !== 1 ? 's' : ''}`; } }; let topSpacer = null; let bottomSpacer = null; const initVirtualScrollElements = () => { if (!objectsTableBody) return; if (!topSpacer) { topSpacer = document.createElement('tr'); topSpacer.id = 'virtual-top-spacer'; topSpacer.innerHTML = ''; } if (!bottomSpacer) { bottomSpacer = document.createElement('tr'); bottomSpacer.id = 'virtual-bottom-spacer'; bottomSpacer.innerHTML = ''; } }; const computeVisibleItems = () => { const items = []; const folders = new Set(); allObjects.forEach(obj => { if (!obj.key.startsWith(currentPrefix)) return; const remainder = obj.key.slice(currentPrefix.length); if (!remainder) return; const isFolderMarker = obj.key.endsWith('/') && obj.size === 0; const slashIndex = remainder.indexOf('/'); if (slashIndex === -1 && !isFolderMarker) { if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) { items.push({ type: 'file', data: obj, displayKey: remainder }); } } else { const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1 ? slashIndex : (slashIndex === -1 ? remainder.length - 1 : slashIndex); const folderName = remainder.slice(0, effectiveSlashIndex); const folderPath = currentPrefix + folderName + '/'; if (!folders.has(folderPath)) { folders.add(folderPath); if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) { items.push({ type: 'folder', path: folderPath, displayKey: folderName }); } } } }); items.sort((a, b) => { if (a.type === 'folder' && b.type === 'file') return -1; if (a.type === 'file' && b.type === 'folder') return 1; const aKey = a.type === 'folder' ? a.path : a.data.key; const bKey = b.type === 'folder' ? b.path : b.data.key; return aKey.localeCompare(bKey); }); return items; }; const renderVirtualRows = () => { if (!objectsTableBody || !scrollContainer) return; const containerHeight = scrollContainer.clientHeight; const scrollTop = scrollContainer.scrollTop; const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS); const endIndex = Math.min(visibleItems.length, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + BUFFER_ROWS); if (startIndex === renderedRange.start && endIndex === renderedRange.end) return; renderedRange = { start: startIndex, end: endIndex }; objectsTableBody.innerHTML = ''; initVirtualScrollElements(); topSpacer.querySelector('td').style.height = `${startIndex * ROW_HEIGHT}px`; objectsTableBody.appendChild(topSpacer); for (let i = startIndex; i < endIndex; i++) { const item = visibleItems[i]; if (!item) continue; let row; if (item.type === 'folder') { row = createFolderRow(item.path, item.displayKey); } else { row = createObjectRow(item.data, item.displayKey); } row.dataset.virtualIndex = i; objectsTableBody.appendChild(row); } const remainingRows = visibleItems.length - endIndex; bottomSpacer.querySelector('td').style.height = `${remainingRows * ROW_HEIGHT}px`; objectsTableBody.appendChild(bottomSpacer); attachRowHandlers(); }; let scrollTimeout = null; const handleVirtualScroll = () => { if (scrollTimeout) cancelAnimationFrame(scrollTimeout); scrollTimeout = requestAnimationFrame(renderVirtualRows); }; const refreshVirtualList = () => { visibleItems = computeVisibleItems(); renderedRange = { start: -1, end: -1 }; if (visibleItems.length === 0) { if (allObjects.length === 0 && !hasMoreObjects) { showEmptyState(); } else { objectsTableBody.innerHTML = `
Empty folder

This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}

`; } } else { renderVirtualRows(); } updateFolderViewStatus(); }; const updateFolderViewStatus = () => { const folderViewStatusEl = document.getElementById('folder-view-status'); if (!folderViewStatusEl) return; if (currentPrefix) { const folderCount = visibleItems.filter(i => i.type === 'folder').length; const fileCount = visibleItems.filter(i => i.type === 'file').length; folderViewStatusEl.innerHTML = `${folderCount} folder${folderCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''} in this view`; folderViewStatusEl.classList.remove('d-none'); } else { folderViewStatusEl.classList.add('d-none'); } }; const processStreamObject = (obj) => { const key = obj.key; return { key: key, size: obj.size, lastModified: obj.last_modified, lastModifiedDisplay: obj.last_modified_display, etag: obj.etag, previewUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.preview, key) : '', downloadUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.download, key) : '', presignEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.presign, key) : '', deleteEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.delete, key) : '', metadata: '{}', versionsEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.versions, key) : '', restoreTemplate: urlTemplates ? urlTemplates.restore.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/')) : '', tagsUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.tags, key) : '', copyUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.copy, key) : '', moveUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.move, key) : '' }; }; const flushPendingStreamObjects = () => { if (pendingStreamObjects.length === 0) return; const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length); batch.forEach(obj => { loadedObjectCount++; allObjects.push(obj); }); updateObjectCountBadge(); if (loadMoreStatus) { if (streamingComplete) { loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`; } else { const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : ''; loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`; } } refreshVirtualList(); streamRenderScheduled = false; }; const scheduleStreamRender = () => { if (streamRenderScheduled) return; streamRenderScheduled = true; requestAnimationFrame(flushPendingStreamObjects); }; const loadObjectsStreaming = async () => { if (isLoadingObjects) return; isLoadingObjects = true; streamingComplete = false; if (objectsLoadingRow) objectsLoadingRow.style.display = ''; nextContinuationToken = null; loadedObjectCount = 0; totalObjectCount = 0; allObjects = []; pendingStreamObjects = []; streamAbortController = new AbortController(); try { const params = new URLSearchParams(); if (currentPrefix) params.set('prefix', currentPrefix); const response = await fetch(`${objectsStreamUrl}?${params}`, { signal: streamAbortController.signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } if (objectsLoadingRow) objectsLoadingRow.remove(); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; try { const msg = JSON.parse(line); switch (msg.type) { case 'meta': urlTemplates = msg.url_templates; versioningEnabled = msg.versioning_enabled; if (objectsContainer) { objectsContainer.dataset.versioning = versioningEnabled ? 'true' : 'false'; } break; case 'count': totalObjectCount = msg.total_count || 0; break; case 'object': pendingStreamObjects.push(processStreamObject(msg)); if (pendingStreamObjects.length >= STREAM_RENDER_BATCH) { scheduleStreamRender(); } break; case 'error': throw new Error(msg.error); case 'done': streamingComplete = true; break; } } catch (parseErr) { console.warn('Failed to parse stream line:', line, parseErr); } } if (pendingStreamObjects.length > 0) { scheduleStreamRender(); } } if (buffer.trim()) { try { const msg = JSON.parse(buffer); if (msg.type === 'object') { pendingStreamObjects.push(processStreamObject(msg)); } else if (msg.type === 'done') { streamingComplete = true; } } catch (e) {} } flushPendingStreamObjects(); streamingComplete = true; hasMoreObjects = false; updateObjectCountBadge(); if (loadMoreStatus) { loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`; } if (typeof updateLoadMoreButton === 'function') { updateLoadMoreButton(); } refreshVirtualList(); renderBreadcrumb(currentPrefix); } catch (error) { if (error.name === 'AbortError') return; console.error('Streaming failed, falling back to paginated:', error); useStreaming = false; isLoadingObjects = false; await loadObjectsPaginated(false); return; } finally { isLoadingObjects = false; streamAbortController = null; } }; const loadObjectsPaginated = async (append = false) => { if (isLoadingObjects) return; isLoadingObjects = true; if (!append) { if (objectsLoadingRow) objectsLoadingRow.style.display = ''; nextContinuationToken = null; loadedObjectCount = 0; totalObjectCount = 0; allObjects = []; } if (append && loadMoreSpinner) { loadMoreSpinner.classList.remove('d-none'); } try { const params = new URLSearchParams({ max_keys: String(pageSize) }); if (nextContinuationToken) { params.set('continuation_token', nextContinuationToken); } const response = await fetch(`${objectsApiUrl}?${params}`); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `HTTP ${response.status}`); } const data = await response.json(); versioningEnabled = data.versioning_enabled; if (objectsContainer) { objectsContainer.dataset.versioning = versioningEnabled ? 'true' : 'false'; } totalObjectCount = data.total_count || 0; nextContinuationToken = data.next_continuation_token; if (!append && objectsLoadingRow) { objectsLoadingRow.remove(); } if (data.url_templates && !urlTemplates) { urlTemplates = data.url_templates; } data.objects.forEach(obj => { loadedObjectCount++; allObjects.push(processStreamObject(obj)); }); updateObjectCountBadge(); hasMoreObjects = data.is_truncated; if (loadMoreStatus) { if (data.is_truncated) { loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} of ${totalObjectCount.toLocaleString()} loaded`; } else { loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`; } } if (typeof updateLoadMoreButton === 'function') { updateLoadMoreButton(); } refreshVirtualList(); renderBreadcrumb(currentPrefix); } catch (error) { console.error('Failed to load objects:', error); if (!append) { showLoadError(error.message); } else { showMessage({ title: 'Load Failed', body: error.message, variant: 'danger' }); } } finally { isLoadingObjects = false; if (loadMoreSpinner) { loadMoreSpinner.classList.add('d-none'); } } }; const loadObjects = async (append = false) => { if (useStreaming && !append) { return loadObjectsStreaming(); } return loadObjectsPaginated(append); }; const attachRowHandlers = () => { const objectRows = document.querySelectorAll('[data-object-row]'); objectRows.forEach(row => { if (row.dataset.handlersAttached) return; row.dataset.handlersAttached = 'true'; const deleteBtn = row.querySelector('[data-delete-object]'); deleteBtn?.addEventListener('click', (e) => { e.stopPropagation(); const deleteModalEl = document.getElementById('deleteObjectModal'); const deleteModal = deleteModalEl ? bootstrap.Modal.getOrCreateInstance(deleteModalEl) : null; const deleteObjectForm = document.getElementById('deleteObjectForm'); const deleteObjectKey = document.getElementById('deleteObjectKey'); if (deleteModal && deleteObjectForm) { deleteObjectForm.setAttribute('action', row.dataset.deleteEndpoint); if (deleteObjectKey) deleteObjectKey.textContent = row.dataset.key; deleteModal.show(); } }); const selectCheckbox = row.querySelector('[data-object-select]'); selectCheckbox?.addEventListener('click', (event) => event.stopPropagation()); selectCheckbox?.addEventListener('change', () => { toggleRowSelection(row, selectCheckbox.checked); }); if (selectedRows.has(row.dataset.key)) { selectCheckbox.checked = true; row.classList.add('table-active'); } }); const folderRows = document.querySelectorAll('.folder-row'); folderRows.forEach(row => { if (row.dataset.handlersAttached) return; row.dataset.handlersAttached = 'true'; const folderPath = row.dataset.folderPath; const checkbox = row.querySelector('[data-folder-select]'); checkbox?.addEventListener('change', (e) => { e.stopPropagation(); const folderObjects = allObjects.filter(obj => obj.key.startsWith(folderPath)); folderObjects.forEach(obj => { if (checkbox.checked) { selectedRows.set(obj.key, obj); } else { selectedRows.delete(obj.key); } }); updateBulkDeleteState(); }); const folderBtn = row.querySelector('button'); folderBtn?.addEventListener('click', (e) => { e.stopPropagation(); navigateToFolder(folderPath); }); row.addEventListener('click', (e) => { if (e.target.closest('[data-folder-select]') || e.target.closest('button')) return; navigateToFolder(folderPath); }); }); updateBulkDeleteState(); }; const scrollSentinel = document.getElementById('scroll-sentinel'); const scrollContainer = document.querySelector('.objects-table-container'); const loadMoreBtn = document.getElementById('load-more-btn'); if (scrollContainer) { scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true }); } loadMoreBtn?.addEventListener('click', () => { if (hasMoreObjects && !isLoadingObjects) { loadObjects(true); } }); function updateLoadMoreButton() { if (loadMoreBtn) { loadMoreBtn.classList.toggle('d-none', !hasMoreObjects); } } if (scrollSentinel && scrollContainer) { const containerObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) { loadObjects(true); } }); }, { root: scrollContainer, rootMargin: '500px', threshold: 0 }); containerObserver.observe(scrollSentinel); const viewportObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) { loadObjects(true); } }); }, { root: null, rootMargin: '500px', threshold: 0 }); viewportObserver.observe(scrollSentinel); } const pageSizeSelect = document.getElementById('page-size-select'); pageSizeSelect?.addEventListener('change', (e) => { pageSize = parseInt(e.target.value, 10); }); if (objectsApiUrl) { loadObjects(); } const folderBreadcrumb = document.getElementById('folder-breadcrumb'); const objectsTableBody = document.querySelector('#objects-table tbody'); if (objectsTableBody) { objectsTableBody.addEventListener('click', (e) => { const row = e.target.closest('[data-object-row]'); if (!row) return; if (e.target.closest('[data-delete-object]') || e.target.closest('[data-object-select]') || e.target.closest('a')) { return; } selectRow(row); }); } const hasFolders = () => allObjects.some(obj => obj.key.includes('/')); const getFoldersAtPrefix = (prefix) => { const folders = new Set(); const files = []; allObjects.forEach(obj => { const key = obj.key; if (!key.startsWith(prefix)) return; const remainder = key.slice(prefix.length); const slashIndex = remainder.indexOf('/'); if (slashIndex === -1) { files.push(obj); } else { const folderName = remainder.slice(0, slashIndex + 1); folders.add(prefix + folderName); } }); return { folders: Array.from(folders).sort(), files }; }; const countObjectsInFolder = (folderPrefix) => { const count = allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length; return { count, mayHaveMore: hasMoreObjects }; }; const renderBreadcrumb = (prefix) => { if (!folderBreadcrumb) return; if (!prefix && !hasFolders()) { folderBreadcrumb.classList.add('d-none'); return; } folderBreadcrumb.classList.remove('d-none'); const ol = folderBreadcrumb.querySelector('ol'); ol.innerHTML = ''; const rootLi = document.createElement('li'); rootLi.className = 'breadcrumb-item'; if (!prefix) { rootLi.classList.add('active'); rootLi.setAttribute('aria-current', 'page'); rootLi.innerHTML = ` Root `; } else { rootLi.innerHTML = ` Root `; } ol.appendChild(rootLi); if (prefix) { const parts = prefix.split('/').filter(Boolean); let accumulated = ''; parts.forEach((part, index) => { accumulated += part + '/'; const li = document.createElement('li'); li.className = 'breadcrumb-item'; if (index === parts.length - 1) { li.classList.add('active'); li.setAttribute('aria-current', 'page'); li.textContent = part; } else { const a = document.createElement('a'); a.href = '#'; a.className = 'text-decoration-none'; a.dataset.folderNav = accumulated; a.textContent = part; li.appendChild(a); } ol.appendChild(li); }); } ol.querySelectorAll('[data-folder-nav]').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); navigateToFolder(link.dataset.folderNav); }); }); }; const getObjectsInFolder = (folderPrefix) => { return allObjects.filter(obj => obj.key.startsWith(folderPrefix)); }; const createFolderRow = (folderPath, displayName = null) => { const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, ''); const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath); const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount; const tr = document.createElement('tr'); tr.className = 'folder-row'; tr.dataset.folderPath = folderPath; tr.style.cursor = 'pointer'; tr.innerHTML = `
${escapeHtml(folderName)}/
${countDisplay} object${objectCount !== 1 ? 's' : ''}
`; return tr; }; const navigateToFolder = (prefix) => { currentPrefix = prefix; if (scrollContainer) scrollContainer.scrollTop = 0; refreshVirtualList(); renderBreadcrumb(prefix); selectedRows.clear(); if (typeof updateBulkDeleteState === 'function') { updateBulkDeleteState(); } if (previewPanel) previewPanel.classList.add('d-none'); if (previewEmpty) previewEmpty.classList.remove('d-none'); activeRow = null; }; const renderObjectsView = () => { if (!objectsTableBody) return; const { folders, files } = getFoldersAtPrefix(currentPrefix); objectsTableBody.innerHTML = ''; folders.forEach(folderPath => { objectsTableBody.appendChild(createFolderRow(folderPath)); }); files.forEach(obj => { objectsTableBody.appendChild(obj.element); obj.element.style.display = ''; const keyCell = obj.element.querySelector('.object-key .fw-medium'); if (keyCell && currentPrefix) { const displayName = obj.key.slice(currentPrefix.length); keyCell.textContent = displayName; keyCell.closest('.object-key').title = obj.key; } else if (keyCell) { keyCell.textContent = obj.key; } }); allObjects.forEach(obj => { if (!files.includes(obj)) { obj.element.style.display = 'none'; } }); if (folders.length === 0 && files.length === 0) { const emptyRow = document.createElement('tr'); emptyRow.innerHTML = `
Empty folder

This folder contains no objects.

`; objectsTableBody.appendChild(emptyRow); } if (typeof updateBulkDeleteState === 'function') { updateBulkDeleteState(); } }; const showMessage = ({ title = 'Notice', body = '', bodyHtml = null, variant = 'info', actionText = null, onAction = null }) => { if (!actionText && !onAction && window.showToast) { window.showToast(body || title, title, variant); return; } if (!messageModal) { window.alert(body || title); return; } document.querySelectorAll('.modal.show').forEach(modal => { const instance = bootstrap.Modal.getInstance(modal); if (instance && modal.id !== 'messageModal') { instance.hide(); } }); const iconEl = document.getElementById('messageModalIcon'); if (iconEl) { const iconPaths = { success: '', danger: '', warning: '', info: '' }; const iconColors = { success: 'text-success', danger: 'text-danger', warning: 'text-warning', info: 'text-primary' }; iconEl.innerHTML = iconPaths[variant] || iconPaths.info; iconEl.classList.remove('text-success', 'text-danger', 'text-warning', 'text-primary'); iconEl.classList.add(iconColors[variant] || 'text-primary'); } messageModalTitle.textContent = title; if (bodyHtml) { messageModalBody.innerHTML = bodyHtml; } else { messageModalBody.textContent = body; } messageModalActionHandler = null; const variantClass = { success: 'btn-success', danger: 'btn-danger', warning: 'btn-warning', info: 'btn-primary', }; Object.values(variantClass).forEach((cls) => messageModalAction.classList.remove(cls)); if (actionText && typeof onAction === 'function') { messageModalAction.textContent = actionText; messageModalAction.classList.remove('d-none'); messageModalAction.classList.add(variantClass[variant] || 'btn-primary'); messageModalActionHandler = onAction; } else { messageModalAction.classList.add('d-none'); } setTimeout(() => messageModal.show(), 150); }; messageModalAction?.addEventListener('click', () => { if (typeof messageModalActionHandler === 'function') { messageModalActionHandler(); } messageModal?.hide(); }); messageModalEl?.addEventListener('hidden.bs.modal', () => { messageModalActionHandler = null; messageModalAction.classList.add('d-none'); }); const normalizePolicyTemplate = (rawTemplate) => { if (!rawTemplate) { return ''; } try { let parsed = JSON.parse(rawTemplate); if (typeof parsed === 'string') { parsed = JSON.parse(parsed); } return JSON.stringify(parsed, null, 2); } catch { return rawTemplate; } }; let publicPolicyTemplate = normalizePolicyTemplate(policyTextarea?.dataset.publicTemplate || ''); let customPolicyDraft = policyTextarea?.value || ''; const setPolicyTextareaState = (readonly) => { if (!policyTextarea) return; if (readonly) { policyTextarea.setAttribute('readonly', 'readonly'); policyTextarea.classList.add('bg-body-secondary'); } else { policyTextarea.removeAttribute('readonly'); policyTextarea.classList.remove('bg-body-secondary'); } }; const policyReadonlyHint = document.getElementById('policyReadonlyHint'); const applyPolicyPreset = (preset) => { if (!policyTextarea || !policyMode) return; const isPresetMode = preset === 'private' || preset === 'public'; if (policyReadonlyHint) { policyReadonlyHint.classList.toggle('d-none', !isPresetMode); } switch (preset) { case 'private': setPolicyTextareaState(true); policyTextarea.value = ''; policyMode.value = 'delete'; break; case 'public': setPolicyTextareaState(true); policyTextarea.value = publicPolicyTemplate || ''; policyMode.value = 'upsert'; break; default: setPolicyTextareaState(false); policyTextarea.value = customPolicyDraft; policyMode.value = 'upsert'; break; } }; policyTextarea?.addEventListener('input', () => { if (policyPreset?.value === 'custom') { customPolicyDraft = policyTextarea.value; } }); const presetButtons = document.querySelectorAll('.preset-btn[data-preset]'); presetButtons.forEach(btn => { btn.addEventListener('click', () => { const preset = btn.dataset.preset; if (policyPreset) policyPreset.value = preset; presetButtons.forEach(b => b.classList.remove('active')); btn.classList.add('active'); applyPolicyPreset(preset); }); }); if (policyPreset) { applyPolicyPreset(policyPreset.value || policyPreset.dataset.default || 'custom'); } policyForm?.addEventListener('submit', () => { if (!policyMode || !policyPreset || !policyTextarea) { return; } if (policyPreset.value === 'private') { policyMode.value = 'delete'; policyTextarea.value = ''; } else if (policyPreset.value === 'public') { policyMode.value = 'upsert'; policyTextarea.value = publicPolicyTemplate || policyTextarea.value; } else { policyMode.value = 'upsert'; } }); const bulkActionsWrapper = document.getElementById('bulk-actions-wrapper'); const updateBulkDeleteState = () => { const selectedCount = selectedRows.size; if (bulkDeleteButton) { const shouldShow = Boolean(bulkDeleteEndpoint) && (selectedCount > 0 || bulkDeleting); bulkDeleteButton.disabled = !bulkDeleteEndpoint || selectedCount === 0 || bulkDeleting; if (bulkDeleteLabel) { bulkDeleteLabel.textContent = selectedCount ? `Delete (${selectedCount})` : 'Delete'; } if (bulkActionsWrapper) { bulkActionsWrapper.classList.toggle('d-none', !shouldShow); } } if (bulkDeleteConfirm) { bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting; } if (selectAllCheckbox) { const filesInView = visibleItems.filter(item => item.type === 'file'); const total = filesInView.length; const visibleSelectedCount = filesInView.filter(item => selectedRows.has(item.data.key)).length; selectAllCheckbox.disabled = total === 0; selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0; selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total; } }; function toggleRowSelection(row, shouldSelect) { if (!row || !row.dataset.key) return; if (shouldSelect) { selectedRows.set(row.dataset.key, row); } else { selectedRows.delete(row.dataset.key); } updateBulkDeleteState(); } const renderBulkDeletePreview = () => { if (!bulkDeleteList) return; const keys = Array.from(selectedRows.keys()); bulkDeleteList.innerHTML = ''; if (bulkDeleteCount) { const label = keys.length === 1 ? 'object' : 'objects'; bulkDeleteCount.textContent = `${keys.length} ${label} selected`; } if (!keys.length) { const empty = document.createElement('li'); empty.className = 'list-group-item py-2 small text-muted'; empty.textContent = 'No objects selected.'; bulkDeleteList.appendChild(empty); if (bulkDeleteStatus) { bulkDeleteStatus.textContent = ''; } return; } const preview = keys.slice(0, 6); preview.forEach((key) => { const item = document.createElement('li'); item.className = 'list-group-item py-1 small text-break'; item.textContent = key; bulkDeleteList.appendChild(item); }); if (bulkDeleteStatus) { bulkDeleteStatus.textContent = keys.length > preview.length ? `+${keys.length - preview.length} more not shown` : ''; } }; const openBulkDeleteModal = () => { if (!bulkDeleteModal) { return; } if (selectedRows.size === 0) { showMessage({ title: 'Select objects', body: 'Choose at least one object to delete.', variant: 'warning' }); return; } renderBulkDeletePreview(); if (bulkDeletePurge) { bulkDeletePurge.checked = false; } if (bulkDeleteConfirm) { bulkDeleteConfirm.disabled = bulkDeleting; bulkDeleteConfirm.textContent = bulkDeleting ? 'Deleting…' : 'Delete objects'; } bulkDeleteModal.show(); }; const performBulkDelete = async () => { if (!bulkDeleteEndpoint || selectedRows.size === 0 || !bulkDeleteConfirm) { return; } bulkDeleting = true; bulkDeleteConfirm.disabled = true; bulkDeleteConfirm.textContent = 'Deleting…'; updateBulkDeleteState(); const payload = { keys: Array.from(selectedRows.keys()), }; if (versioningEnabled && bulkDeletePurge?.checked) { payload.purge_versions = true; } try { const response = await fetch(bulkDeleteEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, body: JSON.stringify(payload), }); let data = {}; try { data = await response.json(); } catch { data = {}; } if (!response.ok || data.error) { throw new Error(data.error || data.message || 'Unable to delete selected objects'); } bulkDeleteModal?.hide(); const deletedCount = Array.isArray(data.deleted) ? data.deleted.length : selectedRows.size; const errorCount = Array.isArray(data.errors) ? data.errors.length : 0; const messageParts = []; if (deletedCount) { messageParts.push(`${deletedCount} deleted`); } if (errorCount) { messageParts.push(`${errorCount} failed`); } const summary = messageParts.length ? messageParts.join(', ') : 'Bulk delete finished'; showMessage({ title: 'Bulk delete complete', body: data.message || summary, variant: errorCount ? 'warning' : 'success' }); selectedRows.clear(); previewEmpty.classList.remove('d-none'); previewPanel.classList.add('d-none'); activeRow = null; loadObjects(false); } catch (error) { bulkDeleteModal?.hide(); showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete selected objects', variant: 'danger' }); } finally { bulkDeleting = false; if (bulkDeleteConfirm) { bulkDeleteConfirm.disabled = false; bulkDeleteConfirm.textContent = 'Delete objects'; } updateBulkDeleteState(); } }; const updateGeneratePresignState = () => { if (!generatePresignButton) return; if (isGeneratingPresign) { generatePresignButton.disabled = true; generatePresignButton.textContent = 'Generating…'; return; } generatePresignButton.textContent = 'Generate link'; generatePresignButton.disabled = !activeRow; }; const requestPresignedUrl = async () => { if (!activeRow) { showMessage({ title: 'Select an object', body: 'Choose an object before generating a presigned URL.', variant: 'warning' }); return; } const endpoint = activeRow.dataset.presignEndpoint; if (!endpoint) { showMessage({ title: 'Unavailable', body: 'Presign endpoint unavailable for this object.', variant: 'danger' }); return; } if (isGeneratingPresign) { return; } isGeneratingPresign = true; updateGeneratePresignState(); presignLink.value = ''; try { const payload = { method: presignMethod?.value || 'GET', expires_in: Number(presignTtl?.value) || 900, }; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Unable to generate presigned URL'); } presignLink.value = data.url; } catch (error) { presignModal?.hide(); showMessage({ title: 'Presign failed', body: (error && error.message) || 'Unable to generate presigned URL', variant: 'danger' }); } finally { isGeneratingPresign = false; updateGeneratePresignState(); } }; const renderMetadata = (metadata) => { if (!previewMetadata || !previewMetadataList) return; previewMetadataList.innerHTML = ''; if (!metadata || Object.keys(metadata).length === 0) { previewMetadata.classList.add('d-none'); return; } previewMetadata.classList.remove('d-none'); Object.entries(metadata).forEach(([key, value]) => { const wrapper = document.createElement('div'); wrapper.className = 'metadata-entry'; const label = document.createElement('div'); label.className = 'metadata-key small'; label.textContent = key; const val = document.createElement('div'); val.className = 'metadata-value text-break'; val.textContent = value; wrapper.appendChild(label); wrapper.appendChild(val); previewMetadataList.appendChild(wrapper); }); }; const describeVersionReason = (reason) => { switch (reason) { case 'delete': return 'delete marker'; case 'restore-overwrite': return 'restore overwrite'; default: return reason || 'update'; } }; const confirmVersionRestore = (row, version, label = null, onConfirm) => { if (!version) return; const timestamp = version.archived_at ? new Date(version.archived_at).toLocaleString() : version.version_id; const sizeLabel = formatBytes(Number(version.size) || 0); const reasonLabel = describeVersionReason(version.reason); const targetLabel = label || row?.dataset.key || 'this object'; const metadata = version.metadata && typeof version.metadata === 'object' ? Object.entries(version.metadata) : []; const metadataHtml = metadata.length ? `
Metadata

` : ''; const summaryHtml = `
Target: ${escapeHtml(targetLabel)}
Version ID: ${escapeHtml(version.version_id)}
Timestamp: ${escapeHtml(timestamp)}
Size: ${escapeHtml(sizeLabel)}
Reason: ${escapeHtml(reasonLabel)}
${metadataHtml} `; const fallbackText = `Restore ${targetLabel} from ${timestamp}? Size ${sizeLabel}. Reason: ${reasonLabel}.`; showMessage({ title: 'Restore archived version?', body: fallbackText, bodyHtml: summaryHtml, variant: 'warning', actionText: 'Restore version', onAction: () => { if (typeof onConfirm === 'function') { onConfirm(); } else { restoreVersion(row, version); } }, }); }; const updateArchivedCount = (count) => { if (!archivedCountBadge) return; const label = count === 1 ? 'item' : 'items'; archivedCountBadge.textContent = `${count} ${label}`; }; function renderArchivedRows(items) { if (!archivedBody) return; archivedBody.innerHTML = ''; if (!items || items.length === 0) { archivedBody.innerHTML = 'No archived-only objects.'; updateArchivedCount(0); return; } updateArchivedCount(items.length); items.forEach((item) => { const row = document.createElement('tr'); const keyCell = document.createElement('td'); const keyLabel = document.createElement('div'); keyLabel.className = 'fw-semibold text-break'; keyLabel.textContent = item.key; const badgeWrap = document.createElement('div'); badgeWrap.className = 'mt-1'; const badge = document.createElement('span'); badge.className = 'badge text-bg-warning'; badge.textContent = 'Archived'; badgeWrap.appendChild(badge); keyCell.appendChild(keyLabel); keyCell.appendChild(badgeWrap); const latestCell = document.createElement('td'); if (item.latest) { const ts = item.latest.archived_at ? new Date(item.latest.archived_at).toLocaleString() : item.latest.version_id; const sizeLabel = formatBytes(Number(item.latest.size) || 0); latestCell.innerHTML = `
${ts}
${sizeLabel} · ${describeVersionReason(item.latest.reason)}
`; } else { latestCell.innerHTML = 'Unknown'; } const countCell = document.createElement('td'); countCell.className = 'text-end text-muted'; countCell.textContent = item.versions; const actionsCell = document.createElement('td'); actionsCell.className = 'text-end'; const btnGroup = document.createElement('div'); btnGroup.className = 'btn-group btn-group-sm'; const restoreButton = document.createElement('button'); restoreButton.type = 'button'; restoreButton.className = 'btn btn-outline-primary'; restoreButton.textContent = 'Restore'; restoreButton.disabled = !item.latest || !item.restore_url; restoreButton.addEventListener('click', () => confirmVersionRestore(null, item.latest, item.key, () => restoreArchivedObject(item))); const purgeButton = document.createElement('button'); purgeButton.type = 'button'; purgeButton.className = 'btn btn-outline-danger'; purgeButton.textContent = 'Delete versions'; purgeButton.addEventListener('click', () => confirmArchivedPurge(item)); btnGroup.appendChild(restoreButton); btnGroup.appendChild(purgeButton); actionsCell.appendChild(btnGroup); row.appendChild(keyCell); row.appendChild(latestCell); row.appendChild(countCell); row.appendChild(actionsCell); archivedBody.appendChild(row); }); } async function restoreArchivedObject(item) { if (!item?.restore_url) return; try { const response = await fetch(item.restore_url, { method: 'POST' }); let data = {}; try { data = await response.json(); } catch { data = {}; } if (!response.ok) { throw new Error(data.error || 'Unable to restore archived object'); } showMessage({ title: 'Restore scheduled', body: data.message || 'Object restored from archive.', variant: 'success' }); await loadArchivedObjects(); loadObjects(false); } catch (error) { showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore archived object', variant: 'danger' }); } } async function purgeArchivedObject(item) { if (!item?.purge_url) return; try { const response = await fetch(item.purge_url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, }); let data = {}; try { data = await response.json(); } catch { data = {}; } if (!response.ok) { throw new Error(data.error || 'Unable to delete archived versions'); } showMessage({ title: 'Archived versions removed', body: data.message || 'All archived data for this key has been deleted.', variant: 'success' }); await loadArchivedObjects(); } catch (error) { showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete archived versions', variant: 'danger' }); } } function confirmArchivedPurge(item) { const label = item?.key || 'this object'; const count = item?.versions || 0; const countLabel = count === 1 ? 'version' : 'versions'; showMessage({ title: 'Delete archived versions?', body: `Permanently remove ${count} archived ${countLabel} for ${label}? This cannot be undone.`, variant: 'danger', actionText: 'Delete versions', onAction: () => purgeArchivedObject(item), }); } async function loadArchivedObjects() { if (!archivedEndpoint || !archivedBody) return; archivedBody.innerHTML = 'Loading…'; try { const response = await fetch(archivedEndpoint); let data = {}; try { data = await response.json(); } catch { data = {}; } if (!response.ok) { throw new Error(data.error || 'Unable to load archived objects'); } const items = Array.isArray(data.objects) ? data.objects : []; renderArchivedRows(items); } catch (error) { archivedBody.innerHTML = `${(error && error.message) || 'Unable to load archived objects'}`; updateArchivedCount(0); } } if (archivedRefreshButton) { archivedRefreshButton.addEventListener('click', () => loadArchivedObjects()); } if (archivedCard && archivedEndpoint) { loadArchivedObjects(); } async function restoreVersion(row, version) { if (!row || !version?.version_id) return; const template = row.dataset.restoreTemplate; if (!template) return; const url = template.replace('VERSION_ID_PLACEHOLDER', version.version_id); try { const response = await fetch(url, { method: 'POST' }); let data = {}; try { data = await response.json(); } catch { data = {}; } if (!response.ok) { throw new Error(data.error || 'Unable to restore version'); } const endpoint = row.dataset.versionsEndpoint; if (endpoint) { versionsCache.delete(endpoint); } await loadObjectVersions(row, { force: true }); showMessage({ title: 'Version restored', body: data.message || 'The selected version has been restored.', variant: 'success' }); loadObjects(false); } catch (error) { showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore version', variant: 'danger' }); } } function renderVersionEntries(entries, row) { if (!versionList) return; if (!entries || entries.length === 0) { versionList.innerHTML = '

No previous versions yet.

'; return; } versionList.innerHTML = ''; entries.forEach((entry, index) => { const versionNumber = index + 1; const item = document.createElement('div'); item.className = 'd-flex align-items-center justify-content-between py-2 border-bottom'; const textStack = document.createElement('div'); textStack.className = 'me-3'; const heading = document.createElement('div'); heading.className = 'd-flex align-items-center'; const badge = document.createElement('span'); badge.className = 'badge text-bg-secondary me-2'; badge.textContent = `#${versionNumber}`; const title = document.createElement('div'); title.className = 'fw-semibold small'; const timestamp = entry.archived_at ? new Date(entry.archived_at).toLocaleString() : entry.version_id; title.textContent = timestamp; heading.appendChild(badge); heading.appendChild(title); const meta = document.createElement('div'); meta.className = 'text-muted small'; const reason = describeVersionReason(entry.reason); const sizeLabel = formatBytes(Number(entry.size) || 0); meta.textContent = `${sizeLabel} · ${reason}`; textStack.appendChild(heading); textStack.appendChild(meta); const restoreButton = document.createElement('button'); restoreButton.type = 'button'; restoreButton.className = 'btn btn-outline-primary btn-sm'; restoreButton.textContent = 'Restore'; restoreButton.addEventListener('click', () => confirmVersionRestore(row, entry)); item.appendChild(textStack); item.appendChild(restoreButton); versionList.appendChild(item); }); } async function loadObjectVersions(row, { force = false } = {}) { if (!versionPanel || !versionList || !versioningEnabled) { versionPanel?.classList.add('d-none'); return; } if (!row) { versionPanel.classList.add('d-none'); return; } const endpoint = row.dataset.versionsEndpoint; if (!endpoint) { versionPanel.classList.add('d-none'); return; } versionPanel.classList.remove('d-none'); if (!force && versionsCache.has(endpoint)) { renderVersionEntries(versionsCache.get(endpoint), row); return; } versionList.innerHTML = '
Loading versions…
'; try { const response = await fetch(endpoint); let data = {}; try { data = await response.json(); } catch { data = {}; } if (!response.ok) { throw new Error(data.error || 'Unable to load versions'); } const entries = Array.isArray(data.versions) ? data.versions : []; versionsCache.set(endpoint, entries); renderVersionEntries(entries, row); } catch (error) { versionList.innerHTML = `

${(error && error.message) || 'Unable to load versions'}

`; } } renderMetadata(null); const deleteModalEl = document.getElementById('deleteObjectModal'); const deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null; const deleteObjectForm = document.getElementById('deleteObjectForm'); const deleteObjectKey = document.getElementById('deleteObjectKey'); if (deleteObjectForm) { deleteObjectForm.addEventListener('submit', async (e) => { e.preventDefault(); const submitBtn = deleteObjectForm.querySelector('[type="submit"]'); const originalHtml = submitBtn ? submitBtn.innerHTML : ''; try { if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = 'Deleting...'; } const formData = new FormData(deleteObjectForm); const csrfToken = formData.get('csrf_token') || (window.getCsrfToken ? window.getCsrfToken() : ''); const formAction = deleteObjectForm.getAttribute('action'); const response = await fetch(formAction, { method: 'POST', headers: { 'X-CSRFToken': csrfToken, 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: formData }); const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { throw new Error('Server returned an unexpected response. Please try again.'); } const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Unable to delete object'); } if (deleteModal) deleteModal.hide(); showMessage({ title: 'Object deleted', body: data.message || 'The object has been deleted.', variant: 'success' }); previewEmpty.classList.remove('d-none'); previewPanel.classList.add('d-none'); activeRow = null; loadObjects(false); } catch (err) { if (deleteModal) deleteModal.hide(); showMessage({ title: 'Delete failed', body: err.message || 'Unable to delete object', variant: 'danger' }); } finally { if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = originalHtml; } } }); } const resetPreviewMedia = () => { [previewImage, previewVideo, previewIframe].forEach((el) => { el.classList.add('d-none'); if (el.tagName === 'VIDEO') { el.pause(); el.removeAttribute('src'); } if (el.tagName === 'IFRAME') { el.setAttribute('src', 'about:blank'); } }); previewPlaceholder.classList.remove('d-none'); }; function metadataFromRow(row) { if (!row || !row.dataset.metadata) { return null; } try { const parsed = JSON.parse(row.dataset.metadata); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed; } } catch (err) { console.warn('Failed to parse metadata for row', err); } return null; } function selectRow(row) { document.querySelectorAll('[data-object-row]').forEach((r) => r.classList.remove('table-active')); row.classList.add('table-active'); previewEmpty.classList.add('d-none'); previewPanel.classList.remove('d-none'); activeRow = row; renderMetadata(metadataFromRow(row)); previewKey.textContent = row.dataset.key; previewSize.textContent = formatBytes(Number(row.dataset.size)); previewModified.textContent = row.dataset.lastModified; previewEtag.textContent = row.dataset.etag; downloadButton.href = row.dataset.downloadUrl; downloadButton.classList.remove('disabled'); if (presignButton) { presignButton.dataset.endpoint = row.dataset.presignEndpoint; presignButton.disabled = false; } if (generatePresignButton) { generatePresignButton.disabled = false; } updateGeneratePresignState(); if (versioningEnabled) { loadObjectVersions(row); } resetPreviewMedia(); const previewUrl = row.dataset.previewUrl; const lower = row.dataset.key.toLowerCase(); if (lower.match(/\.(png|jpg|jpeg|gif|webp|svg)$/)) { previewImage.src = previewUrl; previewImage.classList.remove('d-none'); previewPlaceholder.classList.add('d-none'); } else if (lower.match(/\.(mp4|webm|ogg)$/)) { previewVideo.src = previewUrl; previewVideo.classList.remove('d-none'); previewPlaceholder.classList.add('d-none'); } else if (lower.match(/\.(txt|log|json|md|csv)$/)) { previewIframe.src = previewUrl; previewIframe.classList.remove('d-none'); previewPlaceholder.classList.add('d-none'); } } updateBulkDeleteState(); function initFolderNavigation() { if (hasFolders()) { renderBreadcrumb(currentPrefix); renderObjectsView(); } if (typeof updateFolderViewStatus === 'function') { updateFolderViewStatus(); } if (typeof updateFilterWarning === 'function') { updateFilterWarning(); } } bulkDeleteButton?.addEventListener('click', () => openBulkDeleteModal()); bulkDeleteConfirm?.addEventListener('click', () => performBulkDelete()); const filterWarning = document.getElementById('filter-warning'); const filterWarningText = document.getElementById('filter-warning-text'); const folderViewStatus = document.getElementById('folder-view-status'); const updateFilterWarning = () => { if (!filterWarning) return; const isFiltering = currentFilterTerm.length > 0; if (isFiltering && hasMoreObjects) { filterWarning.classList.remove('d-none'); } else { filterWarning.classList.add('d-none'); } }; document.getElementById('object-search')?.addEventListener('input', (event) => { currentFilterTerm = event.target.value.toLowerCase(); updateFilterWarning(); refreshVirtualList(); }); refreshVersionsButton?.addEventListener('click', () => { if (!activeRow) { versionList.innerHTML = '

Select an object to view versions.

'; return; } const endpoint = activeRow.dataset.versionsEndpoint; if (endpoint) { versionsCache.delete(endpoint); } loadObjectVersions(activeRow, { force: true }); }); presignButton?.addEventListener('click', () => { if (!activeRow) { showMessage({ title: 'Select an object', body: 'Choose an object before generating a presigned URL.', variant: 'warning' }); return; } presignLink.value = ''; presignModal?.show(); requestPresignedUrl(); }); generatePresignButton?.addEventListener('click', () => { requestPresignedUrl(); }); copyPresignLink?.addEventListener('click', async () => { if (!presignLink?.value) { return; } const fallbackCopy = (text) => { const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); let success = false; try { success = document.execCommand('copy'); } catch (err) { success = false; } textArea.remove(); return success; }; let copied = false; if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(presignLink.value); copied = true; } catch (error) { } } if (!copied) { copied = fallbackCopy(presignLink.value); } if (copied) { copyPresignLink.textContent = 'Copied!'; window.setTimeout(() => { copyPresignLink.textContent = copyPresignDefaultLabel; }, 1500); } else { showMessage({ title: 'Copy Failed', body: 'Unable to copy link to clipboard. Please select the link and copy manually.', variant: 'warning' }); } }); if (uploadForm && uploadFileInput) { const uploadSubmitBtn = document.getElementById('uploadSubmitBtn'); const uploadCancelBtn = document.getElementById('uploadCancelBtn'); const uploadBtnText = document.getElementById('uploadBtnText'); const bulkUploadProgress = document.getElementById('bulkUploadProgress'); const bulkUploadStatus = document.getElementById('bulkUploadStatus'); const bulkUploadCounter = document.getElementById('bulkUploadCounter'); const bulkUploadProgressBar = document.getElementById('bulkUploadProgressBar'); const bulkUploadCurrentFile = document.getElementById('bulkUploadCurrentFile'); const bulkUploadResults = document.getElementById('bulkUploadResults'); const bulkUploadSuccessAlert = document.getElementById('bulkUploadSuccessAlert'); const bulkUploadErrorAlert = document.getElementById('bulkUploadErrorAlert'); const bulkUploadSuccessCount = document.getElementById('bulkUploadSuccessCount'); const bulkUploadErrorCount = document.getElementById('bulkUploadErrorCount'); const bulkUploadErrorList = document.getElementById('bulkUploadErrorList'); const uploadKeyPrefix = document.getElementById('uploadKeyPrefix'); const singleFileOptions = document.getElementById('singleFileOptions'); const floatingProgress = document.getElementById('floatingUploadProgress'); const floatingProgressBar = document.getElementById('floatingUploadProgressBar'); const floatingProgressStatus = document.getElementById('floatingUploadStatus'); const floatingProgressTitle = document.getElementById('floatingUploadTitle'); const floatingProgressExpand = document.getElementById('floatingUploadExpand'); const floatingProgressCancel = document.getElementById('floatingUploadCancel'); const uploadQueueContainer = document.getElementById('uploadQueueContainer'); const uploadQueueList = document.getElementById('uploadQueueList'); const uploadQueueCount = document.getElementById('uploadQueueCount'); const clearUploadQueueBtn = document.getElementById('clearUploadQueueBtn'); let isUploading = false; let uploadQueue = []; let activeXHRs = []; let activeMultipartUpload = null; let uploadCancelled = false; let uploadStats = { totalFiles: 0, completedFiles: 0, totalBytes: 0, uploadedBytes: 0, currentFileBytes: 0, currentFileLoaded: 0, currentFileName: '' }; window.addEventListener('beforeunload', (e) => { if (isUploading) { e.preventDefault(); e.returnValue = 'Upload in progress. Are you sure you want to leave?'; return e.returnValue; } }); const showFloatingProgress = () => { if (floatingProgress) { floatingProgress.classList.remove('d-none'); } }; const hideFloatingProgress = () => { if (floatingProgress) { floatingProgress.classList.add('d-none'); } }; const updateFloatingProgress = () => { const { totalFiles, completedFiles, totalBytes, uploadedBytes, currentFileLoaded, currentFileName } = uploadStats; const effectiveUploaded = uploadedBytes + currentFileLoaded; if (floatingProgressBar && totalBytes > 0) { const percent = Math.round((effectiveUploaded / totalBytes) * 100); floatingProgressBar.style.width = `${percent}%`; } if (floatingProgressStatus) { const bytesText = `${formatBytes(effectiveUploaded)} / ${formatBytes(totalBytes)}`; const queuedCount = uploadQueue.length; let statusText = `${completedFiles}/${totalFiles} files`; if (queuedCount > 0) { statusText += ` (+${queuedCount} queued)`; } statusText += ` • ${bytesText}`; floatingProgressStatus.textContent = statusText; } if (floatingProgressTitle) { const remaining = totalFiles - completedFiles; const queuedCount = uploadQueue.length; let title = `Uploading ${remaining} file${remaining !== 1 ? 's' : ''}`; if (queuedCount > 0) { title += ` (+${queuedCount} queued)`; } floatingProgressTitle.textContent = title + '...'; } }; floatingProgressExpand?.addEventListener('click', () => { if (uploadModal) { uploadModal.show(); } }); const cancelAllUploads = async () => { uploadCancelled = true; activeXHRs.forEach(xhr => { try { xhr.abort(); } catch {} }); activeXHRs = []; if (activeMultipartUpload) { const { abortUrl } = activeMultipartUpload; const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; try { await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } }); } catch {} activeMultipartUpload = null; } uploadQueue = []; isProcessingQueue = false; isUploading = false; setUploadLockState(false); hideFloatingProgress(); resetUploadUI(); showMessage({ title: 'Upload cancelled', body: 'All uploads have been cancelled.', variant: 'info' }); loadObjects(false); }; floatingProgressCancel?.addEventListener('click', () => { cancelAllUploads(); }); const refreshUploadDropLabel = () => { if (!uploadDropZoneLabel) return; if (isUploading) { uploadDropZoneLabel.textContent = 'Drop files here to add to queue'; if (singleFileOptions) singleFileOptions.classList.add('d-none'); return; } const files = uploadFileInput.files; if (!files || files.length === 0) { uploadDropZoneLabel.textContent = 'No file selected'; if (singleFileOptions) singleFileOptions.classList.remove('d-none'); return; } uploadDropZoneLabel.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`; if (singleFileOptions) { singleFileOptions.classList.toggle('d-none', files.length > 1); } }; const updateUploadBtnText = () => { if (!uploadBtnText) return; if (isUploading) { const files = uploadFileInput.files; if (files && files.length > 0) { uploadBtnText.textContent = `Add ${files.length} to queue`; if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; } else { uploadBtnText.textContent = 'Uploading...'; } return; } const files = uploadFileInput.files; if (!files || files.length <= 1) { uploadBtnText.textContent = 'Upload'; } else { uploadBtnText.textContent = `Upload ${files.length} files`; } }; const resetUploadUI = () => { if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none'); if (bulkUploadResults) bulkUploadResults.classList.add('d-none'); if (bulkUploadSuccessAlert) bulkUploadSuccessAlert.classList.remove('d-none'); if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.add('d-none'); if (bulkUploadErrorList) bulkUploadErrorList.innerHTML = ''; if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; if (uploadFileInput) uploadFileInput.disabled = false; const progressStack = document.querySelector('[data-upload-progress]'); if (progressStack) progressStack.innerHTML = ''; if (uploadDropZone) { uploadDropZone.classList.remove('upload-locked'); uploadDropZone.style.pointerEvents = ''; } isUploading = false; hideFloatingProgress(); }; const MULTIPART_THRESHOLD = 8 * 1024 * 1024; const CHUNK_SIZE = 8 * 1024 * 1024; const uploadProgressStack = document.querySelector('[data-upload-progress]'); const multipartInitUrl = uploadForm.dataset.multipartInitUrl; const multipartPartTemplate = uploadForm.dataset.multipartPartTemplate; const multipartCompleteTemplate = uploadForm.dataset.multipartCompleteTemplate; const multipartAbortTemplate = uploadForm.dataset.multipartAbortTemplate; const createProgressItem = (file) => { const item = document.createElement('div'); item.className = 'upload-progress-item'; item.dataset.state = 'uploading'; item.innerHTML = `
${escapeHtml(file.name)}
${formatBytes(file.size)}
Preparing...
0 B 0%
`; return item; }; const updateProgressItem = (item, { loaded, total, status, state, error }) => { if (state) item.dataset.state = state; const statusEl = item.querySelector('.upload-status'); const progressBar = item.querySelector('.progress-bar'); const progressLoaded = item.querySelector('.progress-loaded'); const progressPercent = item.querySelector('.progress-percent'); if (status) { statusEl.textContent = status; statusEl.className = 'upload-status text-end ms-2'; if (state === 'success') statusEl.classList.add('success'); if (state === 'error') statusEl.classList.add('error'); } if (typeof loaded === 'number' && typeof total === 'number' && total > 0) { const percent = Math.round((loaded / total) * 100); progressBar.style.width = `${percent}%`; progressLoaded.textContent = `${formatBytes(loaded)} / ${formatBytes(total)}`; progressPercent.textContent = `${percent}%`; } if (error) { const progressContainer = item.querySelector('.progress-container'); if (progressContainer) { progressContainer.innerHTML = `
${escapeHtml(error)}
`; } } }; const uploadMultipart = async (file, objectKey, metadata, progressItem) => { const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; if (uploadCancelled) throw new Error('Upload cancelled'); updateProgressItem(progressItem, { status: 'Initiating...', loaded: 0, total: file.size }); const initResp = await fetch(multipartInitUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' }, body: JSON.stringify({ object_key: objectKey, metadata }) }); if (!initResp.ok) { const err = await initResp.json().catch(() => ({})); throw new Error(err.error || 'Failed to initiate upload'); } const { upload_id } = await initResp.json(); const partUrl = multipartPartTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id); const completeUrl = multipartCompleteTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id); const abortUrl = multipartAbortTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id); activeMultipartUpload = { upload_id, abortUrl }; const parts = []; const totalParts = Math.ceil(file.size / CHUNK_SIZE); let uploadedBytes = 0; try { for (let partNumber = 1; partNumber <= totalParts; partNumber++) { if (uploadCancelled) throw new Error('Upload cancelled'); const start = (partNumber - 1) * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); updateProgressItem(progressItem, { status: `Part ${partNumber}/${totalParts}`, loaded: uploadedBytes, total: file.size }); uploadStats.currentFileLoaded = uploadedBytes; updateFloatingProgress(); const partResp = await fetch(`${partUrl}?partNumber=${partNumber}`, { method: 'PUT', headers: { 'X-CSRFToken': csrfToken || '', 'Content-Type': 'application/octet-stream' }, body: chunk }); if (uploadCancelled) throw new Error('Upload cancelled'); if (!partResp.ok) { const err = await partResp.json().catch(() => ({})); throw new Error(err.error || `Part ${partNumber} failed`); } const partData = await partResp.json(); parts.push({ part_number: partNumber, etag: partData.etag }); uploadedBytes += chunk.size; updateProgressItem(progressItem, { loaded: uploadedBytes, total: file.size }); uploadStats.currentFileLoaded = uploadedBytes; updateFloatingProgress(); } updateProgressItem(progressItem, { status: 'Completing...', loaded: file.size, total: file.size }); const completeResp = await fetch(completeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' }, body: JSON.stringify({ parts }) }); if (!completeResp.ok) { const err = await completeResp.json().catch(() => ({})); throw new Error(err.error || 'Failed to complete upload'); } activeMultipartUpload = null; return await completeResp.json(); } catch (err) { if (!uploadCancelled) { try { await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } }); } catch {} } activeMultipartUpload = null; throw err; } }; const uploadRegular = async (file, objectKey, metadata, progressItem) => { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('object', file); formData.append('object_key', objectKey); if (metadata) formData.append('metadata', JSON.stringify(metadata)); const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; if (csrfToken) formData.append('csrf_token', csrfToken); const xhr = new XMLHttpRequest(); activeXHRs.push(xhr); xhr.open('POST', uploadForm.action, true); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); const removeXHR = () => { const idx = activeXHRs.indexOf(xhr); if (idx > -1) activeXHRs.splice(idx, 1); }; xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { updateProgressItem(progressItem, { status: 'Uploading...', loaded: e.loaded, total: e.total }); uploadStats.currentFileLoaded = e.loaded; updateFloatingProgress(); } }); xhr.addEventListener('load', () => { removeXHR(); if (xhr.status >= 200 && xhr.status < 300) { try { const data = JSON.parse(xhr.responseText); if (data.status === 'error') { reject(new Error(data.message || 'Upload failed')); } else { resolve(data); } } catch { resolve({}); } } else { try { const data = JSON.parse(xhr.responseText); reject(new Error(data.message || `Upload failed (${xhr.status})`)); } catch { reject(new Error(`Upload failed (${xhr.status})`)); } } }); xhr.addEventListener('error', () => { removeXHR(); reject(new Error('Network error')); }); xhr.addEventListener('abort', () => { removeXHR(); reject(new Error('Upload cancelled')); }); xhr.send(formData); }); }; const uploadSingleFile = async (file, keyPrefix = '', metadata = null, progressItem = null) => { const objectKey = keyPrefix ? `${keyPrefix}${file.name}` : file.name; const shouldUseMultipart = file.size >= MULTIPART_THRESHOLD && multipartInitUrl; if (!progressItem && uploadProgressStack) { progressItem = createProgressItem(file); uploadProgressStack.appendChild(progressItem); } try { let result; if (shouldUseMultipart) { updateProgressItem(progressItem, { status: 'Multipart upload...', loaded: 0, total: file.size }); result = await uploadMultipart(file, objectKey, metadata, progressItem); } else { updateProgressItem(progressItem, { status: 'Uploading...', loaded: 0, total: file.size }); result = await uploadRegular(file, objectKey, metadata, progressItem); } updateProgressItem(progressItem, { state: 'success', status: 'Complete', loaded: file.size, total: file.size }); return result; } catch (err) { updateProgressItem(progressItem, { state: 'error', status: 'Failed', error: err.message }); throw err; } }; const setUploadLockState = (locked) => { if (uploadDropZone) { uploadDropZone.classList.toggle('upload-locked', locked); } }; let uploadSuccessFiles = []; let uploadErrorFiles = []; let isProcessingQueue = false; const updateQueueListDisplay = () => { if (!uploadQueueList || !uploadQueueContainer || !uploadQueueCount) return; if (uploadQueue.length === 0) { uploadQueueContainer.classList.add('d-none'); return; } uploadQueueContainer.classList.remove('d-none'); uploadQueueCount.textContent = uploadQueue.length; uploadQueueList.innerHTML = uploadQueue.map((item, idx) => `
  • ${escapeHtml(item.file.name)} ${formatBytes(item.file.size)}
  • `).join(''); }; const addFilesToQueue = (files, keyPrefix, metadata) => { for (const file of files) { uploadQueue.push({ file, keyPrefix, metadata }); uploadStats.totalFiles++; uploadStats.totalBytes += file.size; } updateFloatingProgress(); updateQueueListDisplay(); }; const clearUploadQueue = () => { const clearedCount = uploadQueue.length; if (clearedCount === 0) return; for (const item of uploadQueue) { uploadStats.totalFiles--; uploadStats.totalBytes -= item.file.size; } uploadQueue.length = 0; updateFloatingProgress(); updateQueueListDisplay(); }; if (clearUploadQueueBtn) { clearUploadQueueBtn.addEventListener('click', clearUploadQueue); } const processUploadQueue = async () => { if (isProcessingQueue) return; isProcessingQueue = true; while (uploadQueue.length > 0 && !uploadCancelled) { const item = uploadQueue.shift(); const { file, keyPrefix, metadata } = item; updateQueueListDisplay(); uploadStats.currentFileName = file.name; uploadStats.currentFileBytes = file.size; uploadStats.currentFileLoaded = 0; if (bulkUploadCounter) { const queuedCount = uploadQueue.length; let counterText = `${uploadStats.completedFiles + 1}/${uploadStats.totalFiles}`; if (queuedCount > 0) { counterText += ` (+${queuedCount} queued)`; } bulkUploadCounter.textContent = counterText; } if (bulkUploadCurrentFile) { bulkUploadCurrentFile.textContent = `Uploading: ${file.name}`; } if (bulkUploadProgressBar) { const percent = Math.round(((uploadStats.completedFiles + 1) / uploadStats.totalFiles) * 100); bulkUploadProgressBar.style.width = `${percent}%`; } updateFloatingProgress(); try { await uploadSingleFile(file, keyPrefix, metadata); uploadSuccessFiles.push(file.name); } catch (error) { uploadErrorFiles.push({ name: file.name, error: error.message || 'Unknown error' }); } uploadStats.uploadedBytes += file.size; uploadStats.completedFiles++; uploadStats.currentFileLoaded = 0; updateFloatingProgress(); } isProcessingQueue = false; if (uploadQueue.length === 0 && !uploadCancelled) { finishUploadSession(); } }; const finishUploadSession = () => { if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none'); if (bulkUploadResults) bulkUploadResults.classList.remove('d-none'); hideFloatingProgress(); if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = uploadSuccessFiles.length; if (uploadSuccessFiles.length === 0 && bulkUploadSuccessAlert) { bulkUploadSuccessAlert.classList.add('d-none'); } if (uploadErrorFiles.length > 0) { if (bulkUploadErrorCount) bulkUploadErrorCount.textContent = uploadErrorFiles.length; if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.remove('d-none'); if (bulkUploadErrorList) { bulkUploadErrorList.innerHTML = uploadErrorFiles .map(f => `
  • ${escapeHtml(f.name)}: ${escapeHtml(f.error)}
  • `) .join(''); } } isUploading = false; setUploadLockState(false); refreshUploadDropLabel(); updateUploadBtnText(); updateQueueListDisplay(); if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; if (uploadFileInput) { uploadFileInput.disabled = false; uploadFileInput.value = ''; } loadObjects(false); const successCount = uploadSuccessFiles.length; const errorCount = uploadErrorFiles.length; if (successCount > 0 && errorCount > 0) { showMessage({ title: 'Upload complete', body: `${successCount} uploaded, ${errorCount} failed.`, variant: 'warning' }); } else if (successCount > 0) { showMessage({ title: 'Upload complete', body: `${successCount} object(s) uploaded successfully.`, variant: 'success' }); } else if (errorCount > 0) { showMessage({ title: 'Upload failed', body: `${errorCount} file(s) failed to upload.`, variant: 'danger' }); } }; const performBulkUpload = async (files) => { if (!files || files.length === 0) return; const keyPrefix = (uploadKeyPrefix?.value || '').trim(); const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim(); let metadata = null; if (metadataRaw) { try { metadata = JSON.parse(metadataRaw); } catch { showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' }); return; } } if (!isUploading) { isUploading = true; uploadCancelled = false; uploadSuccessFiles = []; uploadErrorFiles = []; uploadStats = { totalFiles: 0, completedFiles: 0, totalBytes: 0, uploadedBytes: 0, currentFileBytes: 0, currentFileLoaded: 0, currentFileName: '' }; if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none'); if (bulkUploadResults) bulkUploadResults.classList.add('d-none'); if (uploadSubmitBtn) uploadSubmitBtn.disabled = true; refreshUploadDropLabel(); updateUploadBtnText(); if (uploadModal) uploadModal.hide(); showFloatingProgress(); } const fileCount = files.length; addFilesToQueue(Array.from(files), keyPrefix, metadata); if (uploadFileInput) { uploadFileInput.value = ''; } refreshUploadDropLabel(); updateUploadBtnText(); processUploadQueue(); }; refreshUploadDropLabel(); uploadFileInput.addEventListener('change', () => { refreshUploadDropLabel(); updateUploadBtnText(); if (!isUploading) { resetUploadUI(); } }); uploadDropZone?.addEventListener('click', () => { uploadFileInput?.click(); }); uploadForm.addEventListener('submit', async (event) => { const files = uploadFileInput.files; if (!files || files.length === 0) return; const keyPrefix = (uploadKeyPrefix?.value || '').trim(); if (files.length === 1 && !keyPrefix) { const customKey = uploadForm.querySelector('input[name="object_key"]')?.value?.trim(); if (customKey) { if (uploadSubmitBtn) { uploadSubmitBtn.disabled = true; if (uploadBtnText) uploadBtnText.textContent = 'Uploading...'; } return; } } event.preventDefault(); if (uploadSubmitBtn) { uploadSubmitBtn.disabled = true; if (uploadBtnText) uploadBtnText.textContent = 'Uploading...'; } await performBulkUpload(Array.from(files)); }); uploadModalEl?.addEventListener('show.bs.modal', () => { if (hasFolders() && currentPrefix) { uploadKeyPrefix.value = currentPrefix; const advancedToggle = document.querySelector('[data-bs-target="#advancedUploadOptions"]'); const advancedCollapse = document.getElementById('advancedUploadOptions'); if (advancedToggle && advancedCollapse && !advancedCollapse.classList.contains('show')) { new bootstrap.Collapse(advancedCollapse, { show: true }); } } else if (uploadKeyPrefix) { uploadKeyPrefix.value = ''; } }); uploadModalEl?.addEventListener('hide.bs.modal', (event) => { if (isUploading) { showFloatingProgress(); } }); uploadModalEl?.addEventListener('hidden.bs.modal', () => { if (!isUploading) { resetUploadUI(); uploadFileInput.value = ''; refreshUploadDropLabel(); updateUploadBtnText(); } }); uploadModalEl?.addEventListener('show.bs.modal', () => { if (isUploading) { hideFloatingProgress(); } }); const preventDefaults = (event) => { event.preventDefault(); event.stopPropagation(); }; const wireDropTarget = (target, { highlightClass = '', autoOpenModal = false } = {}) => { if (!target) return; ['dragenter', 'dragover'].forEach((eventName) => { target.addEventListener(eventName, (event) => { preventDefaults(event); if (highlightClass) { target.classList.add(highlightClass); } }); }); ['dragleave', 'drop'].forEach((eventName) => { target.addEventListener(eventName, (event) => { preventDefaults(event); if (highlightClass) { target.classList.remove(highlightClass); } }); }); target.addEventListener('drop', (event) => { if (!event.dataTransfer?.files?.length) { return; } if (isUploading) { performBulkUpload(event.dataTransfer.files); } else { if (uploadFileInput) { uploadFileInput.files = event.dataTransfer.files; uploadFileInput.dispatchEvent(new Event('change', { bubbles: true })); } if (autoOpenModal && uploadModal) { uploadModal.show(); } } }); }; if (uploadDropZone) { wireDropTarget(uploadDropZone, { highlightClass: 'is-dragover' }); } if (objectsContainer) { wireDropTarget(objectsContainer, { highlightClass: 'drag-over', autoOpenModal: true }); } } const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]'); const bulkDownloadEndpoint = document.getElementById('objects-drop-zone')?.dataset.bulkDownloadEndpoint; const updateBulkDownloadState = () => { if (!bulkDownloadButton) return; const selectedCount = document.querySelectorAll('[data-object-select]:checked').length; bulkDownloadButton.disabled = selectedCount === 0; }; selectAllCheckbox?.addEventListener('change', (event) => { const shouldSelect = Boolean(event.target?.checked); const filesInView = visibleItems.filter(item => item.type === 'file'); filesInView.forEach(item => { if (shouldSelect) { selectedRows.set(item.data.key, item.data); } else { selectedRows.delete(item.data.key); } }); document.querySelectorAll('[data-folder-select]').forEach(cb => { cb.checked = shouldSelect; }); document.querySelectorAll('[data-object-row]').forEach((row) => { const checkbox = row.querySelector('[data-object-select]'); if (checkbox) { checkbox.checked = shouldSelect; } }); updateBulkDeleteState(); setTimeout(updateBulkDownloadState, 0); }); bulkDownloadButton?.addEventListener('click', async () => { if (!bulkDownloadEndpoint) return; const selected = Array.from(selectedRows.keys()); if (selected.length === 0) return; bulkDownloadButton.disabled = true; const originalHtml = bulkDownloadButton.innerHTML; bulkDownloadButton.innerHTML = ' Downloading...'; try { const response = await fetch(bulkDownloadEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '', }, body: JSON.stringify({ keys: selected }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'Download failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${document.getElementById('objects-drop-zone').dataset.bucket}-download.zip`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); a.remove(); } catch (error) { showMessage({ title: 'Download Failed', body: error.message, variant: 'danger' }); } finally { bulkDownloadButton.disabled = false; bulkDownloadButton.innerHTML = originalHtml; } }); const replicationStatsContainer = document.getElementById('replication-stats-cards'); if (replicationStatsContainer) { const statusEndpoint = replicationStatsContainer.dataset.statusEndpoint; const syncedEl = replicationStatsContainer.querySelector('[data-stat="synced"]'); const pendingEl = replicationStatsContainer.querySelector('[data-stat="pending"]'); const orphanedEl = replicationStatsContainer.querySelector('[data-stat="orphaned"]'); const bytesEl = replicationStatsContainer.querySelector('[data-stat="bytes"]'); const lastSyncEl = document.getElementById('replication-last-sync'); const lastSyncTimeEl = document.querySelector('[data-stat="last-sync-time"]'); const lastSyncKeyEl = document.querySelector('[data-stat="last-sync-key"]'); const endpointWarning = document.getElementById('replication-endpoint-warning'); const endpointErrorEl = document.getElementById('replication-endpoint-error'); const statusAlert = document.getElementById('replication-status-alert'); const statusBadge = document.getElementById('replication-status-badge'); const statusText = document.getElementById('replication-status-text'); const pauseForm = document.getElementById('pause-replication-form'); const loadReplicationStats = async () => { try { const resp = await fetch(statusEndpoint); if (!resp.ok) throw new Error('Failed to fetch stats'); const data = await resp.json(); // Handle endpoint health status if (data.endpoint_healthy === false) { // Show warning and hide success alert if (endpointWarning) { endpointWarning.classList.remove('d-none'); if (endpointErrorEl && data.endpoint_error) { endpointErrorEl.textContent = data.endpoint_error + '. Replication is paused until the endpoint is available.'; } } if (statusAlert) statusAlert.classList.add('d-none'); // Update status badge to show "Paused" with warning styling if (statusBadge) { statusBadge.className = 'badge bg-warning-subtle text-warning px-3 py-2'; statusBadge.innerHTML = ` Paused (Endpoint Unavailable)`; } // Hide the pause button since replication is effectively already paused if (pauseForm) pauseForm.classList.add('d-none'); } else { // Hide warning and show success alert if (endpointWarning) endpointWarning.classList.add('d-none'); if (statusAlert) statusAlert.classList.remove('d-none'); // Restore status badge to show "Enabled" if (statusBadge) { statusBadge.className = 'badge bg-success-subtle text-success px-3 py-2'; statusBadge.innerHTML = ` Enabled`; } // Show the pause button if (pauseForm) pauseForm.classList.remove('d-none'); } if (syncedEl) syncedEl.textContent = data.objects_synced; if (pendingEl) { pendingEl.textContent = data.objects_pending; if (data.objects_pending > 0) pendingEl.classList.add('text-warning'); } if (orphanedEl) orphanedEl.textContent = data.objects_orphaned; if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced); if (data.last_sync_at && lastSyncEl) { lastSyncEl.style.display = ''; const date = new Date(data.last_sync_at * 1000); if (lastSyncTimeEl) lastSyncTimeEl.textContent = date.toLocaleString(); if (lastSyncKeyEl && data.last_sync_key) { lastSyncKeyEl.innerHTML = ' — ' + escapeHtml(data.last_sync_key) + ''; } } } catch (err) { console.error('Failed to load replication stats:', err); if (syncedEl) syncedEl.textContent = '—'; if (pendingEl) pendingEl.textContent = '—'; if (orphanedEl) orphanedEl.textContent = '—'; if (bytesEl) bytesEl.textContent = '—'; } }; loadReplicationStats(); if (window.pollingManager) { window.pollingManager.start('replication', loadReplicationStats); } const refreshBtn = document.querySelector('[data-refresh-replication]'); refreshBtn?.addEventListener('click', () => { if (syncedEl) syncedEl.innerHTML = ''; if (pendingEl) pendingEl.innerHTML = ''; if (orphanedEl) orphanedEl.innerHTML = ''; if (bytesEl) bytesEl.innerHTML = ''; loadReplicationStats(); loadReplicationFailures(); }); const failuresCard = document.getElementById('replication-failures-card'); const failuresBody = document.getElementById('replication-failures-body'); const failureCountBadge = document.getElementById('replication-failure-count'); const retryAllBtn = document.getElementById('retry-all-failures-btn'); const clearFailuresBtn = document.getElementById('clear-failures-btn'); const showMoreFailuresBtn = document.getElementById('show-more-failures'); const failuresPagination = document.getElementById('replication-failures-pagination'); const failuresShownCount = document.getElementById('failures-shown-count'); const clearFailuresModal = document.getElementById('clearFailuresModal'); const confirmClearFailuresBtn = document.getElementById('confirmClearFailuresBtn'); const clearFailuresModalInstance = clearFailuresModal ? new bootstrap.Modal(clearFailuresModal) : null; let failuresExpanded = false; let currentFailures = []; const loadReplicationFailures = async () => { if (!failuresCard) return; const endpoint = failuresCard.dataset.failuresEndpoint; const limit = failuresExpanded ? 50 : 5; try { const resp = await fetch(`${endpoint}?limit=${limit}`); if (!resp.ok) throw new Error('Failed to fetch failures'); const data = await resp.json(); currentFailures = data.failures; const total = data.total; if (total > 0) { failuresCard.style.display = ''; failureCountBadge.textContent = total; renderFailures(currentFailures); if (total > 5 && !failuresExpanded) { failuresPagination.style.display = ''; failuresShownCount.textContent = `Showing ${Math.min(5, total)} of ${total}`; } else { failuresPagination.style.display = 'none'; } } else { failuresCard.style.display = 'none'; } } catch (err) { console.error('Failed to load replication failures:', err); } }; const renderFailures = (failures) => { if (!failuresBody) return; failuresBody.innerHTML = failures.map(f => ` ${escapeHtml(f.object_key)} ${escapeHtml(f.error_message)} ${new Date(f.timestamp * 1000).toLocaleString()} ${f.failure_count} `).join(''); }; window.retryFailure = async (btn, objectKey) => { const originalHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; const endpoint = failuresCard.dataset.retryEndpoint.replace('__KEY__', encodeURIComponent(objectKey)); try { const resp = await fetch(endpoint, { method: 'POST' }); if (resp.ok) { loadReplicationFailures(); } } catch (err) { console.error('Failed to retry:', err); btn.disabled = false; btn.innerHTML = originalHtml; } }; window.dismissFailure = async (btn, objectKey) => { const originalHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; const endpoint = failuresCard.dataset.dismissEndpoint.replace('__KEY__', encodeURIComponent(objectKey)); try { const resp = await fetch(endpoint, { method: 'DELETE' }); if (resp.ok) { loadReplicationFailures(); } } catch (err) { console.error('Failed to dismiss:', err); btn.disabled = false; btn.innerHTML = originalHtml; } }; retryAllBtn?.addEventListener('click', async () => { const btn = retryAllBtn; const originalHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = 'Retrying...'; const endpoint = failuresCard.dataset.retryAllEndpoint; try { const resp = await fetch(endpoint, { method: 'POST' }); if (resp.ok) { loadReplicationFailures(); } } catch (err) { console.error('Failed to retry all:', err); } finally { btn.disabled = false; btn.innerHTML = originalHtml; } }); clearFailuresBtn?.addEventListener('click', () => { clearFailuresModalInstance?.show(); }); confirmClearFailuresBtn?.addEventListener('click', async () => { const btn = confirmClearFailuresBtn; const originalHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = 'Clearing...'; const endpoint = failuresCard.dataset.clearEndpoint; try { const resp = await fetch(endpoint, { method: 'DELETE' }); if (resp.ok) { clearFailuresModalInstance?.hide(); loadReplicationFailures(); } } catch (err) { console.error('Failed to clear failures:', err); } finally { btn.disabled = false; btn.innerHTML = originalHtml; } }); showMoreFailuresBtn?.addEventListener('click', () => { failuresExpanded = !failuresExpanded; showMoreFailuresBtn.textContent = failuresExpanded ? 'Show less' : 'Show more...'; loadReplicationFailures(); }); loadReplicationFailures(); } const algoAes256Radio = document.getElementById('algo_aes256'); const algoKmsRadio = document.getElementById('algo_kms'); const kmsKeySection = document.getElementById('kmsKeySection'); const encryptionForm = document.getElementById('encryptionForm'); const encryptionAction = document.getElementById('encryptionAction'); const disableEncryptionBtn = document.getElementById('disableEncryptionBtn'); const updateKmsKeyVisibility = () => { if (!kmsKeySection) return; const showKms = algoKmsRadio?.checked; kmsKeySection.style.display = showKms ? '' : 'none'; }; algoAes256Radio?.addEventListener('change', updateKmsKeyVisibility); algoKmsRadio?.addEventListener('change', updateKmsKeyVisibility); disableEncryptionBtn?.addEventListener('click', () => { if (encryptionAction && encryptionForm) { if (confirm('Are you sure you want to disable default encryption? New objects will not be encrypted automatically.')) { encryptionAction.value = 'disable'; encryptionForm.submit(); } } }); const targetBucketInput = document.getElementById('target_bucket'); const targetBucketFeedback = document.getElementById('target_bucket_feedback'); const validateBucketName = (name) => { if (!name) return { valid: false, error: 'Bucket name is required' }; if (name.length < 3) return { valid: false, error: 'Bucket name must be at least 3 characters' }; if (name.length > 63) return { valid: false, error: 'Bucket name must be 63 characters or less' }; if (!/^[a-z0-9]/.test(name)) return { valid: false, error: 'Bucket name must start with a lowercase letter or number' }; if (!/[a-z0-9]$/.test(name)) return { valid: false, error: 'Bucket name must end with a lowercase letter or number' }; if (/[A-Z]/.test(name)) return { valid: false, error: 'Bucket name must not contain uppercase letters' }; if (/_/.test(name)) return { valid: false, error: 'Bucket name must not contain underscores' }; if (/\.\.|--/.test(name)) return { valid: false, error: 'Bucket name must not contain consecutive periods or hyphens' }; if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return { valid: false, error: 'Bucket name must not be formatted as an IP address' }; if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(name) && name.length > 2) return { valid: false, error: 'Bucket name contains invalid characters. Use only lowercase letters, numbers, hyphens, and periods.' }; return { valid: true, error: null }; }; const updateBucketNameValidation = () => { if (!targetBucketInput || !targetBucketFeedback) return; const name = targetBucketInput.value.trim(); if (!name) { targetBucketInput.classList.remove('is-valid', 'is-invalid'); targetBucketFeedback.textContent = ''; return; } const result = validateBucketName(name); targetBucketInput.classList.toggle('is-valid', result.valid); targetBucketInput.classList.toggle('is-invalid', !result.valid); targetBucketFeedback.textContent = result.error || ''; }; targetBucketInput?.addEventListener('input', updateBucketNameValidation); targetBucketInput?.addEventListener('blur', updateBucketNameValidation); const replicationForm = targetBucketInput?.closest('form'); replicationForm?.addEventListener('submit', (e) => { const name = targetBucketInput.value.trim(); const result = validateBucketName(name); if (!result.valid) { e.preventDefault(); updateBucketNameValidation(); targetBucketInput.focus(); return false; } }); const formatPolicyBtn = document.getElementById('formatPolicyBtn'); const policyValidationStatus = document.getElementById('policyValidationStatus'); const policyValidBadge = document.getElementById('policyValidBadge'); const policyInvalidBadge = document.getElementById('policyInvalidBadge'); const policyErrorDetail = document.getElementById('policyErrorDetail'); const validatePolicyJson = () => { if (!policyTextarea || !policyValidationStatus) return; const value = policyTextarea.value.trim(); if (!value) { policyValidationStatus.classList.add('d-none'); policyErrorDetail?.classList.add('d-none'); return; } policyValidationStatus.classList.remove('d-none'); try { JSON.parse(value); policyValidBadge?.classList.remove('d-none'); policyInvalidBadge?.classList.add('d-none'); policyErrorDetail?.classList.add('d-none'); } catch (err) { policyValidBadge?.classList.add('d-none'); policyInvalidBadge?.classList.remove('d-none'); if (policyErrorDetail) { policyErrorDetail.textContent = err.message; policyErrorDetail.classList.remove('d-none'); } } }; policyTextarea?.addEventListener('input', validatePolicyJson); policyTextarea?.addEventListener('blur', validatePolicyJson); formatPolicyBtn?.addEventListener('click', () => { if (!policyTextarea) return; const value = policyTextarea.value.trim(); if (!value) return; try { const parsed = JSON.parse(value); policyTextarea.value = JSON.stringify(parsed, null, 2); validatePolicyJson(); } catch (err) { validatePolicyJson(); } }); if (policyTextarea && policyPreset?.value === 'custom') { validatePolicyJson(); } const lifecycleCard = document.getElementById('lifecycle-rules-card'); const lifecycleUrl = lifecycleCard?.dataset.lifecycleUrl; const lifecycleRulesBody = document.getElementById('lifecycle-rules-body'); const addLifecycleRuleModalEl = document.getElementById('addLifecycleRuleModal'); const addLifecycleRuleModal = addLifecycleRuleModalEl ? new bootstrap.Modal(addLifecycleRuleModalEl) : null; let lifecycleRules = []; const loadLifecycleRules = async () => { if (!lifecycleUrl || !lifecycleRulesBody) return; lifecycleRulesBody.innerHTML = '
    Loading...'; try { const resp = await fetch(lifecycleUrl); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to load lifecycle rules'); lifecycleRules = data.rules || []; renderLifecycleRules(); } catch (err) { lifecycleRulesBody.innerHTML = `${escapeHtml(err.message)}`; } }; const renderLifecycleRules = () => { if (!lifecycleRulesBody) return; if (lifecycleRules.length === 0) { lifecycleRulesBody.innerHTML = 'No lifecycle rules configured'; return; } lifecycleRulesBody.innerHTML = lifecycleRules.map((rule, idx) => { const expiration = rule.Expiration?.Days ? `${rule.Expiration.Days}d` : '-'; const noncurrent = rule.NoncurrentVersionExpiration?.NoncurrentDays ? `${rule.NoncurrentVersionExpiration.NoncurrentDays}d` : '-'; const statusClass = rule.Status === 'Enabled' ? 'bg-success' : 'bg-secondary'; return ` ${escapeHtml(rule.ID || '')} ${escapeHtml(rule.Filter?.Prefix || '*')} ${escapeHtml(rule.Status)} ${expiration} ${noncurrent}
    `; }).join(''); }; window.editLifecycleRule = (idx) => { const rule = lifecycleRules[idx]; if (!rule) return; document.getElementById('lifecycleRuleId').value = rule.ID || ''; document.getElementById('lifecycleRuleStatus').value = rule.Status || 'Enabled'; document.getElementById('lifecycleRulePrefix').value = rule.Filter?.Prefix || ''; document.getElementById('lifecycleExpirationDays').value = rule.Expiration?.Days || ''; document.getElementById('lifecycleNoncurrentDays').value = rule.NoncurrentVersionExpiration?.NoncurrentDays || ''; document.getElementById('lifecycleAbortMpuDays').value = rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation || ''; window.editingLifecycleIdx = idx; addLifecycleRuleModal?.show(); }; window.editingLifecycleIdx = null; window.deleteLifecycleRule = async (idx) => { lifecycleRules.splice(idx, 1); await saveLifecycleRules(); }; const saveLifecycleRules = async () => { if (!lifecycleUrl) return; try { const resp = await fetch(lifecycleUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, body: JSON.stringify({ rules: lifecycleRules }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to save'); showMessage({ title: 'Lifecycle rules saved', body: 'Configuration updated successfully.', variant: 'success' }); renderLifecycleRules(); } catch (err) { showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); } }; document.getElementById('addLifecycleRuleConfirm')?.addEventListener('click', async () => { const ruleId = document.getElementById('lifecycleRuleId')?.value?.trim(); const status = document.getElementById('lifecycleRuleStatus')?.value || 'Enabled'; const prefix = document.getElementById('lifecycleRulePrefix')?.value?.trim() || ''; const expDays = parseInt(document.getElementById('lifecycleExpirationDays')?.value) || 0; const ncDays = parseInt(document.getElementById('lifecycleNoncurrentDays')?.value) || 0; const abortDays = parseInt(document.getElementById('lifecycleAbortMpuDays')?.value) || 0; if (!ruleId) { showMessage({ title: 'Validation error', body: 'Rule ID is required', variant: 'warning' }); return; } if (expDays === 0 && ncDays === 0 && abortDays === 0) { showMessage({ title: 'Validation error', body: 'At least one action is required', variant: 'warning' }); return; } const rule = { ID: ruleId, Status: status, Filter: { Prefix: prefix } }; if (expDays > 0) rule.Expiration = { Days: expDays }; if (ncDays > 0) rule.NoncurrentVersionExpiration = { NoncurrentDays: ncDays }; if (abortDays > 0) rule.AbortIncompleteMultipartUpload = { DaysAfterInitiation: abortDays }; if (typeof window.editingLifecycleIdx === 'number' && window.editingLifecycleIdx !== null) { lifecycleRules[window.editingLifecycleIdx] = rule; window.editingLifecycleIdx = null; } else { lifecycleRules.push(rule); } await saveLifecycleRules(); addLifecycleRuleModal?.hide(); document.getElementById('lifecycleRuleId').value = ''; document.getElementById('lifecycleRulePrefix').value = ''; document.getElementById('lifecycleExpirationDays').value = ''; document.getElementById('lifecycleNoncurrentDays').value = ''; document.getElementById('lifecycleAbortMpuDays').value = ''; document.getElementById('lifecycleRuleStatus').value = 'Enabled'; }); const corsCard = document.getElementById('cors-rules-card'); const corsUrl = corsCard?.dataset.corsUrl; const corsRulesBody = document.getElementById('cors-rules-body'); const addCorsRuleModalEl = document.getElementById('addCorsRuleModal'); const addCorsRuleModal = addCorsRuleModalEl ? new bootstrap.Modal(addCorsRuleModalEl) : null; let corsRules = []; const loadCorsRules = async () => { if (!corsUrl || !corsRulesBody) return; corsRulesBody.innerHTML = '
    Loading...'; try { const resp = await fetch(corsUrl); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to load CORS rules'); corsRules = data.rules || []; renderCorsRules(); } catch (err) { corsRulesBody.innerHTML = `${escapeHtml(err.message)}`; } }; const renderCorsRules = () => { if (!corsRulesBody) return; if (corsRules.length === 0) { corsRulesBody.innerHTML = 'No CORS rules configured'; return; } corsRulesBody.innerHTML = corsRules.map((rule, idx) => { const origins = (rule.AllowedOrigins || []).map(o => `${escapeHtml(o)}`).join(', '); const methods = (rule.AllowedMethods || []).map(m => `${escapeHtml(m)}`).join(' '); const headers = (rule.AllowedHeaders || []).slice(0, 3).map(h => `${escapeHtml(h)}`).join(', '); return ` ${origins || 'None'} ${methods || 'None'} ${headers || '*'} ${rule.MaxAgeSeconds || '-'}
    `; }).join(''); }; window.editCorsRule = (idx) => { const rule = corsRules[idx]; if (!rule) return; document.getElementById('corsAllowedOrigins').value = (rule.AllowedOrigins || []).join('\n'); document.getElementById('corsAllowedHeaders').value = (rule.AllowedHeaders || []).join('\n'); document.getElementById('corsExposeHeaders').value = (rule.ExposeHeaders || []).join('\n'); document.getElementById('corsMaxAge').value = rule.MaxAgeSeconds || ''; document.getElementById('corsMethodGet').checked = (rule.AllowedMethods || []).includes('GET'); document.getElementById('corsMethodPut').checked = (rule.AllowedMethods || []).includes('PUT'); document.getElementById('corsMethodPost').checked = (rule.AllowedMethods || []).includes('POST'); document.getElementById('corsMethodDelete').checked = (rule.AllowedMethods || []).includes('DELETE'); document.getElementById('corsMethodHead').checked = (rule.AllowedMethods || []).includes('HEAD'); window.editingCorsIdx = idx; addCorsRuleModal?.show(); }; window.editingCorsIdx = null; window.deleteCorsRule = async (idx) => { corsRules.splice(idx, 1); await saveCorsRules(); }; const saveCorsRules = async () => { if (!corsUrl) return; try { const resp = await fetch(corsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, body: JSON.stringify({ rules: corsRules }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to save'); showMessage({ title: 'CORS rules saved', body: 'Configuration updated successfully.', variant: 'success' }); renderCorsRules(); } catch (err) { showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); } }; document.getElementById('addCorsRuleConfirm')?.addEventListener('click', async () => { const originsRaw = document.getElementById('corsAllowedOrigins')?.value?.trim() || ''; const origins = originsRaw.split('\n').map(s => s.trim()).filter(Boolean); const methods = []; if (document.getElementById('corsMethodGet')?.checked) methods.push('GET'); if (document.getElementById('corsMethodPut')?.checked) methods.push('PUT'); if (document.getElementById('corsMethodPost')?.checked) methods.push('POST'); if (document.getElementById('corsMethodDelete')?.checked) methods.push('DELETE'); if (document.getElementById('corsMethodHead')?.checked) methods.push('HEAD'); const headersRaw = document.getElementById('corsAllowedHeaders')?.value?.trim() || ''; const headers = headersRaw.split('\n').map(s => s.trim()).filter(Boolean); const exposeRaw = document.getElementById('corsExposeHeaders')?.value?.trim() || ''; const expose = exposeRaw.split('\n').map(s => s.trim()).filter(Boolean); const maxAge = parseInt(document.getElementById('corsMaxAge')?.value) || 0; if (origins.length === 0) { showMessage({ title: 'Validation error', body: 'At least one origin is required', variant: 'warning' }); return; } if (methods.length === 0) { showMessage({ title: 'Validation error', body: 'At least one method is required', variant: 'warning' }); return; } const rule = { AllowedOrigins: origins, AllowedMethods: methods }; if (headers.length > 0) rule.AllowedHeaders = headers; if (expose.length > 0) rule.ExposeHeaders = expose; if (maxAge > 0) rule.MaxAgeSeconds = maxAge; if (typeof window.editingCorsIdx === 'number' && window.editingCorsIdx !== null) { corsRules[window.editingCorsIdx] = rule; window.editingCorsIdx = null; } else { corsRules.push(rule); } await saveCorsRules(); addCorsRuleModal?.hide(); document.getElementById('corsAllowedOrigins').value = ''; document.getElementById('corsAllowedHeaders').value = ''; document.getElementById('corsExposeHeaders').value = ''; document.getElementById('corsMaxAge').value = ''; document.getElementById('corsMethodGet').checked = false; document.getElementById('corsMethodPut').checked = false; document.getElementById('corsMethodPost').checked = false; document.getElementById('corsMethodDelete').checked = false; document.getElementById('corsMethodHead').checked = false; }); const aclCard = document.getElementById('bucket-acl-card'); const aclUrl = aclCard?.dataset.aclUrl; const aclOwnerEl = document.getElementById('acl-owner'); const aclGrantsList = document.getElementById('acl-grants-list'); const aclLoading = document.getElementById('acl-loading'); const aclContent = document.getElementById('acl-content'); const cannedAclSelect = document.getElementById('cannedAclSelect'); const loadAcl = async () => { if (!aclUrl) return; try { const resp = await fetch(aclUrl); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to load ACL'); if (aclOwnerEl) aclOwnerEl.textContent = data.owner || '-'; if (aclGrantsList) { const grants = data.grants || []; if (grants.length === 0) { aclGrantsList.innerHTML = '
    No grants
    '; } else { aclGrantsList.innerHTML = grants.map(g => `
    ${escapeHtml(g.grantee)}${escapeHtml(g.permission)}
    `).join(''); } } if (aclLoading) aclLoading.classList.add('d-none'); if (aclContent) aclContent.classList.remove('d-none'); } catch (err) { if (aclLoading) aclLoading.classList.add('d-none'); if (aclContent) aclContent.classList.remove('d-none'); if (aclGrantsList) aclGrantsList.innerHTML = `
    ${escapeHtml(err.message)}
    `; } }; cannedAclSelect?.addEventListener('change', async () => { const canned = cannedAclSelect.value; if (!canned || !aclUrl) return; try { const resp = await fetch(aclUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, body: JSON.stringify({ canned_acl: canned }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to set ACL'); showMessage({ title: 'ACL updated', body: `Bucket ACL set to "${canned}"`, variant: 'success' }); await loadAcl(); } catch (err) { showMessage({ title: 'ACL update failed', body: err.message, variant: 'danger' }); } }); document.querySelectorAll('[data-set-acl]').forEach(btn => { btn.addEventListener('click', async () => { const canned = btn.dataset.setAcl; if (!canned || !aclUrl) return; btn.disabled = true; const originalText = btn.innerHTML; btn.innerHTML = ''; try { const resp = await fetch(aclUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, body: JSON.stringify({ canned_acl: canned }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to set ACL'); showMessage({ title: 'ACL updated', body: `Bucket ACL set to "${canned}"`, variant: 'success' }); await loadAcl(); } catch (err) { showMessage({ title: 'ACL update failed', body: err.message, variant: 'danger' }); } finally { btn.innerHTML = originalText; btn.disabled = false; } }); }); document.getElementById('objects-table')?.addEventListener('show.bs.dropdown', function(e) { const dropdown = e.target.closest('.dropdown'); const menu = dropdown?.querySelector('.dropdown-menu'); const btn = e.target; if (!menu || !btn) return; const btnRect = btn.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.top = (btnRect.bottom + 4) + 'px'; menu.style.left = 'auto'; menu.style.right = (window.innerWidth - btnRect.right) + 'px'; menu.style.transform = 'none'; }); const previewTagsPanel = document.getElementById('preview-tags'); const previewTagsList = document.getElementById('preview-tags-list'); const previewTagsEmpty = document.getElementById('preview-tags-empty'); const previewTagsCount = document.getElementById('preview-tags-count'); const previewTagsEditor = document.getElementById('preview-tags-editor'); const previewTagsInputs = document.getElementById('preview-tags-inputs'); const editTagsButton = document.getElementById('editTagsButton'); const addTagRow = document.getElementById('addTagRow'); const saveTagsButton = document.getElementById('saveTagsButton'); const cancelTagsButton = document.getElementById('cancelTagsButton'); let currentObjectTags = []; let isEditingTags = false; const loadObjectTags = async (row) => { if (!row || !previewTagsPanel) return; const tagsUrl = row.dataset.tagsUrl; if (!tagsUrl) { previewTagsPanel.classList.add('d-none'); return; } previewTagsPanel.classList.remove('d-none'); try { const resp = await fetch(tagsUrl); const data = await resp.json(); currentObjectTags = data.tags || []; renderObjectTags(); } catch (err) { currentObjectTags = []; renderObjectTags(); } }; const renderObjectTags = () => { if (!previewTagsList || !previewTagsEmpty || !previewTagsCount) return; previewTagsCount.textContent = currentObjectTags.length; if (currentObjectTags.length === 0) { previewTagsList.innerHTML = ''; previewTagsEmpty.classList.remove('d-none'); } else { previewTagsEmpty.classList.add('d-none'); previewTagsList.innerHTML = currentObjectTags.map(t => `${escapeHtml(t.Key)}=${escapeHtml(t.Value)}`).join(''); } }; const renderTagEditor = () => { if (!previewTagsInputs) return; previewTagsInputs.innerHTML = currentObjectTags.map((t, idx) => `
    `).join(''); }; window.removeTagRow = (idx) => { currentObjectTags.splice(idx, 1); renderTagEditor(); }; editTagsButton?.addEventListener('click', () => { isEditingTags = true; previewTagsList.classList.add('d-none'); previewTagsEmpty.classList.add('d-none'); previewTagsEditor?.classList.remove('d-none'); renderTagEditor(); }); cancelTagsButton?.addEventListener('click', () => { isEditingTags = false; previewTagsEditor?.classList.add('d-none'); previewTagsList.classList.remove('d-none'); renderObjectTags(); }); addTagRow?.addEventListener('click', () => { if (currentObjectTags.length >= 10) { showMessage({ title: 'Limit reached', body: 'Maximum 10 tags allowed per object.', variant: 'warning' }); return; } currentObjectTags.push({ Key: '', Value: '' }); renderTagEditor(); }); saveTagsButton?.addEventListener('click', async () => { if (!activeRow) return; const tagsUrl = activeRow.dataset.tagsUrl; if (!tagsUrl) return; const inputs = previewTagsInputs?.querySelectorAll('.input-group'); const newTags = []; inputs?.forEach((group, idx) => { const key = group.querySelector(`[data-tag-key="${idx}"]`)?.value?.trim() || ''; const value = group.querySelector(`[data-tag-value="${idx}"]`)?.value?.trim() || ''; if (key) newTags.push({ Key: key, Value: value }); }); try { const resp = await fetch(tagsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, body: JSON.stringify({ tags: newTags }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to save tags'); currentObjectTags = newTags; isEditingTags = false; previewTagsEditor?.classList.add('d-none'); previewTagsList.classList.remove('d-none'); renderObjectTags(); showMessage({ title: 'Tags saved', body: 'Object tags updated successfully.', variant: 'success' }); } catch (err) { showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); } }); const copyMoveModalEl = document.getElementById('copyMoveModal'); const copyMoveModal = copyMoveModalEl ? new bootstrap.Modal(copyMoveModalEl) : null; const copyMoveActionLabel = document.getElementById('copyMoveActionLabel'); const copyMoveConfirmLabel = document.getElementById('copyMoveConfirmLabel'); const copyMoveSource = document.getElementById('copyMoveSource'); const copyMoveDestBucket = document.getElementById('copyMoveDestBucket'); const copyMoveDestKey = document.getElementById('copyMoveDestKey'); const copyMoveConfirm = document.getElementById('copyMoveConfirm'); const bucketsForCopyUrl = objectsContainer?.dataset.bucketsForCopyUrl; let copyMoveAction = 'copy'; let copyMoveSourceKey = ''; window.openCopyMoveModal = async (action, key) => { copyMoveAction = action; copyMoveSourceKey = key; if (copyMoveActionLabel) copyMoveActionLabel.textContent = action === 'move' ? 'Move' : 'Copy'; if (copyMoveConfirmLabel) copyMoveConfirmLabel.textContent = action === 'move' ? 'Move' : 'Copy'; if (copyMoveSource) copyMoveSource.textContent = key; if (copyMoveDestKey) copyMoveDestKey.value = key; if (copyMoveDestBucket) { copyMoveDestBucket.innerHTML = ''; try { const resp = await fetch(bucketsForCopyUrl); const data = await resp.json(); const buckets = data.buckets || []; copyMoveDestBucket.innerHTML = buckets.map(b => ``).join(''); } catch { copyMoveDestBucket.innerHTML = ''; } } copyMoveModal?.show(); }; copyMoveConfirm?.addEventListener('click', async () => { const destBucket = copyMoveDestBucket?.value; const destKey = copyMoveDestKey?.value?.trim(); if (!destBucket || !destKey) { showMessage({ title: 'Validation error', body: 'Destination bucket and key are required', variant: 'warning' }); return; } const actionUrl = copyMoveAction === 'move' ? urlTemplates?.move?.replace('KEY_PLACEHOLDER', encodeURIComponent(copyMoveSourceKey).replace(/%2F/g, '/')) : urlTemplates?.copy?.replace('KEY_PLACEHOLDER', encodeURIComponent(copyMoveSourceKey).replace(/%2F/g, '/')); if (!actionUrl) { showMessage({ title: 'Error', body: 'Copy/move URL not configured', variant: 'danger' }); return; } try { const resp = await fetch(actionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, body: JSON.stringify({ dest_bucket: destBucket, dest_key: destKey }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || `Failed to ${copyMoveAction} object`); showMessage({ title: `Object ${copyMoveAction === 'move' ? 'moved' : 'copied'}`, body: `Successfully ${copyMoveAction === 'move' ? 'moved' : 'copied'} to ${destBucket}/${destKey}`, variant: 'success' }); copyMoveModal?.hide(); if (copyMoveAction === 'move') { previewEmpty.classList.remove('d-none'); previewPanel.classList.add('d-none'); activeRow = null; loadObjects(false); } } catch (err) { showMessage({ title: `${copyMoveAction === 'move' ? 'Move' : 'Copy'} failed`, body: err.message, variant: 'danger' }); } }); const originalSelectRow = selectRow; selectRow = (row) => { originalSelectRow(row); loadObjectTags(row); }; if (lifecycleCard) loadLifecycleRules(); const lifecycleHistoryCard = document.getElementById('lifecycle-history-card'); const lifecycleHistoryBody = document.getElementById('lifecycle-history-body'); const lifecycleHistoryPagination = document.getElementById('lifecycle-history-pagination'); const showMoreHistoryBtn = document.getElementById('show-more-history'); const historyShownCount = document.getElementById('history-shown-count'); let historyExpanded = false; const loadLifecycleHistory = async () => { if (!lifecycleHistoryCard || !lifecycleHistoryBody) return; const endpoint = lifecycleHistoryCard.dataset.historyEndpoint; const limit = historyExpanded ? 50 : 5; lifecycleHistoryBody.innerHTML = '
    Loading...'; try { const resp = await fetch(`${endpoint}?limit=${limit}`); if (!resp.ok) throw new Error('Failed to fetch history'); const data = await resp.json(); if (!data.enabled) { lifecycleHistoryBody.innerHTML = 'Lifecycle enforcement is not enabled'; return; } const executions = data.executions || []; const total = data.total || 0; if (executions.length === 0) { lifecycleHistoryBody.innerHTML = 'No executions recorded yet'; lifecycleHistoryPagination.style.display = 'none'; return; } lifecycleHistoryBody.innerHTML = executions.map(e => { const date = new Date(e.timestamp * 1000); const hasErrors = e.errors && e.errors.length > 0; const hasActivity = e.objects_deleted > 0 || e.versions_deleted > 0 || e.uploads_aborted > 0; let statusBadge; if (hasErrors) { statusBadge = 'Errors'; } else if (hasActivity) { statusBadge = 'Success'; } else { statusBadge = 'No action'; } const errorTooltip = hasErrors ? ` title="${escapeHtml(e.errors.join('; '))}"` : ''; return ` ${date.toLocaleString()} ${e.objects_deleted} ${e.versions_deleted} ${e.uploads_aborted} ${statusBadge} `; }).join(''); if (total > 5 && !historyExpanded) { lifecycleHistoryPagination.style.display = ''; historyShownCount.textContent = `Showing ${Math.min(5, total)} of ${total}`; } else { lifecycleHistoryPagination.style.display = 'none'; } } catch (err) { console.error('Failed to load lifecycle history:', err); lifecycleHistoryBody.innerHTML = 'Failed to load history'; } }; showMoreHistoryBtn?.addEventListener('click', () => { historyExpanded = !historyExpanded; showMoreHistoryBtn.textContent = historyExpanded ? 'Show less' : 'Show more...'; loadLifecycleHistory(); }); if (lifecycleHistoryCard) { loadLifecycleHistory(); if (window.pollingManager) { window.pollingManager.start('lifecycle', loadLifecycleHistory); } } if (corsCard) loadCorsRules(); if (aclCard) loadAcl(); function updateVersioningBadge(enabled) { var badge = document.querySelector('.badge.rounded-pill'); if (!badge) return; badge.classList.remove('text-bg-success', 'text-bg-secondary'); badge.classList.add(enabled ? 'text-bg-success' : 'text-bg-secondary'); var icon = '' + '' + ''; badge.innerHTML = icon + (enabled ? 'Versioning On' : 'Versioning Off'); versioningEnabled = enabled; } function interceptForm(formId, options) { var form = document.getElementById(formId); if (!form) return; form.addEventListener('submit', function(e) { e.preventDefault(); window.UICore.submitFormAjax(form, { successMessage: options.successMessage || 'Operation completed', onSuccess: function(data) { if (options.onSuccess) options.onSuccess(data); if (options.closeModal) { var modal = bootstrap.Modal.getInstance(document.getElementById(options.closeModal)); if (modal) modal.hide(); } if (options.reload) { setTimeout(function() { location.reload(); }, 500); } } }); }); } function updateVersioningCard(enabled) { var card = document.getElementById('bucket-versioning-card'); if (!card) return; var cardBody = card.querySelector('.card-body'); if (!cardBody) return; var enabledHtml = '' + ''; var disabledHtml = '' + '
    ' + '' + '' + '
    '; cardBody.innerHTML = enabled ? enabledHtml : disabledHtml; var archivedCardEl = document.getElementById('archived-objects-card'); if (archivedCardEl) { archivedCardEl.style.display = enabled ? '' : 'none'; } var dropZone = document.getElementById('objects-drop-zone'); if (dropZone) { dropZone.setAttribute('data-versioning', enabled ? 'true' : 'false'); } if (!enabled) { var newForm = document.getElementById('enableVersioningForm'); if (newForm) { newForm.setAttribute('action', window.BucketDetailConfig?.endpoints?.versioning || ''); newForm.addEventListener('submit', function(e) { e.preventDefault(); window.UICore.submitFormAjax(newForm, { successMessage: 'Versioning enabled', onSuccess: function() { updateVersioningBadge(true); updateVersioningCard(true); } }); }); } } } function updateEncryptionCard(enabled, algorithm) { var encCard = document.getElementById('bucket-encryption-card'); if (!encCard) return; var alertContainer = encCard.querySelector('.alert'); if (alertContainer) { if (enabled) { alertContainer.className = 'alert alert-success d-flex align-items-start mb-4'; var algoText = algorithm === 'aws:kms' ? 'KMS' : 'AES-256'; alertContainer.innerHTML = '' + '' + '' + '
    Default encryption enabled (' + algoText + ')' + '

    All new objects uploaded to this bucket will be automatically encrypted.

    '; } else { alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; alertContainer.innerHTML = '' + '' + '
    Default encryption disabled' + '

    Objects are stored without default encryption. You can enable server-side encryption below.

    '; } } var disableBtn = document.getElementById('disableEncryptionBtn'); if (disableBtn) { disableBtn.style.display = enabled ? '' : 'none'; } } function updateQuotaCard(hasQuota, maxBytes, maxObjects) { var quotaCard = document.getElementById('bucket-quota-card'); if (!quotaCard) return; var alertContainer = quotaCard.querySelector('.alert'); if (alertContainer) { if (hasQuota) { alertContainer.className = 'alert alert-info d-flex align-items-start mb-4'; var quotaParts = []; if (maxBytes) quotaParts.push(formatBytes(maxBytes) + ' storage'); if (maxObjects) quotaParts.push(maxObjects.toLocaleString() + ' objects'); alertContainer.innerHTML = '' + '' + '
    Storage quota active' + '

    This bucket is limited to ' + quotaParts.join(' and ') + '.

    '; } else { alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; alertContainer.innerHTML = '' + '' + '' + '
    No storage quota' + '

    This bucket has no storage or object count limits. Set limits below to control usage.

    '; } } var removeBtn = document.getElementById('removeQuotaBtn'); if (removeBtn) { removeBtn.style.display = hasQuota ? '' : 'none'; } var maxMbInput = document.getElementById('max_mb'); var maxObjInput = document.getElementById('max_objects'); if (maxMbInput) maxMbInput.value = maxBytes ? Math.floor(maxBytes / 1048576) : ''; if (maxObjInput) maxObjInput.value = maxObjects || ''; } function updatePolicyCard(hasPolicy, preset) { var policyCard = document.querySelector('#permissions-pane .card'); if (!policyCard) return; var alertContainer = policyCard.querySelector('.alert'); if (alertContainer) { if (hasPolicy) { alertContainer.className = 'alert alert-info d-flex align-items-start mb-4'; alertContainer.innerHTML = '' + '' + '
    Policy attached' + '

    A bucket policy is attached to this bucket. Access is granted via both IAM and bucket policy rules.

    '; } else { alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; alertContainer.innerHTML = '' + '' + '' + '
    IAM only' + '

    No bucket policy is attached. Access is controlled by IAM policies only.

    '; } } document.querySelectorAll('.preset-btn').forEach(function(btn) { btn.classList.remove('active'); if (btn.dataset.preset === preset) btn.classList.add('active'); }); var presetInputEl = document.getElementById('policyPreset'); if (presetInputEl) presetInputEl.value = preset; var deletePolicyBtn = document.getElementById('deletePolicyBtn'); if (deletePolicyBtn) { deletePolicyBtn.style.display = hasPolicy ? '' : 'none'; } } interceptForm('enableVersioningForm', { successMessage: 'Versioning enabled', onSuccess: function(data) { updateVersioningBadge(true); updateVersioningCard(true); } }); interceptForm('suspendVersioningForm', { successMessage: 'Versioning suspended', closeModal: 'suspendVersioningModal', onSuccess: function(data) { updateVersioningBadge(false); updateVersioningCard(false); } }); interceptForm('encryptionForm', { successMessage: 'Encryption settings saved', onSuccess: function(data) { updateEncryptionCard(data.enabled !== false, data.algorithm || 'AES256'); } }); interceptForm('quotaForm', { successMessage: 'Quota settings saved', onSuccess: function(data) { updateQuotaCard(data.has_quota, data.max_bytes, data.max_objects); } }); interceptForm('bucketPolicyForm', { successMessage: 'Bucket policy saved', onSuccess: function(data) { var policyModeEl = document.getElementById('policyMode'); var policyPresetEl = document.getElementById('policyPreset'); var preset = policyModeEl && policyModeEl.value === 'delete' ? 'private' : (policyPresetEl?.value || 'custom'); updatePolicyCard(preset !== 'private', preset); } }); var deletePolicyForm = document.getElementById('deletePolicyForm'); if (deletePolicyForm) { deletePolicyForm.addEventListener('submit', function(e) { e.preventDefault(); window.UICore.submitFormAjax(deletePolicyForm, { successMessage: 'Bucket policy deleted', onSuccess: function(data) { var modal = bootstrap.Modal.getInstance(document.getElementById('deletePolicyModal')); if (modal) modal.hide(); updatePolicyCard(false, 'private'); var policyTextarea = document.getElementById('policyDocument'); if (policyTextarea) policyTextarea.value = ''; } }); }); } var disableEncBtn = document.getElementById('disableEncryptionBtn'); if (disableEncBtn) { disableEncBtn.addEventListener('click', function() { var form = document.getElementById('encryptionForm'); if (!form) return; document.getElementById('encryptionAction').value = 'disable'; window.UICore.submitFormAjax(form, { successMessage: 'Encryption disabled', onSuccess: function(data) { document.getElementById('encryptionAction').value = 'enable'; updateEncryptionCard(false, null); } }); }); } var removeQuotaBtn = document.getElementById('removeQuotaBtn'); if (removeQuotaBtn) { removeQuotaBtn.addEventListener('click', function() { var form = document.getElementById('quotaForm'); if (!form) return; document.getElementById('quotaAction').value = 'remove'; window.UICore.submitFormAjax(form, { successMessage: 'Quota removed', onSuccess: function(data) { document.getElementById('quotaAction').value = 'set'; updateQuotaCard(false, null, null); } }); }); } function reloadReplicationPane() { var replicationPane = document.getElementById('replication-pane'); if (!replicationPane) return; fetch(window.location.pathname + '?tab=replication', { headers: { 'X-Requested-With': 'XMLHttpRequest' } }) .then(function(resp) { return resp.text(); }) .then(function(html) { var parser = new DOMParser(); var doc = parser.parseFromString(html, 'text/html'); var newPane = doc.getElementById('replication-pane'); if (newPane) { replicationPane.innerHTML = newPane.innerHTML; initReplicationForms(); initReplicationStats(); } }) .catch(function(err) { console.error('Failed to reload replication pane:', err); }); } function initReplicationForms() { document.querySelectorAll('form[action*="replication"]').forEach(function(form) { if (form.dataset.ajaxBound) return; form.dataset.ajaxBound = 'true'; var actionInput = form.querySelector('input[name="action"]'); if (!actionInput) return; var action = actionInput.value; form.addEventListener('submit', function(e) { e.preventDefault(); var msg = action === 'pause' ? 'Replication paused' : action === 'resume' ? 'Replication resumed' : action === 'delete' ? 'Replication disabled' : action === 'create' ? 'Replication configured' : 'Operation completed'; window.UICore.submitFormAjax(form, { successMessage: msg, onSuccess: function(data) { var modal = bootstrap.Modal.getInstance(document.getElementById('disableReplicationModal')); if (modal) modal.hide(); reloadReplicationPane(); } }); }); }); } function initReplicationStats() { var statsContainer = document.getElementById('replication-stats-cards'); if (!statsContainer) return; var statusEndpoint = statsContainer.dataset.statusEndpoint; if (!statusEndpoint) return; var syncedEl = statsContainer.querySelector('[data-stat="synced"]'); var pendingEl = statsContainer.querySelector('[data-stat="pending"]'); var orphanedEl = statsContainer.querySelector('[data-stat="orphaned"]'); var bytesEl = statsContainer.querySelector('[data-stat="bytes"]'); fetch(statusEndpoint) .then(function(resp) { return resp.json(); }) .then(function(data) { if (syncedEl) syncedEl.textContent = data.objects_synced || 0; if (pendingEl) pendingEl.textContent = data.objects_pending || 0; if (orphanedEl) orphanedEl.textContent = data.objects_orphaned || 0; if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced || 0); }) .catch(function(err) { console.error('Failed to load replication stats:', err); }); } initReplicationForms(); initReplicationStats(); var deleteBucketForm = document.getElementById('deleteBucketForm'); if (deleteBucketForm) { deleteBucketForm.addEventListener('submit', function(e) { e.preventDefault(); window.UICore.submitFormAjax(deleteBucketForm, { onSuccess: function() { sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Bucket deleted', variant: 'success' })); window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets'; } }); }); } window.BucketDetailConfig = window.BucketDetailConfig || {}; })();