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:
@@ -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, ¶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<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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user