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.
This commit is contained in:
2026-04-25 00:58:49 +08:00
parent 37541ffba1
commit 7e32ac2a46
9 changed files with 221 additions and 17 deletions

View File

@@ -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<String>,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
pub async fn search_bucket_objects(
State(state): State<AppState>,
Extension(_session): Extension<SessionHandle>,
Path(bucket_name): Path<String>,
Query(q): Query<SearchObjectsQuery>,
) -> 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<Value> = Vec::new();
let mut truncated = false;
let mut token: Option<String> = 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, &params).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<AppState>,
Extension(_session): Extension<SessionHandle>,
Path(bucket_name): Path<String>,
) -> 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<AppState>,
Extension(_session): Extension<SessionHandle>,
@@ -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<String, String> = 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);
}

View File

@@ -396,7 +396,13 @@ pub async fn bucket_detail(
Query(request_args): Query<HashMap<String, String>>,
) -> 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");

View File

@@ -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)

View File

@@ -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 '<div class="progress mt-2" style="height: 4px;">' +
'<div class="progress-bar ' + cls + '" style="width: ' + pct + '%"></div>' +
'</div>' +
'<div class="small text-muted mt-1">' + pct + '% of ' + maxObjects.toLocaleString() + ' limit</div>';
}
return '<div class="small text-muted mt-2">No limit</div>';
};
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 '<div class="progress mt-2" style="height: 4px;">' +
'<div class="progress-bar ' + cls + '" style="width: ' + pct + '%"></div>' +
'</div>' +
'<div class="small text-muted mt-1">' + pct + '% of ' + formatBytes(maxBytes) + ' limit</div>';
}
return '<div class="small text-muted mt-2">No limit</div>';
};
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';
}
});

View File

@@ -869,9 +869,10 @@
<h6 class="small fw-semibold mb-3">Current Usage</h6>
<div class="row g-3">
<div class="col-6">
<div class="border rounded p-3 text-center">
<div class="fs-4 fw-bold text-primary">{{ total_objects }}</div>
<div class="border rounded p-3 text-center" data-usage-objects data-total-objects="{{ total_objects }}" data-max-objects="{% if has_max_objects %}{{ max_objects }}{% endif %}">
<div class="fs-4 fw-bold text-primary" data-usage-objects-value>{{ total_objects }}</div>
<div class="small text-muted">Total Objects</div>
<div data-usage-objects-limit>
{% if has_max_objects %}
<div class="progress mt-2" style="height: 4px;">
{% if max_objects > 0 %}{% set obj_pct = total_objects / max_objects * 100 | int %}{% else %}{% set obj_pct = 0 %}{% endif %}
@@ -881,6 +882,7 @@
{% else %}
<div class="small text-muted mt-2">No limit</div>
{% endif %}
</div>
{% if version_count > 0 %}
<div class="small text-muted mt-1">
<span class="text-body-secondary">({{ current_objects }} current + {{ version_count }} versions)</span>
@@ -889,9 +891,10 @@
</div>
</div>
<div class="col-6">
<div class="border rounded p-3 text-center">
<div class="fs-4 fw-bold text-primary">{{ total_bytes | filesizeformat }}</div>
<div class="border rounded p-3 text-center" data-usage-bytes data-total-bytes="{{ total_bytes }}" data-max-bytes="{% if has_max_bytes %}{{ max_bytes }}{% endif %}">
<div class="fs-4 fw-bold text-primary" data-usage-bytes-value>{{ total_bytes | filesizeformat }}</div>
<div class="small text-muted">Total Storage</div>
<div data-usage-bytes-limit>
{% if has_max_bytes %}
<div class="progress mt-2" style="height: 4px;">
{% if max_bytes > 0 %}{% set bytes_pct = total_bytes / max_bytes * 100 | int %}{% else %}{% set bytes_pct = 0 %}{% endif %}
@@ -901,6 +904,7 @@
{% else %}
<div class="small text-muted mt-2">No limit</div>
{% endif %}
</div>
{% if version_bytes > 0 %}
<div class="small text-muted mt-1">
<span class="text-body-secondary">({{ current_bytes | filesizeformat }} current + {{ version_bytes | filesizeformat }} versions)</span>

View File

@@ -31,7 +31,7 @@
{% if iam_locked %}
<div class="alert alert-warning" role="alert">
<div class="fw-semibold mb-1">Administrator permissions required</div>
<p class="mb-0">You need the <code>iam:list_users</code> action to edit users or policies. {{ locked_reason or "Sign in with an admin identity to continue." }}</p>
<p class="mb-0">You need the <code>iam:list_users</code> action to edit users or policies. {% if locked_reason %}{{ locked_reason }}{% else %}Sign in with an admin identity to continue.{% endif %}</p>
</div>
{% endif %}

View File

@@ -18,7 +18,7 @@
</svg>
Set Up Replication
</h1>
<p class="text-muted mb-0 mt-1">Configure bucket replication to <strong>{{ peer.display_name or peer.site_id }}</strong></p>
<p class="text-muted mb-0 mt-1">Configure bucket replication to <strong>{% if peer.display_name %}{{ peer.display_name }}{% else %}{{ peer.site_id }}{% endif %}</strong></p>
</div>
</div>
@@ -100,7 +100,7 @@
<hr class="my-2">
<p class="mb-2 fw-semibold">After completing this wizard, you must also:</p>
<ol class="mb-2 ps-3">
<li>Go to <strong>{{ peer.display_name or peer.site_id }}</strong>'s admin UI</li>
<li>Go to <strong>{% if peer.display_name %}{{ peer.display_name }}{% else %}{{ peer.site_id }}{% endif %}</strong>'s admin UI</li>
<li>Register <strong>this site</strong> as a peer (with a connection)</li>
<li>Create matching bidirectional replication rules pointing back to this site</li>
<li>Ensure <code>SITE_SYNC_ENABLED=true</code> is set on both sites</li>
@@ -147,7 +147,7 @@
<td>
<input type="text" class="form-control form-control-sm"
name="target_{{ bucket.name }}"
value="{{ bucket.existing_target or bucket.name }}"
value="{% if bucket.existing_target %}{{ bucket.existing_target }}{% else %}{{ bucket.name }}{% endif %}"
placeholder="{{ bucket.name }}"
{% if bucket.has_rule %}disabled{% endif %}>
</td>

View File

@@ -225,7 +225,7 @@
</svg>
</div>
<div>
<span class="fw-medium">{{ peer.display_name or peer.site_id }}</span>
<span class="fw-medium">{% if peer.display_name %}{{ peer.display_name }}{% else %}{{ peer.site_id }}{% endif %}</span>
{% if peer.display_name and peer.display_name != peer.site_id %}
<br><small class="text-muted">{{ peer.site_id }}</small>
{% endif %}
@@ -301,7 +301,7 @@
<li>
<button type="button" class="dropdown-item btn-check-bidir {% if not item.has_connection %}disabled{% endif %}"
data-site-id="{{ peer.site_id }}"
data-display-name="{{ peer.display_name or peer.site_id }}">
data-display-name="{% if peer.display_name %}{{ peer.display_name }}{% else %}{{ peer.site_id }}{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2 text-info" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z"/>
</svg>
@@ -335,7 +335,7 @@
data-bs-toggle="modal"
data-bs-target="#deletePeerModal"
data-site-id="{{ peer.site_id }}"
data-display-name="{{ peer.display_name or peer.site_id }}">
data-display-name="{% if peer.display_name %}{{ peer.display_name }}{% else %}{{ peer.site_id }}{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>