From 7e32ac2a4682fd1fdfb831b6a052904237e56934 Mon Sep 17 00:00:00 2001 From: kqjy Date: Sat, 25 Apr 2026 00:58:49 +0800 Subject: [PATCH] Add object search endpoint, hide internal metadata keys, fix toast/template bugs - Implement missing /ui/buckets/{bucket}/objects/search route used by the Objects tab filter; previously returned 404 and showed 'Search failed'. - Filter __checksum_*__ and __size__ sentinels from the object metadata panel so users no longer see internal keys in the UI. - Include a body field in bucket-delete flash message so the toast shows distinct title and body. - Replace Tera boolean 'or' operator with if/else fallback in replication_wizard.html, sites.html, iam.html. --- Cargo.toml | 2 +- crates/myfsio-server/src/handlers/ui_api.rs | 100 +++++++++++++++++- crates/myfsio-server/src/handlers/ui_pages.rs | 8 +- crates/myfsio-server/src/lib.rs | 15 ++- .../static/js/bucket-detail-main.js | 87 ++++++++++++++- .../templates/bucket_detail.html | 12 ++- crates/myfsio-server/templates/iam.html | 2 +- .../templates/replication_wizard.html | 6 +- crates/myfsio-server/templates/sites.html | 6 +- 9 files changed, 221 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3aa50b6..a9c20a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ edition = "2021" tokio = { version = "1", features = ["full"] } axum = { version = "0.8" } tower = { version = "0.5" } -tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip", "timeout"] } +tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip", "timeout", "set-header"] } hyper = { version = "1" } bytes = "1" serde = { version = "1", features = ["derive"] } diff --git a/crates/myfsio-server/src/handlers/ui_api.rs b/crates/myfsio-server/src/handlers/ui_api.rs index 98939a3..7e8f84f 100644 --- a/crates/myfsio-server/src/handlers/ui_api.rs +++ b/crates/myfsio-server/src/handlers/ui_api.rs @@ -1205,6 +1205,100 @@ pub async fn stream_bucket_objects( (StatusCode::OK, headers, body).into_response() } +#[derive(Deserialize, Default)] +pub struct SearchObjectsQuery { + #[serde(default)] + pub q: Option, + #[serde(default)] + pub prefix: Option, + #[serde(default)] + pub limit: Option, +} + +pub async fn search_bucket_objects( + State(state): State, + Extension(_session): Extension, + Path(bucket_name): Path, + Query(q): Query, +) -> Response { + if !matches!(state.storage.bucket_exists(&bucket_name).await, Ok(true)) { + return json_error(StatusCode::NOT_FOUND, "Bucket not found"); + } + + let term = q.q.unwrap_or_default().to_lowercase(); + let limit = q.limit.unwrap_or(500).clamp(1, 1000); + let prefix = q.prefix.clone().unwrap_or_default(); + + if term.is_empty() { + return Json(json!({ "results": [], "truncated": false })).into_response(); + } + + let mut results: Vec = Vec::new(); + let mut truncated = false; + let mut token: Option = None; + loop { + let params = ListParams { + max_keys: 1000, + continuation_token: token.clone(), + prefix: if prefix.is_empty() { + None + } else { + Some(prefix.clone()) + }, + start_after: None, + }; + match state.storage.list_objects(&bucket_name, ¶ms).await { + Ok(res) => { + for o in &res.objects { + if o.key.to_lowercase().contains(&term) { + if results.len() >= limit { + truncated = true; + break; + } + results.push(object_json(&bucket_name, o)); + } + } + if truncated || !res.is_truncated || res.next_continuation_token.is_none() { + if res.is_truncated && results.len() >= limit { + truncated = true; + } + break; + } + token = res.next_continuation_token; + } + Err(e) => return storage_json_error(e), + } + } + + Json(json!({ + "results": results, + "truncated": truncated, + })) + .into_response() +} + +pub async fn bucket_stats_json( + State(state): State, + Extension(_session): Extension, + Path(bucket_name): Path, +) -> Response { + if !matches!(state.storage.bucket_exists(&bucket_name).await, Ok(true)) { + return json_error(StatusCode::NOT_FOUND, "Bucket not found"); + } + match state.storage.bucket_stats(&bucket_name).await { + Ok(stats) => Json(json!({ + "objects": stats.objects, + "bytes": stats.bytes, + "version_count": stats.version_count, + "version_bytes": stats.version_bytes, + "total_objects": stats.objects + stats.version_count, + "total_bytes": stats.bytes + stats.version_bytes, + })) + .into_response(), + Err(e) => storage_json_error(e), + } +} + pub async fn list_bucket_folders( State(state): State, Extension(_session): Extension, @@ -2323,7 +2417,11 @@ async fn object_metadata_json(state: &AppState, bucket: &str, key: &str) -> Resp .await .unwrap_or_default(); - let mut out = metadata.clone(); + let mut out: std::collections::HashMap = metadata + .iter() + .filter(|(k, _)| !(k.starts_with("__") && k.ends_with("__"))) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); if let Some(content_type) = head.content_type { out.insert("Content-Type".to_string(), content_type); } diff --git a/crates/myfsio-server/src/handlers/ui_pages.rs b/crates/myfsio-server/src/handlers/ui_pages.rs index 8e98192..5f13a7b 100644 --- a/crates/myfsio-server/src/handlers/ui_pages.rs +++ b/crates/myfsio-server/src/handlers/ui_pages.rs @@ -396,7 +396,13 @@ pub async fn bucket_detail( Query(request_args): Query>, ) -> Response { if !matches!(state.storage.bucket_exists(&bucket_name).await, Ok(true)) { - return (StatusCode::NOT_FOUND, "Bucket not found").into_response(); + session.write(|s| { + s.push_flash( + "danger", + format!("Bucket '{}' does not exist.", bucket_name), + ) + }); + return Redirect::to("/ui/buckets").into_response(); } let mut ctx = page_context(&state, &session, "ui.bucket_detail"); diff --git a/crates/myfsio-server/src/lib.rs b/crates/myfsio-server/src/lib.rs index fd3e159..9c74f94 100644 --- a/crates/myfsio-server/src/lib.rs +++ b/crates/myfsio-server/src/lib.rs @@ -91,6 +91,14 @@ pub fn create_ui_router(state: state::AppState) -> Router { "/ui/buckets/{bucket_name}/objects/stream", get(ui_api::stream_bucket_objects), ) + .route( + "/ui/buckets/{bucket_name}/objects/search", + get(ui_api::search_bucket_objects), + ) + .route( + "/ui/buckets/{bucket_name}/stats", + get(ui_api::bucket_stats_json), + ) .route( "/ui/buckets/{bucket_name}/folders", get(ui_api::list_bucket_folders), @@ -311,7 +319,12 @@ pub fn create_ui_router(state: state::AppState) -> Router { secure: false, }; - let static_service = tower_http::services::ServeDir::new(&state.config.static_dir); + let static_service = tower::ServiceBuilder::new() + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-cache"), + )) + .service(tower_http::services::ServeDir::new(&state.config.static_dir)); protected .merge(public) diff --git a/crates/myfsio-server/static/js/bucket-detail-main.js b/crates/myfsio-server/static/js/bucket-detail-main.js index 75040f5..63e2672 100644 --- a/crates/myfsio-server/static/js/bucket-detail-main.js +++ b/crates/myfsio-server/static/js/bucket-detail-main.js @@ -336,6 +336,72 @@ } }; + const renderObjectsLimit = (totalObjects, maxObjects) => { + if (maxObjects && maxObjects > 0) { + const pct = Math.min(100, Math.floor(totalObjects / maxObjects * 100)); + const cls = pct >= 90 ? 'bg-danger' : pct >= 75 ? 'bg-warning' : 'bg-success'; + return '
' + + '
' + + '
' + + '
' + pct + '% of ' + maxObjects.toLocaleString() + ' limit
'; + } + return '
No limit
'; + }; + + const renderBytesLimit = (totalBytes, maxBytes) => { + if (maxBytes && maxBytes > 0) { + const pct = Math.min(100, Math.floor(totalBytes / maxBytes * 100)); + const cls = pct >= 90 ? 'bg-danger' : pct >= 75 ? 'bg-warning' : 'bg-success'; + return '
' + + '
' + + '
' + + '
' + pct + '% of ' + formatBytes(maxBytes) + ' limit
'; + } + return '
No limit
'; + }; + + const redrawUsageLimits = () => { + const objectsCard = document.querySelector('[data-usage-objects]'); + const objectsLimit = document.querySelector('[data-usage-objects-limit]'); + if (objectsCard && objectsLimit) { + const totalObjects = parseInt(objectsCard.dataset.totalObjects || '0', 10); + const maxObjectsRaw = objectsCard.dataset.maxObjects; + const maxObjects = maxObjectsRaw ? parseInt(maxObjectsRaw, 10) : 0; + objectsLimit.innerHTML = renderObjectsLimit(totalObjects, maxObjects); + } + const bytesCard = document.querySelector('[data-usage-bytes]'); + const bytesLimit = document.querySelector('[data-usage-bytes-limit]'); + if (bytesCard && bytesLimit) { + const totalBytes = parseInt(bytesCard.dataset.totalBytes || '0', 10); + const maxBytesRaw = bytesCard.dataset.maxBytes; + const maxBytes = maxBytesRaw ? parseInt(maxBytesRaw, 10) : 0; + bytesLimit.innerHTML = renderBytesLimit(totalBytes, maxBytes); + } + }; + + const refreshBucketUsage = async () => { + try { + const bucketName = objectsContainer?.dataset.bucket; + if (!bucketName) return; + const url = `/ui/buckets/${encodeURIComponent(bucketName)}/stats`; + const response = await fetch(url, { headers: { 'Accept': 'application/json' } }); + if (!response.ok) return; + const data = await response.json(); + + const objectsCard = document.querySelector('[data-usage-objects]'); + const objectsValue = document.querySelector('[data-usage-objects-value]'); + if (objectsCard) objectsCard.dataset.totalObjects = String(data.total_objects); + if (objectsValue) objectsValue.textContent = data.total_objects.toLocaleString(); + + const bytesCard = document.querySelector('[data-usage-bytes]'); + const bytesValue = document.querySelector('[data-usage-bytes-value]'); + if (bytesCard) bytesCard.dataset.totalBytes = String(data.total_bytes); + if (bytesValue) bytesValue.textContent = formatBytes(data.total_bytes); + + redrawUsageLimits(); + } catch (e) { } + }; + let topSpacer = null; let bottomSpacer = null; @@ -660,6 +726,10 @@ break; case 'count': totalObjectCount = msg.total_count || 0; + if (!currentPrefix) { + bucketTotalObjects = totalObjectCount; + updateObjectCountBadge(); + } if (objectsLoadingRow) { const loadingText = objectsLoadingRow.querySelector('p'); if (loadingText) loadingText.textContent = `Loading 0 of ${totalObjectCount.toLocaleString()} objects...`; @@ -770,7 +840,7 @@ } totalObjectCount = data.total_count || 0; - if (!append && !currentPrefix && !useDelimiterMode) bucketTotalObjects = totalObjectCount; + if (!append && !currentPrefix) bucketTotalObjects = totalObjectCount; nextContinuationToken = data.next_continuation_token; if (!append && objectsLoadingRow) { @@ -1491,6 +1561,7 @@ previewPanel.classList.add('d-none'); activeRow = null; loadObjects(false); + refreshBucketUsage(); } catch (error) { bulkDeleteModal?.hide(); showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete selected objects', variant: 'danger' }); @@ -1966,6 +2037,7 @@ previewPanel.classList.add('d-none'); activeRow = null; loadObjects(false); + refreshBucketUsage(); } catch (err) { if (deleteModal) deleteModal.hide(); showMessage({ title: 'Delete failed', body: err.message || 'Unable to delete object', variant: 'danger' }); @@ -3071,6 +3143,7 @@ } else if (errorCount > 0) { showMessage({ title: 'Upload failed', body: `${errorCount} file(s) failed to upload.`, variant: 'danger' }); } + if (successCount > 0) refreshBucketUsage(); }; const performBulkUpload = async (files) => { @@ -4542,6 +4615,16 @@ var maxObjInput = document.getElementById('max_objects'); if (maxMbInput) maxMbInput.value = maxBytes ? Math.floor(maxBytes / 1048576) : ''; if (maxObjInput) maxObjInput.value = maxObjects || ''; + + var objectsCard = document.querySelector('[data-usage-objects]'); + if (objectsCard) { + objectsCard.dataset.maxObjects = maxObjects && maxObjects > 0 ? String(maxObjects) : ''; + } + var bytesCard = document.querySelector('[data-usage-bytes]'); + if (bytesCard) { + bytesCard.dataset.maxBytes = maxBytes && maxBytes > 0 ? String(maxBytes) : ''; + } + redrawUsageLimits(); } function updatePolicyCard(hasPolicy, preset) { @@ -4815,7 +4898,7 @@ e.preventDefault(); window.UICore.submitFormAjax(deleteBucketForm, { onSuccess: function () { - sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Bucket deleted', variant: 'success' })); + sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Success', body: 'Bucket deleted', variant: 'success' })); window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets'; } }); diff --git a/crates/myfsio-server/templates/bucket_detail.html b/crates/myfsio-server/templates/bucket_detail.html index c15741f..f968b6a 100644 --- a/crates/myfsio-server/templates/bucket_detail.html +++ b/crates/myfsio-server/templates/bucket_detail.html @@ -869,9 +869,10 @@
Current Usage
-
-
{{ total_objects }}
+
+
{{ total_objects }}
Total Objects
+
{% if has_max_objects %}
{% if max_objects > 0 %}{% set obj_pct = total_objects / max_objects * 100 | int %}{% else %}{% set obj_pct = 0 %}{% endif %} @@ -881,6 +882,7 @@ {% else %}
No limit
{% endif %} +
{% if version_count > 0 %}
({{ current_objects }} current + {{ version_count }} versions) @@ -889,9 +891,10 @@
-
-
{{ total_bytes | filesizeformat }}
+
+
{{ total_bytes | filesizeformat }}
Total Storage
+
{% if has_max_bytes %}
{% if max_bytes > 0 %}{% set bytes_pct = total_bytes / max_bytes * 100 | int %}{% else %}{% set bytes_pct = 0 %}{% endif %} @@ -901,6 +904,7 @@ {% else %}
No limit
{% endif %} +
{% if version_bytes > 0 %}
({{ current_bytes | filesizeformat }} current + {{ version_bytes | filesizeformat }} versions) diff --git a/crates/myfsio-server/templates/iam.html b/crates/myfsio-server/templates/iam.html index f1e8abd..1ae9d68 100644 --- a/crates/myfsio-server/templates/iam.html +++ b/crates/myfsio-server/templates/iam.html @@ -31,7 +31,7 @@ {% if iam_locked %} {% endif %} diff --git a/crates/myfsio-server/templates/replication_wizard.html b/crates/myfsio-server/templates/replication_wizard.html index dad08d0..fb971d8 100644 --- a/crates/myfsio-server/templates/replication_wizard.html +++ b/crates/myfsio-server/templates/replication_wizard.html @@ -18,7 +18,7 @@ Set Up Replication -

Configure bucket replication to {{ peer.display_name or peer.site_id }}

+

Configure bucket replication to {% if peer.display_name %}{{ peer.display_name }}{% else %}{{ peer.site_id }}{% endif %}

@@ -100,7 +100,7 @@

After completing this wizard, you must also:

    -
  1. Go to {{ peer.display_name or peer.site_id }}'s admin UI
  2. +
  3. Go to {% if peer.display_name %}{{ peer.display_name }}{% else %}{{ peer.site_id }}{% endif %}'s admin UI
  4. Register this site as a peer (with a connection)
  5. Create matching bidirectional replication rules pointing back to this site
  6. Ensure SITE_SYNC_ENABLED=true is set on both sites
  7. @@ -147,7 +147,7 @@ diff --git a/crates/myfsio-server/templates/sites.html b/crates/myfsio-server/templates/sites.html index 34d7a76..41dad8d 100644 --- a/crates/myfsio-server/templates/sites.html +++ b/crates/myfsio-server/templates/sites.html @@ -225,7 +225,7 @@
- {{ peer.display_name or peer.site_id }} + {% if peer.display_name %}{{ peer.display_name }}{% else %}{{ peer.site_id }}{% endif %} {% if peer.display_name and peer.display_name != peer.site_id %}
{{ peer.site_id }} {% endif %} @@ -301,7 +301,7 @@