Stop search auto-pagination from looping on failure; accept CSRF in JSON body; make replication pause/resume idempotent
This commit is contained in:
@@ -1213,6 +1213,8 @@ pub struct SearchObjectsQuery {
|
||||
pub prefix: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub start_after: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn search_bucket_objects(
|
||||
@@ -1228,14 +1230,18 @@ pub async fn search_bucket_objects(
|
||||
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();
|
||||
let start_after = q.start_after.clone().filter(|s| !s.is_empty());
|
||||
|
||||
if term.is_empty() {
|
||||
return Json(json!({ "results": [], "truncated": false })).into_response();
|
||||
return Json(json!({ "results": [], "truncated": false, "next_token": Value::Null }))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let mut truncated = false;
|
||||
let mut last_match_key: Option<String> = None;
|
||||
let mut token: Option<String> = None;
|
||||
let mut start_after_arg = start_after;
|
||||
loop {
|
||||
let params = ListParams {
|
||||
max_keys: 1000,
|
||||
@@ -1245,7 +1251,7 @@ pub async fn search_bucket_objects(
|
||||
} else {
|
||||
Some(prefix.clone())
|
||||
},
|
||||
start_after: None,
|
||||
start_after: start_after_arg.take(),
|
||||
};
|
||||
match state.storage.list_objects(&bucket_name, ¶ms).await {
|
||||
Ok(res) => {
|
||||
@@ -1255,6 +1261,7 @@ pub async fn search_bucket_objects(
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
last_match_key = Some(o.key.clone());
|
||||
results.push(object_json(&bucket_name, o));
|
||||
}
|
||||
}
|
||||
@@ -1270,9 +1277,11 @@ pub async fn search_bucket_objects(
|
||||
}
|
||||
}
|
||||
|
||||
let next_token = if truncated { last_match_key } else { None };
|
||||
Json(json!({
|
||||
"results": results,
|
||||
"truncated": truncated,
|
||||
"next_token": next_token,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -427,11 +427,17 @@ pub async fn bucket_detail(
|
||||
let target_conn = replication_rule
|
||||
.as_ref()
|
||||
.and_then(|rule| state.connections.get(&rule.target_connection_id));
|
||||
let versioning_enabled = state
|
||||
let versioning_status_enum = state
|
||||
.storage
|
||||
.is_versioning_enabled(&bucket_name)
|
||||
.get_versioning_status(&bucket_name)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(myfsio_common::types::VersioningStatus::Disabled);
|
||||
let versioning_enabled =
|
||||
matches!(versioning_status_enum, myfsio_common::types::VersioningStatus::Enabled);
|
||||
let versioning_suspended = matches!(
|
||||
versioning_status_enum,
|
||||
myfsio_common::types::VersioningStatus::Suspended
|
||||
);
|
||||
let encryption_config = config_encryption_to_ui(bucket_config.encryption.as_ref());
|
||||
let website_config = config_website_to_ui(bucket_config.website.as_ref());
|
||||
let quota = bucket_config.quota.clone();
|
||||
@@ -491,12 +497,13 @@ pub async fn bucket_detail(
|
||||
);
|
||||
ctx.insert("has_quota", "a.is_some());
|
||||
ctx.insert("versioning_enabled", &versioning_enabled);
|
||||
ctx.insert("versioning_suspended", &versioning_suspended);
|
||||
ctx.insert(
|
||||
"versioning_status",
|
||||
&(if versioning_enabled {
|
||||
"Enabled"
|
||||
} else {
|
||||
"Disabled"
|
||||
&(match versioning_status_enum {
|
||||
myfsio_common::types::VersioningStatus::Enabled => "Enabled",
|
||||
myfsio_common::types::VersioningStatus::Suspended => "Suspended",
|
||||
myfsio_common::types::VersioningStatus::Disabled => "Disabled",
|
||||
}),
|
||||
);
|
||||
ctx.insert("encryption_config", &encryption_config);
|
||||
@@ -2361,10 +2368,10 @@ pub async fn update_bucket_replication(
|
||||
"pause" => {
|
||||
let Some(mut rule) = state.replication.get_rule(&bucket_name) else {
|
||||
return respond(
|
||||
false,
|
||||
StatusCode::NOT_FOUND,
|
||||
true,
|
||||
StatusCode::OK,
|
||||
"No replication configuration to pause.".to_string(),
|
||||
json!({ "error": "No replication configuration to pause" }),
|
||||
json!({ "action": "pause", "enabled": false, "no_op": true }),
|
||||
);
|
||||
};
|
||||
rule.enabled = false;
|
||||
@@ -2379,10 +2386,10 @@ pub async fn update_bucket_replication(
|
||||
"resume" => {
|
||||
let Some(mut rule) = state.replication.get_rule(&bucket_name) else {
|
||||
return respond(
|
||||
false,
|
||||
StatusCode::NOT_FOUND,
|
||||
true,
|
||||
StatusCode::OK,
|
||||
"No replication configuration to resume.".to_string(),
|
||||
json!({ "error": "No replication configuration to resume" }),
|
||||
json!({ "action": "resume", "enabled": false, "no_op": true }),
|
||||
);
|
||||
};
|
||||
rule.enabled = true;
|
||||
|
||||
@@ -155,6 +155,8 @@ pub async fn csrf_layer(
|
||||
extract_form_token(&bytes)
|
||||
} else if content_type.starts_with("multipart/form-data") {
|
||||
extract_multipart_token(&content_type, &bytes)
|
||||
} else if content_type.starts_with("application/json") {
|
||||
extract_json_token(&bytes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -194,7 +196,7 @@ pub async fn csrf_layer(
|
||||
let mut resp = (
|
||||
StatusCode::FORBIDDEN,
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
r#"{"error":"Invalid CSRF token"}"#,
|
||||
r#"{"error":"Invalid CSRF token. Send it via the X-CSRF-Token header or a csrf_token field in the form/JSON body."}"#,
|
||||
)
|
||||
.into_response();
|
||||
*resp.status_mut() = StatusCode::FORBIDDEN;
|
||||
@@ -238,6 +240,14 @@ fn build_session_cookie(id: &str, secure: bool) -> Cookie<'static> {
|
||||
cookie
|
||||
}
|
||||
|
||||
fn extract_json_token(body: &[u8]) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_slice(body).ok()?;
|
||||
value
|
||||
.get(CSRF_FIELD_NAME)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_form_token(body: &[u8]) -> Option<String> {
|
||||
let text = std::str::from_utf8(body).ok()?;
|
||||
let prefix = format!("{}=", CSRF_FIELD_NAME);
|
||||
|
||||
@@ -552,7 +552,13 @@
|
||||
let scrollTimeout = null;
|
||||
const handleVirtualScroll = () => {
|
||||
if (scrollTimeout) cancelAnimationFrame(scrollTimeout);
|
||||
scrollTimeout = requestAnimationFrame(renderVirtualRows);
|
||||
scrollTimeout = requestAnimationFrame(() => {
|
||||
renderVirtualRows();
|
||||
const c = document.querySelector('.objects-table-container');
|
||||
if (c && c.scrollHeight - c.scrollTop - c.clientHeight < 500) {
|
||||
if (typeof loadMoreOnSentinel === 'function') loadMoreOnSentinel();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const refreshVirtualList = () => {
|
||||
@@ -563,6 +569,11 @@
|
||||
if (allObjects.length === 0 && streamFolders.length === 0 && !hasMoreObjects) {
|
||||
showEmptyState();
|
||||
} else {
|
||||
const isFiltering = currentFilterTerm && currentFilterTerm.length > 0;
|
||||
const title = isFiltering ? 'No matches' : 'Empty folder';
|
||||
const body = isFiltering
|
||||
? `No objects match "${escapeHtml(currentFilterTerm)}".`
|
||||
: `This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}`;
|
||||
objectsTableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="py-5">
|
||||
@@ -572,8 +583,8 @@
|
||||
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h6 class="mb-2">Empty folder</h6>
|
||||
<p class="text-muted small mb-0">This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}</p>
|
||||
<h6 class="mb-2">${title}</h6>
|
||||
<p class="text-muted small mb-0">${body}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -977,12 +988,32 @@
|
||||
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
|
||||
}
|
||||
|
||||
const isSentinelVisible = () => {
|
||||
if (!scrollSentinel) return false;
|
||||
const rect = scrollSentinel.getBoundingClientRect();
|
||||
if (scrollContainer) {
|
||||
const cr = scrollContainer.getBoundingClientRect();
|
||||
return rect.top <= cr.bottom + 500 && rect.bottom >= cr.top - 500;
|
||||
}
|
||||
return rect.top <= window.innerHeight + 500 && rect.bottom >= -500;
|
||||
};
|
||||
|
||||
const loadMoreOnSentinel = () => {
|
||||
if (searchResults !== null) {
|
||||
if (searchNextToken && !searchLoading) {
|
||||
performServerSearch(currentFilterTerm, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hasMoreObjects && !isLoadingObjects) {
|
||||
loadObjects(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (scrollSentinel && scrollContainer) {
|
||||
const containerObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
|
||||
loadObjects(true);
|
||||
}
|
||||
if (entry.isIntersecting) loadMoreOnSentinel();
|
||||
});
|
||||
}, {
|
||||
root: scrollContainer,
|
||||
@@ -993,9 +1024,7 @@
|
||||
|
||||
const viewportObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
|
||||
loadObjects(true);
|
||||
}
|
||||
if (entry.isIntersecting) loadMoreOnSentinel();
|
||||
});
|
||||
}, {
|
||||
root: null,
|
||||
@@ -1231,6 +1260,11 @@
|
||||
});
|
||||
|
||||
if (folders.length === 0 && files.length === 0) {
|
||||
const isFiltering = currentFilterTerm && currentFilterTerm.length > 0;
|
||||
const title = isFiltering ? 'No matches' : 'Empty folder';
|
||||
const body = isFiltering
|
||||
? `No objects match "${escapeHtml(currentFilterTerm)}".`
|
||||
: 'This folder contains no objects.';
|
||||
const emptyRow = document.createElement('tr');
|
||||
emptyRow.innerHTML = `
|
||||
<td colspan="4" class="py-5">
|
||||
@@ -1240,8 +1274,8 @@
|
||||
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h6 class="mb-2">Empty folder</h6>
|
||||
<p class="text-muted small mb-0">This folder contains no objects.</p>
|
||||
<h6 class="mb-2">${title}</h6>
|
||||
<p class="text-muted small mb-0">${body}</p>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
@@ -1421,6 +1455,11 @@
|
||||
});
|
||||
|
||||
const bulkActionsWrapper = document.getElementById('bulk-actions-wrapper');
|
||||
const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]');
|
||||
const updateBulkDownloadState = () => {
|
||||
if (!bulkDownloadButton) return;
|
||||
bulkDownloadButton.disabled = selectedRows.size === 0;
|
||||
};
|
||||
const updateBulkDeleteState = () => {
|
||||
const selectedCount = selectedRows.size;
|
||||
if (bulkDeleteButton) {
|
||||
@@ -1447,6 +1486,7 @@
|
||||
selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0;
|
||||
selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total;
|
||||
}
|
||||
updateBulkDownloadState();
|
||||
};
|
||||
|
||||
function toggleRowSelection(row, shouldSelect) {
|
||||
@@ -2274,47 +2314,69 @@
|
||||
const filterWarningText = document.getElementById('filter-warning-text');
|
||||
const folderViewStatus = document.getElementById('folder-view-status');
|
||||
|
||||
const updateFilterWarning = () => {
|
||||
if (!filterWarning) return;
|
||||
const isFiltering = currentFilterTerm.length > 0;
|
||||
if (isFiltering && hasMoreObjects) {
|
||||
filterWarning.classList.remove('d-none');
|
||||
} else {
|
||||
filterWarning.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
|
||||
let searchDebounceTimer = null;
|
||||
let searchAbortController = null;
|
||||
let searchResults = null;
|
||||
let searchNextToken = null;
|
||||
let searchLoading = false;
|
||||
const SEARCH_PAGE_SIZE = 500;
|
||||
|
||||
const performServerSearch = async (term) => {
|
||||
if (searchAbortController) searchAbortController.abort();
|
||||
searchAbortController = new AbortController();
|
||||
const updateFilterWarning = () => {
|
||||
if (!filterWarning) return;
|
||||
filterWarning.classList.add('d-none');
|
||||
};
|
||||
|
||||
const performServerSearch = async (term, append = false) => {
|
||||
if (!append && searchAbortController) searchAbortController.abort();
|
||||
if (append && (searchLoading || !searchNextToken)) return;
|
||||
if (!append) {
|
||||
searchAbortController = new AbortController();
|
||||
}
|
||||
searchLoading = true;
|
||||
if (append && loadMoreSpinner) loadMoreSpinner.classList.remove('d-none');
|
||||
|
||||
let succeeded = false;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: term, limit: '500' });
|
||||
const params = new URLSearchParams({ q: term, limit: String(SEARCH_PAGE_SIZE) });
|
||||
if (currentPrefix) params.set('prefix', currentPrefix);
|
||||
if (append && searchNextToken) params.set('start_after', searchNextToken);
|
||||
const searchUrl = objectsStreamUrl.replace('/stream', '/search');
|
||||
const response = await fetch(`${searchUrl}?${params}`, {
|
||||
signal: searchAbortController.signal
|
||||
signal: searchAbortController?.signal
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
searchResults = (data.results || []).map(obj => processStreamObject(obj));
|
||||
const newResults = (data.results || []).map(obj => processStreamObject(obj));
|
||||
if (append && Array.isArray(searchResults)) {
|
||||
searchResults = searchResults.concat(newResults);
|
||||
} else {
|
||||
searchResults = newResults;
|
||||
}
|
||||
searchNextToken = data.truncated ? (data.next_token || null) : null;
|
||||
memoizedVisibleItems = null;
|
||||
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||
refreshVirtualList();
|
||||
if (loadMoreStatus) {
|
||||
const countText = searchResults.length.toLocaleString();
|
||||
const truncated = data.truncated ? '+' : '';
|
||||
loadMoreStatus.textContent = `${countText}${truncated} result${searchResults.length !== 1 ? 's' : ''}`;
|
||||
const more = searchNextToken ? '+' : '';
|
||||
const noun = searchResults.length === 1 ? 'result' : 'results';
|
||||
loadMoreStatus.textContent = searchNextToken
|
||||
? `${countText}${more} ${noun} (scroll to load more)`
|
||||
: `${countText} ${noun}`;
|
||||
}
|
||||
succeeded = true;
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
if (loadMoreStatus) {
|
||||
loadMoreStatus.textContent = 'Search failed';
|
||||
loadMoreStatus.textContent = 'Search failed (scroll to retry)';
|
||||
}
|
||||
} finally {
|
||||
searchLoading = false;
|
||||
if (loadMoreSpinner) loadMoreSpinner.classList.add('d-none');
|
||||
}
|
||||
|
||||
if (succeeded && searchNextToken && !searchLoading && isSentinelVisible()) {
|
||||
performServerSearch(currentFilterTerm, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2334,6 +2396,7 @@
|
||||
if (!isFiltering && wasFiltering) {
|
||||
if (searchAbortController) searchAbortController.abort();
|
||||
searchResults = null;
|
||||
searchNextToken = null;
|
||||
memoizedVisibleItems = null;
|
||||
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||
if (loadMoreStatus) {
|
||||
@@ -3311,15 +3374,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]');
|
||||
const bulkDownloadEndpoint = document.getElementById('objects-drop-zone')?.dataset.bulkDownloadEndpoint;
|
||||
|
||||
const updateBulkDownloadState = () => {
|
||||
if (!bulkDownloadButton) return;
|
||||
const selectedCount = document.querySelectorAll('[data-object-select]:checked').length;
|
||||
bulkDownloadButton.disabled = selectedCount === 0;
|
||||
};
|
||||
|
||||
selectAllCheckbox?.addEventListener('change', (event) => {
|
||||
const shouldSelect = Boolean(event.target?.checked);
|
||||
|
||||
@@ -3354,7 +3410,6 @@
|
||||
});
|
||||
|
||||
updateBulkDeleteState();
|
||||
setTimeout(updateBulkDownloadState, 0);
|
||||
});
|
||||
|
||||
bulkDownloadButton?.addEventListener('click', async () => {
|
||||
@@ -4402,10 +4457,25 @@
|
||||
});
|
||||
|
||||
if (lifecycleHistoryCard) {
|
||||
loadLifecycleHistory();
|
||||
if (window.pollingManager) {
|
||||
window.pollingManager.start('lifecycle', loadLifecycleHistory);
|
||||
const lifecycleTab = document.getElementById('lifecycle-tab');
|
||||
const lifecyclePane = document.getElementById('lifecycle-pane');
|
||||
const startLifecyclePolling = () => {
|
||||
if (window.pollingManager) {
|
||||
window.pollingManager.start('lifecycle', loadLifecycleHistory);
|
||||
} else {
|
||||
loadLifecycleHistory();
|
||||
}
|
||||
};
|
||||
const stopLifecyclePolling = () => {
|
||||
if (window.pollingManager) {
|
||||
window.pollingManager.stop('lifecycle');
|
||||
}
|
||||
};
|
||||
if (lifecyclePane && lifecyclePane.classList.contains('show') && lifecyclePane.classList.contains('active')) {
|
||||
startLifecyclePolling();
|
||||
}
|
||||
lifecycleTab?.addEventListener('shown.bs.tab', startLifecyclePolling);
|
||||
lifecycleTab?.addEventListener('hidden.bs.tab', stopLifecyclePolling);
|
||||
}
|
||||
|
||||
if (corsCard) loadCorsRules();
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">{{ bucket_name }}</h1>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge {% if versioning_enabled %}text-bg-success{% else %}text-bg-secondary{% endif %} rounded-pill">
|
||||
<span class="badge {% if versioning_enabled %}text-bg-success{% elif versioning_suspended %}text-bg-warning{% else %}text-bg-secondary{% endif %} rounded-pill">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zm.995-14.901a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z"/>
|
||||
</svg>
|
||||
{% if versioning_enabled %}Versioning On{% else %}Versioning Off{% endif %}
|
||||
{% if versioning_enabled %}Versioning On{% elif versioning_suspended %}Versioning Suspended{% else %}Versioning Off{% endif %}
|
||||
</span>
|
||||
<span class="text-muted small" id="object-count-badge">
|
||||
<span class="spinner-border spinner-border-sm" role="status" style="width: 0.75rem; height: 0.75rem;"></span>
|
||||
@@ -626,6 +626,16 @@
|
||||
<p class="mb-0 small">All previous versions of objects are preserved. You can roll back accidental changes or deletions at any time.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif versioning_suspended %}
|
||||
<div class="alert alert-warning d-flex align-items-start mb-4" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Versioning is suspended</strong>
|
||||
<p class="mb-0 small">New uploads overwrite existing objects, but previously archived versions are still retained. Re-enable versioning to start preserving new versions again.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||
@@ -633,8 +643,8 @@
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Versioning is suspended</strong>
|
||||
<p class="mb-0 small">New object uploads overwrite existing objects. Enable versioning to preserve previous versions.</p>
|
||||
<strong>Versioning is disabled</strong>
|
||||
<p class="mb-0 small">This bucket has never had versioning enabled. Enable it to preserve previous versions of every object.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -288,6 +288,7 @@ fn render_bucket_detail() {
|
||||
ctx.insert("bytes_pct", &0);
|
||||
ctx.insert("has_quota", &false);
|
||||
ctx.insert("versioning_enabled", &false);
|
||||
ctx.insert("versioning_suspended", &false);
|
||||
ctx.insert("versioning_status", &"Disabled");
|
||||
ctx.insert("encryption_config", &json!({"Rules": []}));
|
||||
ctx.insert("enc_rules", &Vec::<Value>::new());
|
||||
@@ -369,6 +370,7 @@ fn render_bucket_detail_without_error_document() {
|
||||
ctx.insert("bytes_pct", &0);
|
||||
ctx.insert("has_quota", &false);
|
||||
ctx.insert("versioning_enabled", &false);
|
||||
ctx.insert("versioning_suspended", &false);
|
||||
ctx.insert("versioning_status", &"Disabled");
|
||||
ctx.insert("encryption_config", &json!({"Rules": []}));
|
||||
ctx.insert("enc_rules", &Vec::<Value>::new());
|
||||
|
||||
Reference in New Issue
Block a user