{% extends "base.html" %} {% block title %}Storage{% endblock %} {% block breadcrumb %}Storage{% endblock %} {% block extra_head %} {% endblock %} {% block content %} {% if not available %}
No storage backends are configured. Add storage configuration via Workspace.storage() or Integration.storage().
{% else %} {# ── Health Alert Strip ── #} {% set unhealthy_count = health|selectattr('1', 'equalto', false)|list|length if health else 0 %} {% if unhealthy_count > 0 %}
{{ unhealthy_count }} backend{{ 's' if unhealthy_count > 1 else '' }} reporting unhealthy. Check backend connectivity and credentials.
{% else %}
All {{ total_backends }} storage backend{{ 's' if total_backends > 1 else '' }} healthy and operational.
{% endif %} {# ── Stats Row ── #}
{{ total_backends }}
Backends
{{ backend_types|join(', ') }}
{{ total_files }}
Total Files
across all backends
{{ total_size_human }}
Total Storage
{{ total_size_bytes|default(0) }} bytes
{{ default_alias }}
Default Backend
{{ default_type }}
{{ healthy_count }}/{{ total_backends }}
Healthy
backends passing ping
{{ file_type_count }}
File Types
unique MIME types
{{ largest_file_size }}
Largest File
{{ largest_file_name|truncate(20) }}
{# ── Tabs ── #}
{# ═══════════════════════════════════════════════════════════════════ #} {# ── TAB 1: Overview — charts + quick summary ── #} {# ═══════════════════════════════════════════════════════════════════ #}
Storage Overview Aggregate metrics across all backends
{# ── Storage Distribution by Backend (Doughnut) ── #}
Storage by Backend {{ total_backends }} backends
{# ── File Types Distribution (Doughnut) ── #}
File Type Distribution {{ file_type_count }} types
{# ── Files per Backend (Bar) ── #}
Files per Backend {{ total_files }} total
{# ── Storage Capacity (Bar) ── #}
Storage Size per Backend {{ total_size_human }}
{# ── File Size Histogram ── #}
File Size Distribution histogram
{# ═══════════════════════════════════════════════════════════════════ #} {# ── TAB 2: Backends — detailed backend cards ── #} {# ═══════════════════════════════════════════════════════════════════ #}
Registered Backends {{ total_backends }} configured
{% for backend in backends %}
{{ backend.alias }} {% if backend.is_default %} DEFAULT {% endif %}
{{ backend.type }}
{% if backend.root %} {% endif %} {% if backend.bucket %} {% endif %} {% if backend.region %} {% endif %} {% if backend.container_name %} {% endif %} {% if backend.host %} {% endif %}
Backend Type{{ backend.type_display }}
Total Files{{ backend.file_count }}
Total Size{{ backend.size_human }}
Root Path{{ backend.root }}
Bucket{{ backend.bucket }}
Region{{ backend.region }}
Container{{ backend.container_name }}
Host{{ backend.host }}:{{ backend.port }}
Health {{ backend.health_status|upper }}
{% if backend.quota_max > 0 %}
Quota Usage {{ backend.quota_pct|round(1) }}%
{% endif %} {% if backend.recent_files %}
Recent Files
{% for f in backend.recent_files[:5] %}
{{ f.name }} {{ f.size_human }}
{% endfor %}
{% endif %}
{% endfor %}
{# ═══════════════════════════════════════════════════════════════════ #} {# ── TAB 3: File Browser — sortable file table ── #} {# ═══════════════════════════════════════════════════════════════════ #}
File Browser {{ total_files }} files across all backends
{# ── Upload Dropzone ── #}
Drop files here or browse
Upload to any configured storage backend
Uploading... 0%
{# ── Upload Preview (shown after successful upload) ── #} {# ── Backend Selector for Upload ── #}
{% for backend in backends %}
{{ backend.alias }} ({{ backend.type }})
{% endfor %}
{# ── Search and Filter ── #}
All Backends
{% for backend in backends %}
{{ backend.alias }} ({{ backend.type }})
{% endfor %}
All Types
{% for ft in file_types_list %}
{{ ft }}
{% endfor %}
{% if all_files|length > 0 %}
{% for f in all_files %} {% endfor %}
File Name Backend Type Size Modified Actions
{{ f.name }} {{ f.backend }} {{ f.content_type }} {{ f.size_human }} {{ f.modified }}
Showing {{ all_files|length }} files
{% else %}
No files stored
Drop files in the upload zone above, use the Storage API, or call await storage.save("path", data)
{% endif %}
{# ── Right-Side File Detail Drawer ── #}

File Details

File Name
Backend
Size
Content Type
Last Modified
Raw Size
Full Path
{# ── Delete Confirmation Modal ── #}

Confirm Deletion

Are you sure you want to delete this file from backend? This action cannot be undone.

{# ═══════════════════════════════════════════════════════════════════ #} {# ── TAB 4: Analytics — advanced charts ── #} {# ═══════════════════════════════════════════════════════════════════ #}
Storage Analytics Deep dive into storage patterns
{# ── Top File Extensions (Polar Area) ── #}
Top File Extensions polar area
{# ── Storage Growth Trend (Line) ── #}
Backend Health Timeline real-time
{# ── Size by Content Type (Horizontal Bar) ── #}
Storage by Content Type horizontal bar
{# ── Backend Comparison Radar ── #}
Backend Comparison radar
{# ═══════════════════════════════════════════════════════════════════ #} {# ── TAB 5: Configuration — backend configs ── #} {# ═══════════════════════════════════════════════════════════════════ #}
Backend Configuration Runtime config for each registered backend
{% for backend in backends %}
{{ backend.alias }} {% if backend.is_default %} DEFAULT {% endif %}
{{ backend.type }}
{% for key, val in backend.config_display.items() %}{{ key }}: {{ val }} {% endfor %}
{# ── Operations Summary ── #}
Capabilities
Save
Open
Delete
Copy
Move
List
URL
Ping
{% endfor %}
{# ═══════════════════════════════════════════════════════════════════ #} {# ── TAB 6: Health — detailed health checks ── #} {# ═══════════════════════════════════════════════════════════════════ #}
Health Checks Live ping status for every backend
{% for alias, healthy in health.items() %}
storage.{{ alias }}
{{ 'HEALTHY' if healthy else 'UNHEALTHY' }}
Alias{{ alias }}
Ping Result{{ '✓ Reachable' if healthy else '✗ Unreachable' }}
Status{{ 'Operational' if healthy else 'Down' }}
{% endfor %}
{# ── Health Timeline Chart ── #}
Health Check Results {{ healthy_count }}/{{ total_backends }} healthy
{% endif %} {% endblock %} {% block extra_js %} (function(){ 'use strict'; function _csrfHdrs(){var h={'Content-Type':'application/json'};var el=document.querySelector('meta[name="csrf-token"]');if(el&&el.content)h['X-CSRF-Token']=el.content;return h;} // ── Theme ── const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; const gridColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'; const textColor = isDark ? '#a1a1aa' : '#71717a'; // ── Utility: format bytes ── function formatBytes(bytes) { if (!bytes || bytes <= 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + (sizes[i] || 'B'); } // ── Data from server ── const backendLabels = {{ backend_labels|tojson }}; const backendSizes = {{ backend_sizes|tojson }}; const backendFileCounts = {{ backend_file_counts|tojson }}; const fileTypeLabels = {{ file_type_labels|tojson }}; const fileTypeCounts = {{ file_type_counts|tojson }}; const extensionLabels = {{ extension_labels|tojson }}; const extensionCounts = {{ extension_counts|tojson }}; const sizeByTypeLabels = {{ size_by_type_labels|tojson }}; const sizeByTypeValues = {{ size_by_type_values|tojson }}; const sizeHistogramLabels = {{ size_histogram_labels|tojson }}; const sizeHistogramValues = {{ size_histogram_values|tojson }}; const healthLabels = {{ health_labels|tojson }}; const healthValues = {{ health_values|tojson }}; // ── Charts (wrapped in try-catch so interactive functions always register) ── const COLORS = ['#22c55e','#3b82f6','#f59e0b','#a855f7','#06b6d4','#f43f5e','#14b8a6','#ec4899','#6366f1','#eab308']; let healthChart = null; try { if (typeof Chart === 'undefined') throw new Error('Chart.js not loaded'); Chart.defaults.color = textColor; Chart.defaults.borderColor = gridColor; Chart.defaults.font.family = "'Outfit', system-ui, sans-serif"; Chart.defaults.font.size = 11; Chart.defaults.plugins.legend.labels.usePointStyle = true; Chart.defaults.plugins.legend.labels.pointStyle = 'circle'; Chart.defaults.plugins.legend.labels.padding = 16; const ctxBackendDist = document.getElementById('chartBackendDist'); if (ctxBackendDist) { new Chart(ctxBackendDist, { type: 'doughnut', data: { labels: backendLabels, datasets: [{ data: backendSizes, backgroundColor: COLORS.slice(0, backendLabels.length), borderWidth: 0, hoverOffset: 8, }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { position: 'bottom', labels: { padding: 12 } }, tooltip: { callbacks: { label: function(ctx) { return ctx.label + ': ' + formatBytes(ctx.raw); } } } } } }); } // ── Chart 2: File Types (Doughnut) ── const ctxFileTypes = document.getElementById('chartFileTypes'); if (ctxFileTypes) { new Chart(ctxFileTypes, { type: 'doughnut', data: { labels: fileTypeLabels, datasets: [{ data: fileTypeCounts, backgroundColor: COLORS.slice(0, fileTypeLabels.length), borderWidth: 0, hoverOffset: 8, }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { position: 'bottom' } } } }); } // ── Chart 3: Files per Backend (Bar) ── const ctxFilesPerBackend = document.getElementById('chartFilesPerBackend'); if (ctxFilesPerBackend) { new Chart(ctxFilesPerBackend, { type: 'bar', data: { labels: backendLabels, datasets: [{ label: 'Files', data: backendFileCounts, backgroundColor: COLORS.slice(0, backendLabels.length).map(c => c + '55'), borderColor: COLORS.slice(0, backendLabels.length), borderWidth: 1.5, borderRadius: 6, barPercentage: 0.6, }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: gridColor }, ticks: { precision: 0 } }, x: { grid: { display: false } } } } }); } // ── Chart 4: Size per Backend (Bar) ── const ctxSizePerBackend = document.getElementById('chartSizePerBackend'); if (ctxSizePerBackend) { new Chart(ctxSizePerBackend, { type: 'bar', data: { labels: backendLabels, datasets: [{ label: 'Bytes', data: backendSizes, backgroundColor: COLORS.slice(0, backendLabels.length).map(c => c + '55'), borderColor: COLORS.slice(0, backendLabels.length), borderWidth: 1.5, borderRadius: 6, barPercentage: 0.6, }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => formatBytes(ctx.raw) } } }, scales: { y: { beginAtZero: true, grid: { color: gridColor }, ticks: { callback: v => formatBytes(v) } }, x: { grid: { display: false } } } } }); } // ── Chart 5: File Size Histogram (Bar) ── const ctxSizeHistogram = document.getElementById('chartSizeHistogram'); if (ctxSizeHistogram) { new Chart(ctxSizeHistogram, { type: 'bar', data: { labels: sizeHistogramLabels, datasets: [{ label: 'Files', data: sizeHistogramValues, backgroundColor: '#22c55e44', borderColor: '#22c55e', borderWidth: 1.5, borderRadius: 4, barPercentage: 0.85, }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: gridColor }, ticks: { precision: 0 } }, x: { grid: { display: false } } } } }); } // ── Chart 6: Top Extensions (Polar Area) ── const ctxExtensions = document.getElementById('chartExtensions'); if (ctxExtensions) { new Chart(ctxExtensions, { type: 'polarArea', data: { labels: extensionLabels, datasets: [{ data: extensionCounts, backgroundColor: COLORS.slice(0, extensionLabels.length).map(c => c + '66'), borderColor: COLORS.slice(0, extensionLabels.length), borderWidth: 1.5, }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } }, scales: { r: { grid: { color: gridColor }, ticks: { display: false } } } } }); } // ── Chart 7: Health Timeline (Line — simulated) ── const ctxHealthTimeline = document.getElementById('chartHealthTimeline'); if (ctxHealthTimeline) { const now = new Date(); const labels = []; for (let i = 9; i >= 0; i--) { const d = new Date(now - i * 5000); labels.push(d.toLocaleTimeString()); } const datasets = healthLabels.map((label, idx) => ({ label: label, data: Array(10).fill(healthValues[idx] ? 1 : 0), borderColor: COLORS[idx % COLORS.length], backgroundColor: COLORS[idx % COLORS.length] + '22', tension: 0.3, fill: false, borderWidth: 2, pointRadius: 3, })); healthChart = new Chart(ctxHealthTimeline, { type: 'line', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } }, scales: { y: { min: -0.1, max: 1.1, grid: { color: gridColor }, ticks: { callback: v => v >= 0.5 ? 'UP' : 'DOWN', stepSize: 1, }}, x: { grid: { display: false } } } } }); } // ── Chart 8: Size by Content Type (Horizontal Bar) ── const ctxSizeByType = document.getElementById('chartSizeByType'); if (ctxSizeByType) { new Chart(ctxSizeByType, { type: 'bar', data: { labels: sizeByTypeLabels, datasets: [{ label: 'Size', data: sizeByTypeValues, backgroundColor: COLORS.slice(0, sizeByTypeLabels.length).map(c => c + '55'), borderColor: COLORS.slice(0, sizeByTypeLabels.length), borderWidth: 1.5, borderRadius: 6, }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => formatBytes(ctx.raw) } } }, scales: { x: { beginAtZero: true, grid: { color: gridColor }, ticks: { callback: v => formatBytes(v) } }, y: { grid: { display: false } } } } }); } // ── Chart 9: Backend Radar ── const ctxBackendRadar = document.getElementById('chartBackendRadar'); if (ctxBackendRadar) { const radarDatasets = backendLabels.map((label, idx) => ({ label: label, data: [ backendFileCounts[idx] || 0, Math.min((backendSizes[idx] || 0) / 1024, 100), healthValues[idx] ? 100 : 0, (backendFileCounts[idx] || 0) > 0 ? 80 : 20, 50, ], borderColor: COLORS[idx % COLORS.length], backgroundColor: COLORS[idx % COLORS.length] + '22', borderWidth: 2, pointRadius: 3, })); new Chart(ctxBackendRadar, { type: 'radar', data: { labels: ['Files', 'Size (KB)', 'Health', 'Activity', 'Reliability'], datasets: radarDatasets, }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } }, scales: { r: { grid: { color: gridColor }, ticks: { display: false }, suggestedMin: 0 } } } }); } // ── Chart 10: Health Bar ── const ctxHealthBar = document.getElementById('chartHealthBar'); if (ctxHealthBar) { new Chart(ctxHealthBar, { type: 'bar', data: { labels: healthLabels, datasets: [{ label: 'Health Status', data: healthValues.map(v => v ? 1 : 0), backgroundColor: healthValues.map(v => v ? '#22c55e55' : '#ef444455'), borderColor: healthValues.map(v => v ? '#22c55e' : '#ef4444'), borderWidth: 1.5, borderRadius: 6, barPercentage: 0.5, }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { min: 0, max: 1.2, grid: { color: gridColor }, ticks: { callback: v => v >= 0.5 ? 'Healthy' : 'Down', stepSize: 1, }}, x: { grid: { display: false } } } } }); } } catch(e) { console.warn('[Aquilia Storage] Charts init error:', e.message || e); } // ── Custom Select helpers ── window.toggleSelect = function(wrapId) { var wrap = document.getElementById(wrapId); if (!wrap) return; var wasOpen = wrap.classList.contains('open'); // Close all other open selects first document.querySelectorAll('.sto-select.open').forEach(function(el) { el.classList.remove('open'); }); if (!wasOpen) wrap.classList.add('open'); }; window.pickSelect = function(wrapId, value, label) { var wrap = document.getElementById(wrapId); if (!wrap) return; var hidden = wrap.querySelector('input[type="hidden"]'); if (hidden) hidden.value = value; var lbl = wrap.querySelector('.sto-select-label'); if (lbl) lbl.textContent = label; // Update selected state wrap.querySelectorAll('.sto-select-option').forEach(function(opt) { opt.classList.toggle('selected', opt.getAttribute('data-value') === value); }); wrap.classList.remove('open'); }; // Close selects on outside click document.addEventListener('click', function(e) { if (!e.target.closest('.sto-select')) { document.querySelectorAll('.sto-select.open').forEach(function(el) { el.classList.remove('open'); }); } }); // ── File search & filter ── let _fileCurrentPage = 1; let _filteredRows = []; window.filterFiles = function() { const search = (document.getElementById('fileSearch').value || '').toLowerCase(); const backend = document.getElementById('backendFilter').value; const type = document.getElementById('typeFilter').value; const rows = Array.from(document.querySelectorAll('#fileTableBody tr')); _filteredRows = []; rows.forEach(function(row) { const name = row.getAttribute('data-name') || ''; const b = row.getAttribute('data-backend') || ''; const t = row.getAttribute('data-type') || ''; const show = ( (!search || name.includes(search) || b.toLowerCase().includes(search) || t.toLowerCase().includes(search)) && (!backend || b === backend) && (!type || t === type) ); row._filterMatch = show; if (show) _filteredRows.push(row); }); // After filtering, reset to page 1 and paginate paginateFiles(1); }; window.paginateFiles = function(page) { if (!page || page < 1) page = 1; _fileCurrentPage = page; const perPageVal = parseInt(document.getElementById('filesPerPage').value, 10); const allRows = Array.from(document.querySelectorAll('#fileTableBody tr')); // If _filteredRows is empty and no filter is active, use all rows if (_filteredRows.length === 0 && !document.getElementById('fileSearch').value && !document.getElementById('backendFilter').value && !document.getElementById('typeFilter').value) { _filteredRows = allRows.slice(); allRows.forEach(function(r) { r._filterMatch = true; }); } const matchedRows = _filteredRows; const total = matchedRows.length; if (perPageVal === 0) { // Show all allRows.forEach(function(row) { row.style.display = row._filterMatch ? '' : 'none'; }); const el = document.getElementById('fileCount'); if (el) el.textContent = 'Showing ' + total + ' files'; document.getElementById('filePagination').style.display = 'none'; return; } const totalPages = Math.max(1, Math.ceil(total / perPageVal)); if (page > totalPages) page = totalPages; _fileCurrentPage = page; const startIdx = (page - 1) * perPageVal; const endIdx = startIdx + perPageVal; // Hide all rows first allRows.forEach(function(row) { row.style.display = 'none'; }); // Show only rows in the current page slice of filtered rows for (let i = startIdx; i < endIdx && i < matchedRows.length; i++) { matchedRows[i].style.display = ''; } const showStart = total === 0 ? 0 : startIdx + 1; const showEnd = Math.min(endIdx, total); const el = document.getElementById('fileCount'); if (el) el.textContent = 'Showing ' + showStart + '–' + showEnd + ' of ' + total + ' files'; // Render pagination controls _renderFilePagination(page, totalPages); }; function _renderFilePagination(page, totalPages) { const wrap = document.getElementById('filePagination'); if (!wrap) return; if (totalPages <= 1) { wrap.style.display = 'none'; return; } wrap.style.display = 'flex'; let html = ''; const btnStyle = 'display:inline-flex;align-items:center;justify-content:center;min-width:32px;height:32px;border-radius:8px;border:1px solid var(--border-subtle);font-size:0.75rem;cursor:pointer;text-decoration:none;padding:0 5px;transition:all .15s ease;'; const activeStyle = 'background:var(--accent);color:#fff;font-weight:700;border-color:var(--accent);box-shadow:0 2px 8px rgba(99,102,241,0.25);'; const normalStyle = 'background:var(--bg-card);color:var(--text-secondary);'; const disabledStyle = 'background:var(--bg-card);color:var(--text-faint);opacity:0.4;cursor:default;'; // Prev if (page > 1) { html += ''; } else { html += ''; } // Page numbers with window var pStart = Math.max(1, page - 2); var pEnd = Math.min(totalPages, page + 2); if (pStart > 1) { html += '1'; if (pStart > 2) html += ''; } for (var p = pStart; p <= pEnd; p++) { if (p === page) { html += '' + p + ''; } else { html += '' + p + ''; } } if (pEnd < totalPages) { if (pEnd < totalPages - 1) html += ''; html += '' + totalPages + ''; } // Next if (page < totalPages) { html += ''; } else { html += ''; } wrap.innerHTML = html; } // Initialize pagination on load (function initFilePagination() { const rows = document.querySelectorAll('#fileTableBody tr'); if (rows.length > 0) { _filteredRows = Array.from(rows); _filteredRows.forEach(function(r) { r._filterMatch = true; }); paginateFiles(1); } })(); // ── Table sort ── let sortDir = {}; window.sortTable = function(col) { const tbody = document.getElementById('fileTableBody'); if (!tbody) return; const rows = Array.from(tbody.querySelectorAll('tr')); sortDir[col] = !sortDir[col]; const dir = sortDir[col] ? 1 : -1; rows.sort(function(a, b) { let va, vb; if (col === 3) { va = parseInt(a.getAttribute('data-size') || '0', 10); vb = parseInt(b.getAttribute('data-size') || '0', 10); } else { va = (a.children[col]?.textContent || '').toLowerCase().trim(); vb = (b.children[col]?.textContent || '').toLowerCase().trim(); } if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir; return va < vb ? -dir : va > vb ? dir : 0; }); rows.forEach(function(row) { tbody.appendChild(row); }); // Rebuild filtered list in new sort order and re-paginate _filteredRows = rows.filter(function(r) { return r._filterMatch !== false; }); paginateFiles(_fileCurrentPage); }; // ── File Detail Drawer ── let currentDrawerFile = { backend: '', name: '' }; window.openFileDrawer = function(row) { const name = row.getAttribute('data-file'); const backend = row.getAttribute('data-backend'); const type = row.getAttribute('data-type') || 'application/octet-stream'; const sizeHuman = row.getAttribute('data-size-human') || '—'; const rawSize = row.getAttribute('data-size') || '0'; const modified = row.getAttribute('data-modified') || '—'; currentDrawerFile = { backend: backend, name: name }; // Highlight selected row document.querySelectorAll('#fileTableBody tr').forEach(r => r.classList.remove('selected')); row.classList.add('selected'); // Get icon from row const iconEl = row.querySelector('.sto-file-icon'); const drawerIconEl = document.getElementById('drawerIcon'); if (iconEl && drawerIconEl) { drawerIconEl.className = iconEl.className; drawerIconEl.style.width = '24px'; drawerIconEl.style.height = '24px'; drawerIconEl.style.fontSize = '12px'; drawerIconEl.innerHTML = iconEl.innerHTML; } // Populate metadata document.getElementById('drawerFileName').textContent = name; document.getElementById('drawerMetaName').textContent = name; document.getElementById('drawerMetaBackend').textContent = backend; document.getElementById('drawerMetaSize').textContent = sizeHuman; document.getElementById('drawerMetaType').textContent = type; document.getElementById('drawerMetaModified').textContent = modified; document.getElementById('drawerMetaRawSize').textContent = rawSize + ' bytes'; document.getElementById('drawerMetaPath').textContent = backend + '://' + name; // Download button const dlBtn = document.getElementById('drawerDownloadBtn'); dlBtn.href = '{{ url_prefix|default("/admin") }}/storage/api/download?backend=' + encodeURIComponent(backend) + '&path=' + encodeURIComponent(name); // Show type-based icon preview (no file download) const previewEl = document.getElementById('drawerPreview'); var ext = name.split('.').pop().toLowerCase(); var typeIcon = {image:'icon-image',video:'icon-film',audio:'icon-music',text:'icon-file-text',application:'icon-file'}[type.split('/')[0]] || 'icon-file'; previewEl.style.display = 'flex'; previewEl.innerHTML = '
' + ext.toUpperCase() + ' File
' + type + '
'; // Open drawer document.getElementById('drawerOverlay').classList.add('open'); document.getElementById('fileDrawer').classList.add('open'); }; window.closeFileDrawer = function() { document.getElementById('drawerOverlay').classList.remove('open'); document.getElementById('fileDrawer').classList.remove('open'); document.querySelectorAll('#fileTableBody tr').forEach(r => r.classList.remove('selected')); }; function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } // ── Download file ── window.downloadFile = function(backend, path) { const url = '{{ url_prefix|default("/admin") }}/storage/api/download?backend=' + encodeURIComponent(backend) + '&path=' + encodeURIComponent(path); const a = document.createElement('a'); a.href = url; a.download = path.split('/').pop() || path; document.body.appendChild(a); a.click(); document.body.removeChild(a); }; // ── Delete file ── let pendingDelete = { backend: '', name: '' }; window.confirmDelete = function(backend, name) { pendingDelete = { backend: backend, name: name }; document.getElementById('deleteFileName').textContent = name; document.getElementById('deleteBackendName').textContent = backend; document.getElementById('deleteModal').classList.add('open'); }; window.confirmDeleteFromDrawer = function() { window.confirmDelete(currentDrawerFile.backend, currentDrawerFile.name); }; window.closeDeleteModal = function() { document.getElementById('deleteModal').classList.remove('open'); }; window.executeDelete = function() { const btn = document.getElementById('deleteConfirmBtn'); btn.disabled = true; btn.innerHTML = 'Deleting…'; fetch('{{ url_prefix|default("/admin") }}/storage/api/delete', { method: 'POST', headers: _csrfHdrs(), body: JSON.stringify({ backend: pendingDelete.backend, path: pendingDelete.name }), }) .then(r => r.json()) .then(data => { closeDeleteModal(); closeFileDrawer(); btn.disabled = false; btn.innerHTML = 'Delete'; if (data.success) { // Remove row from table const rows = document.querySelectorAll('#fileTableBody tr'); rows.forEach(r => { if (r.getAttribute('data-file') === pendingDelete.name && r.getAttribute('data-backend') === pendingDelete.backend) { r.remove(); } }); filterFiles(); } else { alert('Delete failed: ' + (data.error || 'Unknown error')); } }) .catch(err => { closeDeleteModal(); btn.disabled = false; btn.innerHTML = 'Delete'; alert('Delete failed: ' + err.message); }); }; // ── Upload file ── const uploadZone = document.getElementById('uploadZone'); if (uploadZone) { uploadZone.addEventListener('dragover', function(e) { e.preventDefault(); e.stopPropagation(); uploadZone.classList.add('dragover'); }); uploadZone.addEventListener('dragleave', function(e) { e.preventDefault(); e.stopPropagation(); uploadZone.classList.remove('dragover'); }); uploadZone.addEventListener('drop', function(e) { e.preventDefault(); e.stopPropagation(); uploadZone.classList.remove('dragover'); if (e.dataTransfer.files.length > 0) uploadFiles(e.dataTransfer.files); }); } window.handleFileUpload = function(event) { if (event.target.files.length > 0) uploadFiles(event.target.files); }; function uploadFiles(files) { const backend = document.getElementById('uploadBackend')?.value || 'default'; const progressEl = document.getElementById('uploadProgress'); const nameEl = document.getElementById('uploadFileName'); const pctEl = document.getElementById('uploadPct'); const fillEl = document.getElementById('uploadFill'); var lastFile = null; var lastBackend = backend; Array.from(files).reduce(function(chain, file) { return chain.then(function() { return new Promise(function(resolve) { lastFile = file; progressEl.style.display = 'block'; nameEl.textContent = 'Uploading ' + file.name + '…'; pctEl.textContent = '0%'; fillEl.style.width = '0%'; const formData = new FormData(); formData.append('file', file); formData.append('backend', backend); formData.append('path', file.name); const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', function(e) { if (e.lengthComputable) { const pct = Math.round((e.loaded / e.total) * 100); pctEl.textContent = pct + '%'; fillEl.style.width = pct + '%'; } }); xhr.addEventListener('load', function() { fillEl.style.width = '100%'; pctEl.textContent = '100%'; nameEl.textContent = file.name + ' — uploaded!'; setTimeout(function() { progressEl.style.display = 'none'; resolve(); }, 600); }); xhr.addEventListener('error', function() { nameEl.textContent = file.name + ' — upload failed'; lastFile = null; setTimeout(function() { progressEl.style.display = 'none'; resolve(); }, 1500); }); xhr.open('POST', '{{ url_prefix|default("/admin") }}/storage/api/upload'); xhr.send(formData); }); }); }, Promise.resolve()).then(function() { // Show upload preview if (lastFile) { showUploadPreview(lastFile, lastBackend, files.length); } // Refresh the table after a short delay setTimeout(function() { window.location.reload(); }, 3000); }); } function showUploadPreview(file, backend, totalCount) { var previewBox = document.getElementById('uploadPreview'); if (!previewBox) return; // Title var title = document.getElementById('previewTitle'); var subtitle = document.getElementById('previewSubtitle'); if (totalCount > 1) { title.textContent = totalCount + ' Files Uploaded'; subtitle.textContent = 'All files stored to ' + backend + ' backend'; } else { title.textContent = 'Upload Complete'; subtitle.textContent = file.name + ' stored to ' + backend; } // Thumbnail var thumb = document.getElementById('previewThumb'); if (file.type && file.type.startsWith('image/')) { var reader = new FileReader(); reader.onload = function(e) { thumb.innerHTML = ''; }; reader.readAsDataURL(file); } else { var ext = file.name.split('.').pop().toUpperCase(); var ic = {image:'icon-image',video:'icon-film',audio:'icon-music',text:'icon-file-text'}[file.type.split('/')[0]] || 'icon-file'; thumb.innerHTML = '
' + ext + '
'; } // Meta grid var meta = document.getElementById('previewMeta'); meta.innerHTML = '
Name
' + file.name + '
' + '
Size
' + formatBytes(file.size) + '
' + '
Type
' + (file.type || 'unknown') + '
' + '
Backend
' + backend + '
'; // Download button var dlBtn = document.getElementById('previewDownloadBtn'); dlBtn.href = '{{ url_prefix|default("/admin") }}/storage/api/download?backend=' + encodeURIComponent(backend) + '&path=' + encodeURIComponent(file.name); previewBox.style.display = 'block'; } // ── Live polling ── let pollInterval; let polling = true; function startPolling() { pollInterval = setInterval(fetchStorageData, 5000); } window.togglePolling = function() { polling = !polling; const icon = document.getElementById('pollIcon'); const btn = document.getElementById('pollToggle'); const status = document.getElementById('pollStatus'); if (polling) { startPolling(); if (icon) icon.className = 'icon-pause'; if (btn) btn.innerHTML = 'Pause'; if (status) status.innerHTML = 'Polling every 5s'; } else { clearInterval(pollInterval); if (icon) icon.className = 'icon-play'; if (btn) btn.innerHTML = 'Resume'; if (status) status.innerHTML = 'Paused'; } }; function fetchStorageData() { fetch('{{ url_prefix|default("/admin") }}/storage/api/') .then(r => r.json()) .then(data => { // Update stats const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; }; el('statBackends', data.total_backends || 0); el('statFiles', data.total_files || 0); el('statSize', data.total_size_human || '0 B'); el('statHealthy', (data.healthy_count || 0) + '/' + (data.total_backends || 0)); el('statTypes', data.file_type_count || 0); el('statLargest', data.largest_file_size || '0 B'); // Update health timeline chart if (healthChart && data.health_labels && data.health_values) { const now = new Date().toLocaleTimeString(); healthChart.data.labels.push(now); if (healthChart.data.labels.length > 20) healthChart.data.labels.shift(); data.health_values.forEach(function(v, i) { if (healthChart.data.datasets[i]) { healthChart.data.datasets[i].data.push(v ? 1 : 0); if (healthChart.data.datasets[i].data.length > 20) healthChart.data.datasets[i].data.shift(); } }); healthChart.update('none'); } }) .catch(function() { /* silent */ }); } startPolling(); })(); {% endblock %}