diff --git a/app/ui.py b/app/ui.py index 9124bbb..66e40b4 100644 --- a/app/ui.py +++ b/app/ui.py @@ -2042,16 +2042,17 @@ def update_connection(connection_id: str): secret_key = request.form.get("secret_key", "").strip() region = request.form.get("region", "us-east-1").strip() - if not all([name, endpoint, access_key, secret_key]): + if not all([name, endpoint, access_key]): if _wants_json(): - return jsonify({"error": "All fields are required"}), 400 - flash("All fields are required", "danger") + return jsonify({"error": "Name, endpoint, and access key are required"}), 400 + flash("Name, endpoint, and access key are required", "danger") return redirect(url_for("ui.connections_dashboard")) conn.name = name conn.endpoint_url = endpoint conn.access_key = access_key - conn.secret_key = secret_key + if secret_key: + conn.secret_key = secret_key conn.region = region _connections().save() diff --git a/app/version.py b/app/version.py index b25ea84..fc8981e 100644 --- a/app/version.py +++ b/app/version.py @@ -1,6 +1,6 @@ from __future__ import annotations -APP_VERSION = "0.2.9" +APP_VERSION = "0.3.0" def get_version() -> str: diff --git a/static/css/main.css b/static/css/main.css index 0ab8050..aed80d1 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -2819,6 +2819,112 @@ body:has(.login-card) .main-wrapper { padding-top: 0 !important; } +.context-menu { + position: fixed; + z-index: 1060; + min-width: 180px; + background: var(--myfsio-card-bg); + border: 1px solid var(--myfsio-card-border); + border-radius: 0.5rem; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + padding: 0.25rem 0; + font-size: 0.875rem; +} + +[data-theme='dark'] .context-menu { + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3); +} + +.context-menu-item { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 0.875rem; + color: var(--myfsio-text); + cursor: pointer; + transition: background-color 0.1s ease; + border: none; + background: none; + width: 100%; + text-align: left; + font-size: inherit; +} + +.context-menu-item:hover { + background-color: var(--myfsio-hover-bg); +} + +.context-menu-item.text-danger:hover { + background-color: rgba(239, 68, 68, 0.1); +} + +.context-menu-divider { + height: 1px; + background: var(--myfsio-card-border); + margin: 0.25rem 0; +} + +.context-menu-shortcut { + margin-left: auto; + font-size: 0.75rem; + color: var(--myfsio-muted); +} + +.kbd-shortcuts-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.kbd-shortcuts-list .shortcut-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.375rem 0; +} + +.kbd-shortcuts-list kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + padding: 0.2rem 0.5rem; + font-family: inherit; + font-size: 0.75rem; + font-weight: 600; + background: var(--myfsio-preview-bg); + border: 1px solid var(--myfsio-card-border); + border-radius: 0.25rem; + box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.05); + color: var(--myfsio-text); +} + +[data-theme='dark'] .kbd-shortcuts-list kbd { + background: rgba(255, 255, 255, 0.1); + box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.2); +} + +.sort-dropdown .dropdown-item.active, +.sort-dropdown .dropdown-item:active { + background-color: var(--myfsio-hover-bg); + color: var(--myfsio-text); +} + +.sort-dropdown .dropdown-item { + font-size: 0.875rem; + padding: 0.375rem 1rem; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + @media print { .sidebar, .mobile-header { diff --git a/static/js/bucket-detail-main.js b/static/js/bucket-detail-main.js index c643b09..3ff9871 100644 --- a/static/js/bucket-detail-main.js +++ b/static/js/bucket-detail-main.js @@ -162,6 +162,8 @@ let isLoadingObjects = false; let hasMoreObjects = false; let currentFilterTerm = ''; + let currentSortField = 'name'; + let currentSortDir = 'asc'; let pageSize = 5000; let currentPrefix = ''; let allObjects = []; @@ -348,14 +350,18 @@ const currentInputs = { objectCount: allObjects.length, prefix: currentPrefix, - filterTerm: currentFilterTerm + filterTerm: currentFilterTerm, + sortField: currentSortField, + sortDir: currentSortDir }; if (!forceRecompute && memoizedVisibleItems !== null && memoizedInputs.objectCount === currentInputs.objectCount && memoizedInputs.prefix === currentInputs.prefix && - memoizedInputs.filterTerm === currentInputs.filterTerm) { + memoizedInputs.filterTerm === currentInputs.filterTerm && + memoizedInputs.sortField === currentInputs.sortField && + memoizedInputs.sortDir === currentInputs.sortDir) { return memoizedVisibleItems; } @@ -394,9 +400,19 @@ 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); + if (a.type === 'folder' && b.type === 'folder') { + return a.path.localeCompare(b.path); + } + const dir = currentSortDir === 'asc' ? 1 : -1; + if (currentSortField === 'size') { + return (a.data.size - b.data.size) * dir; + } + if (currentSortField === 'date') { + const aTime = new Date(a.data.lastModified || a.data.last_modified || 0).getTime(); + const bTime = new Date(b.data.lastModified || b.data.last_modified || 0).getTime(); + return (aTime - bTime) * dir; + } + return a.data.key.localeCompare(b.data.key) * dir; }); memoizedVisibleItems = items; @@ -2034,6 +2050,128 @@ refreshVirtualList(); }); + document.querySelectorAll('[data-sort-field]').forEach(el => { + el.addEventListener('click', (e) => { + e.preventDefault(); + const field = el.dataset.sortField; + const dir = el.dataset.sortDir || 'asc'; + currentSortField = field; + currentSortDir = dir; + document.querySelectorAll('[data-sort-field]').forEach(s => s.classList.remove('active')); + el.classList.add('active'); + var label = document.getElementById('sort-dropdown-label'); + if (label) label.textContent = el.textContent.trim(); + refreshVirtualList(); + }); + }); + + document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT' || e.target.isContentEditable) return; + + if (e.key === '/' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + document.getElementById('object-search')?.focus(); + } + + if (e.key === '?' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + var kbModal = document.getElementById('keyboardShortcutsModal'); + if (kbModal) { + var instance = bootstrap.Modal.getOrCreateInstance(kbModal); + instance.toggle(); + } + } + + if (e.key === 'Escape') { + var searchInput = document.getElementById('object-search'); + if (searchInput && document.activeElement === searchInput) { + searchInput.value = ''; + currentFilterTerm = ''; + refreshVirtualList(); + searchInput.blur(); + } + } + + if (e.key === 'Delete' && !e.ctrlKey && !e.metaKey) { + if (selectedRows.size > 0 && bulkDeleteButton && !bulkDeleteButton.disabled) { + bulkDeleteButton.click(); + } + } + + if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { + if (visibleItems.length > 0 && selectAllCheckbox) { + e.preventDefault(); + selectAllCheckbox.checked = true; + selectAllCheckbox.dispatchEvent(new Event('change')); + } + } + }); + + const ctxMenu = document.getElementById('objectContextMenu'); + let ctxTargetRow = null; + + const hideContextMenu = () => { + if (ctxMenu) ctxMenu.classList.add('d-none'); + ctxTargetRow = null; + }; + + if (ctxMenu) { + document.addEventListener('click', hideContextMenu); + document.addEventListener('contextmenu', (e) => { + const row = e.target.closest('[data-object-row]'); + if (!row) { hideContextMenu(); return; } + e.preventDefault(); + ctxTargetRow = row; + + const x = Math.min(e.clientX, window.innerWidth - 200); + const y = Math.min(e.clientY, window.innerHeight - 200); + ctxMenu.style.left = x + 'px'; + ctxMenu.style.top = y + 'px'; + ctxMenu.classList.remove('d-none'); + }); + + ctxMenu.querySelectorAll('[data-ctx-action]').forEach(btn => { + btn.addEventListener('click', () => { + if (!ctxTargetRow) return; + const action = btn.dataset.ctxAction; + const key = ctxTargetRow.dataset.key; + const bucket = objectsContainer?.dataset.bucket || ''; + + if (action === 'download') { + const url = ctxTargetRow.dataset.downloadUrl; + if (url) window.open(url, '_blank'); + } else if (action === 'copy-path') { + const s3Path = 's3://' + bucket + '/' + key; + if (navigator.clipboard) { + navigator.clipboard.writeText(s3Path).then(() => { + if (window.showToast) window.showToast('Copied: ' + s3Path, 'Copied', 'success'); + }); + } + } else if (action === 'presign') { + selectRow(ctxTargetRow); + presignLink.value = ''; + presignModal?.show(); + requestPresignedUrl(); + } else if (action === 'delete') { + const deleteEndpoint = ctxTargetRow.dataset.deleteEndpoint; + if (deleteEndpoint) { + selectRow(ctxTargetRow); + 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', deleteEndpoint); + if (deleteObjectKey) deleteObjectKey.textContent = key; + deleteModal.show(); + } + } + } + hideContextMenu(); + }); + }); + } + refreshVersionsButton?.addEventListener('click', () => { if (!activeRow) { versionList.innerHTML = '

Select an object to view versions.

'; diff --git a/static/js/connections-management.js b/static/js/connections-management.js index e58b0be..8d22a6c 100644 --- a/static/js/connections-management.js +++ b/static/js/connections-management.js @@ -78,7 +78,7 @@ window.ConnectionsManagement = (function() { try { var controller = new AbortController(); - var timeoutId = setTimeout(function() { controller.abort(); }, 15000); + var timeoutId = setTimeout(function() { controller.abort(); }, 10000); var response = await fetch(endpoints.healthTemplate.replace('CONNECTION_ID', connectionId), { signal: controller.signal @@ -147,7 +147,7 @@ window.ConnectionsManagement = (function() { '' + ' +
- +
+ +
+ + + +
+ +
+ + {% endblock %} {% block extra_scripts %} diff --git a/templates/buckets.html b/templates/buckets.html index 13ea928..ad24574 100644 --- a/templates/buckets.html +++ b/templates/buckets.html @@ -89,6 +89,14 @@ {% endfor %} +
+
+ + + +

No buckets match your filter.

+
+
diff --git a/templates/metrics.html b/templates/metrics.html index c77372a..659729c 100644 --- a/templates/metrics.html +++ b/templates/metrics.html @@ -20,6 +20,14 @@ + +
@@ -540,9 +548,15 @@ if (el) el.textContent = data.app.buckets; countdown = 5; + var banner = document.getElementById('metrics-error-banner'); + if (banner) banner.classList.add('d-none'); }) .catch(function(err) { console.error('Metrics fetch error:', err); + var banner = document.getElementById('metrics-error-banner'); + if (banner) { + banner.classList.remove('d-none'); + } }); }