feat: pluggable storage backends (S3, Azure, local) with admin UI
Implement 4-phase external storage backends architecture: Phase 1 - Foundation: - BlobStorageBackend trait (application/ports/blob_storage_ports.rs) - LocalBlobBackend: extracted all tokio::fs ops from DedupService - S3BlobBackend: AWS SDK with custom endpoint support (MinIO, R2, B2) - DedupService refactored to use Arc<dyn BlobStorageBackend> Phase 2 - Admin Panel: - StorageSettingsService with DB persistence + env override - Storage tab in admin panel (backend selector, S3 form, provider presets) - GET/PUT/POST endpoints for storage settings + connection test - i18n keys (en/es) and BEM CSS Phase 3 - Migration: - MigrationBlobBackend decorator (dual-read: target-first + source fallback) - Background migration job with parallel transfers + progress tracking - Migration UI (progress bar, ETA, pause/resume/verify/complete) - 6 admin API endpoints for migration lifecycle Phase 4 - Enterprise Extras: - CachedBlobBackend: LRU disk cache for remote backends - EncryptedBlobBackend: AES-256-GCM at-rest encryption - AzureBlobBackend: Azure Blob Storage support - RetryBlobBackend: exponential backoff for transient errors - Decorator composition in DI: retry → encryption → cache All 223 tests passing, clippy clean, fmt verified.
This commit is contained in:
@@ -53,6 +53,9 @@
|
||||
<button class="admin-tab" id="tab-btn-oidc">
|
||||
<i class="fas fa-key"></i> <span data-i18n="admin.tab_oidc">SSO / OIDC</span>
|
||||
</button>
|
||||
<button class="admin-tab" id="tab-btn-storage">
|
||||
<i class="fas fa-database"></i> <span data-i18n="admin.tab_storage">Storage</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-dashboard" class="tab-content active">
|
||||
@@ -424,6 +427,183 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════ Storage tab ════════ -->
|
||||
<div id="tab-storage" class="tab-content">
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-database"></i> <span data-i18n="admin.storage_title">Storage Backend</span>
|
||||
</h2>
|
||||
|
||||
<!-- Current status -->
|
||||
<div class="storage-status-grid">
|
||||
<div class="storage-stat">
|
||||
<span class="storage-stat__label" data-i18n="admin.storage_current_backend">Active Backend</span>
|
||||
<span class="storage-stat__value" id="storage-current-backend">—</span>
|
||||
</div>
|
||||
<div class="storage-stat">
|
||||
<span class="storage-stat__label" data-i18n="admin.storage_total_blobs">Total Blobs</span>
|
||||
<span class="storage-stat__value" id="storage-total-blobs">—</span>
|
||||
</div>
|
||||
<div class="storage-stat">
|
||||
<span class="storage-stat__label" data-i18n="admin.storage_total_size">Total Size</span>
|
||||
<span class="storage-stat__value" id="storage-total-size">—</span>
|
||||
</div>
|
||||
<div class="storage-stat">
|
||||
<span class="storage-stat__label" data-i18n="admin.storage_dedup_ratio">Dedup Ratio</span>
|
||||
<span class="storage-stat__value" id="storage-dedup-ratio">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backend selector -->
|
||||
<div class="storage-backend-selector">
|
||||
<label class="storage-backend-option">
|
||||
<input type="radio" name="storage-backend" value="local" checked />
|
||||
<span class="storage-backend-option__card">
|
||||
<i class="fas fa-hdd"></i>
|
||||
<span data-i18n="admin.storage_local">Local Filesystem</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="storage-backend-option">
|
||||
<input type="radio" name="storage-backend" value="s3" />
|
||||
<span class="storage-backend-option__card">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<span data-i18n="admin.storage_s3">S3-Compatible</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- S3 form (shown when S3 is selected) -->
|
||||
<div id="storage-s3-form" class="storage-form" style="display:none">
|
||||
<!-- Provider preset -->
|
||||
<div class="form-group">
|
||||
<label data-i18n="admin.storage_provider_preset">Provider Preset</label>
|
||||
<select id="storage-preset">
|
||||
<option value="custom" data-i18n="admin.storage_preset_custom">Custom</option>
|
||||
<option value="aws">Amazon S3</option>
|
||||
<option value="backblaze">Backblaze B2</option>
|
||||
<option value="cloudflare-r2">Cloudflare R2</option>
|
||||
<option value="minio">MinIO</option>
|
||||
<option value="digitalocean">DigitalOcean Spaces</option>
|
||||
<option value="wasabi">Wasabi</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Endpoint URL -->
|
||||
<div class="form-group">
|
||||
<label><span data-i18n="admin.storage_endpoint_url">Endpoint URL</span>
|
||||
<span id="badge-s3_endpoint_url"></span></label>
|
||||
<input type="url" id="storage-endpoint-url" placeholder="https://s3.amazonaws.com" />
|
||||
<small data-i18n="admin.storage_endpoint_hint">Leave empty for Amazon S3 default</small>
|
||||
</div>
|
||||
|
||||
<!-- Bucket -->
|
||||
<div class="form-group">
|
||||
<label><span data-i18n="admin.storage_bucket">Bucket</span>
|
||||
<span id="badge-s3_bucket"></span></label>
|
||||
<input type="text" id="storage-bucket" placeholder="my-oxicloud-bucket" />
|
||||
</div>
|
||||
|
||||
<!-- Region -->
|
||||
<div class="form-group">
|
||||
<label><span data-i18n="admin.storage_region">Region</span>
|
||||
<span id="badge-s3_region"></span></label>
|
||||
<input type="text" id="storage-region" placeholder="us-east-1" />
|
||||
</div>
|
||||
|
||||
<!-- Access Key -->
|
||||
<div class="form-group">
|
||||
<label><span data-i18n="admin.storage_access_key">Access Key ID</span>
|
||||
<span id="badge-s3_access_key"></span></label>
|
||||
<input type="text" id="storage-access-key" placeholder="AKIA..." />
|
||||
</div>
|
||||
|
||||
<!-- Secret Key -->
|
||||
<div class="form-group">
|
||||
<label><span data-i18n="admin.storage_secret_key">Secret Access Key</span>
|
||||
<span id="badge-s3_secret_key"></span></label>
|
||||
<input type="password" id="storage-secret-key" placeholder="Leave empty to keep current value" />
|
||||
<small id="storage-secret-hint" style="display:none">
|
||||
<i class="fas fa-check-circle secret-icon"></i>
|
||||
<span data-i18n="admin.storage_secret_configured">A secret key is already configured</span>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Force Path Style -->
|
||||
<div class="toggle-row">
|
||||
<label data-i18n="admin.storage_path_style">Force Path Style</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="storage-path-style" /><span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<small data-i18n="admin.storage_path_style_hint">Required for MinIO and some S3-compatible providers</small>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="storage-actions">
|
||||
<button class="btn btn-secondary" id="btn-test-storage">
|
||||
<i class="fas fa-vial"></i> <span data-i18n="admin.storage_test_connection">Test Connection</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" id="btn-save-storage">
|
||||
<i class="fas fa-save"></i> <span data-i18n="admin.storage_save">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="storage-status" class="alert"></div>
|
||||
|
||||
<!-- Migration section -->
|
||||
<div class="storage-migration-section">
|
||||
<h3><i class="fas fa-exchange-alt"></i> <span data-i18n="admin.storage_migration">Backend Migration</span></h3>
|
||||
|
||||
<!-- Status badge -->
|
||||
<div class="migration-status-row">
|
||||
<span data-i18n="admin.migration_status_label">Status:</span>
|
||||
<span id="migration-status-badge" class="badge badge-migration badge-migration--idle">Idle</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar (hidden when idle) -->
|
||||
<div id="migration-progress-section" style="display:none">
|
||||
<div class="migration-progress-bar">
|
||||
<div class="migration-progress-bar__fill" id="migration-progress-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="migration-progress-text">
|
||||
<span id="migration-progress-label">0 / 0 blobs (0%)</span>
|
||||
<span id="migration-throughput"></span>
|
||||
</div>
|
||||
<div class="migration-progress-text">
|
||||
<span id="migration-bytes-label"></span>
|
||||
<span id="migration-eta"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed blobs (expandable) -->
|
||||
<details id="migration-failed-section" style="display:none">
|
||||
<summary><i class="fas fa-exclamation-triangle"></i> <span id="migration-failed-count">0</span> <span data-i18n="admin.migration_failed_blobs">failed blobs</span></summary>
|
||||
<pre id="migration-failed-list" class="migration-failed-list"></pre>
|
||||
</details>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="migration-actions">
|
||||
<button class="btn btn-primary" id="btn-start-migration">
|
||||
<i class="fas fa-play"></i> <span data-i18n="admin.migration_start">Start Migration</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="btn-pause-migration" style="display:none">
|
||||
<i class="fas fa-pause"></i> <span data-i18n="admin.migration_pause">Pause</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" id="btn-resume-migration" style="display:none">
|
||||
<i class="fas fa-play"></i> <span data-i18n="admin.migration_resume">Resume</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="btn-verify-migration" style="display:none">
|
||||
<i class="fas fa-check-double"></i> <span data-i18n="admin.migration_verify">Verify Integrity</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" id="btn-complete-migration" style="display:none">
|
||||
<i class="fas fa-flag-checkered"></i> <span data-i18n="admin.migration_complete">Finalize</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="migration-verify-result" style="display:none"></div>
|
||||
<div id="migration-status-msg" class="alert" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -324,6 +324,7 @@ body {
|
||||
#oidc-form,
|
||||
#secret-hint,
|
||||
#password-warning,
|
||||
#storage-secret-hint,
|
||||
#quota-modal,
|
||||
#create-user-modal,
|
||||
#reset-pw-modal {
|
||||
@@ -938,3 +939,176 @@ details[open] summary {
|
||||
.btn-danger:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* ── Storage Tab ── */
|
||||
|
||||
.storage-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.storage-stat {
|
||||
padding: 14px;
|
||||
background: var(--color-bg-hover);
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.storage-stat__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.storage-stat__value {
|
||||
display: block;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.storage-backend-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.storage-backend-option {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.storage-backend-option input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
.storage-backend-option__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 18px 14px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
.storage-backend-option__card i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.storage-backend-option input[type="radio"]:checked + .storage-backend-option__card {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
.storage-backend-option input[type="radio"]:checked + .storage-backend-option__card i {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.storage-form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.storage-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.storage-migration-section {
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.storage-migration-section h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
.storage-migration-section h3 i {
|
||||
color: var(--color-text-secondary);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ── Migration UI ── */
|
||||
|
||||
.migration-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.badge-migration {
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.badge-migration--idle {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.badge-migration--running {
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.badge-migration--paused {
|
||||
background: var(--color-warning-bg, #fff8e1);
|
||||
color: var(--color-warning-text, #b28704);
|
||||
}
|
||||
.badge-migration--completed {
|
||||
background: var(--color-success-bg, #e8f5e9);
|
||||
color: var(--color-success-text, #2e7d32);
|
||||
}
|
||||
.badge-migration--failed {
|
||||
background: var(--color-danger-bg, #fce4ec);
|
||||
color: var(--color-danger-text, #c62828);
|
||||
}
|
||||
|
||||
.migration-progress-bar {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: var(--color-bg-hover);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.migration-progress-bar__fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
border-radius: 6px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.migration-progress-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.migration-failed-list {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
font-size: 11px;
|
||||
background: var(--color-bg-hover);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.migration-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ function switchTab(name, el) {
|
||||
activeTabName = name;
|
||||
if (name === 'users') loadUsers();
|
||||
if (name === 'dashboard') loadDashboard();
|
||||
if (name === 'storage') loadStorage();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
@@ -693,6 +694,353 @@ async function saveOidcSettings() {
|
||||
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(t('admin.save_btn'))}`;
|
||||
}
|
||||
|
||||
/* ── Storage tab ── */
|
||||
|
||||
const STORAGE_PRESETS = {
|
||||
'custom': { endpoint: '', region: '', pathStyle: false },
|
||||
'aws': { endpoint: '', region: 'us-east-1', pathStyle: false },
|
||||
'backblaze': { endpoint: 'https://s3.{region}.backblazeb2.com', region: 'us-west-004', pathStyle: false },
|
||||
'cloudflare-r2': { endpoint: 'https://{accountId}.r2.cloudflarestorage.com', region: 'auto', pathStyle: true },
|
||||
'minio': { endpoint: 'http://localhost:9000', region: 'us-east-1', pathStyle: true },
|
||||
'digitalocean': { endpoint: 'https://{region}.digitaloceanspaces.com', region: 'nyc3', pathStyle: false },
|
||||
'wasabi': { endpoint: 'https://s3.{region}.wasabisys.com', region: 'us-east-1', pathStyle: false },
|
||||
};
|
||||
|
||||
function toggleS3Form(visible) {
|
||||
if (visible) showElement('storage-s3-form');
|
||||
else hideElement('storage-s3-form');
|
||||
}
|
||||
|
||||
function onStoragePresetChange() {
|
||||
const preset = document.getElementById('storage-preset').value;
|
||||
const p = STORAGE_PRESETS[preset];
|
||||
if (!p) return;
|
||||
if (p.endpoint) document.getElementById('storage-endpoint-url').value = p.endpoint;
|
||||
if (p.region) document.getElementById('storage-region').value = p.region;
|
||||
document.getElementById('storage-path-style').checked = p.pathStyle;
|
||||
}
|
||||
|
||||
function showStorageStatus(msg, type) {
|
||||
const el = document.getElementById('storage-status');
|
||||
el.textContent = msg;
|
||||
el.className = `alert alert-${type}`;
|
||||
}
|
||||
|
||||
async function loadStorage() {
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/settings/storage`, {
|
||||
headers: headers(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
|
||||
// Backend selector
|
||||
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
|
||||
r.checked = r.value === s.backend;
|
||||
});
|
||||
toggleS3Form(s.backend === 's3');
|
||||
|
||||
// S3 fields
|
||||
document.getElementById('storage-endpoint-url').value = s.s3_endpoint_url || '';
|
||||
document.getElementById('storage-bucket').value = s.s3_bucket || '';
|
||||
document.getElementById('storage-region').value = s.s3_region || '';
|
||||
document.getElementById('storage-access-key').value = '';
|
||||
document.getElementById('storage-secret-key').value = '';
|
||||
document.getElementById('storage-path-style').checked = s.s3_force_path_style;
|
||||
|
||||
// Secret hints
|
||||
if (s.s3_access_key_set) {
|
||||
document.getElementById('storage-access-key').placeholder = t('admin.storage_key_placeholder') || 'Leave empty to keep current value';
|
||||
}
|
||||
if (s.s3_secret_key_set) {
|
||||
showElement('storage-secret-hint');
|
||||
} else {
|
||||
hideElement('storage-secret-hint');
|
||||
}
|
||||
|
||||
// ENV badges
|
||||
(s.env_overrides || []).forEach((field) => {
|
||||
const badge = document.getElementById(`badge-${field}`);
|
||||
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
|
||||
});
|
||||
|
||||
// Status section
|
||||
document.getElementById('storage-current-backend').textContent = s.current_backend || '—';
|
||||
document.getElementById('storage-total-blobs').textContent = s.total_blobs != null ? s.total_blobs.toLocaleString() : '—';
|
||||
document.getElementById('storage-total-size').textContent = s.total_bytes_stored != null ? formatBytes(s.total_bytes_stored) : '—';
|
||||
document.getElementById('storage-dedup-ratio').textContent = s.dedup_ratio != null ? `${s.dedup_ratio.toFixed(2)}x` : '—';
|
||||
} catch (e) {
|
||||
showStorageStatus(t('admin.error_network', { message: e.message }), 'error');
|
||||
}
|
||||
|
||||
// Also load migration status
|
||||
loadMigrationStatus();
|
||||
}
|
||||
|
||||
async function saveStorageSettings() {
|
||||
const btn = document.getElementById('btn-save-storage');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(t('admin.saving'))}`;
|
||||
|
||||
const backend = document.querySelector('input[name="storage-backend"]:checked').value;
|
||||
const body = {
|
||||
backend,
|
||||
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null,
|
||||
s3_bucket: document.getElementById('storage-bucket').value.trim() || null,
|
||||
s3_region: document.getElementById('storage-region').value.trim() || null,
|
||||
s3_access_key: document.getElementById('storage-access-key').value || null,
|
||||
s3_secret_key: document.getElementById('storage-secret-key').value || null,
|
||||
s3_force_path_style: document.getElementById('storage-path-style').checked
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/settings/storage`, {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (resp.ok) {
|
||||
showStorageStatus(t('admin.storage_saved') || 'Storage settings saved successfully', 'success');
|
||||
loadStorage();
|
||||
} else {
|
||||
const e = await resp.json().catch(() => ({}));
|
||||
showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showStorageStatus(t('admin.error_network', { message: e.message }), 'error');
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(t('admin.storage_save') || 'Save')}`;
|
||||
}
|
||||
|
||||
async function testStorageConnection() {
|
||||
const btn = document.getElementById('btn-test-storage');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(t('admin.testing') || 'Testing...')}`;
|
||||
|
||||
const backend = document.querySelector('input[name="storage-backend"]:checked').value;
|
||||
const body = {
|
||||
backend,
|
||||
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null,
|
||||
s3_bucket: document.getElementById('storage-bucket').value.trim() || null,
|
||||
s3_region: document.getElementById('storage-region').value.trim() || null,
|
||||
s3_access_key: document.getElementById('storage-access-key').value || null,
|
||||
s3_secret_key: document.getElementById('storage-secret-key').value || null,
|
||||
s3_force_path_style: document.getElementById('storage-path-style').checked
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/settings/storage/test`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const r = await resp.json();
|
||||
if (r.connected) {
|
||||
let msg = `${t('admin.storage_test_success') || 'Connection successful'} (${escapeHtml(r.backend_type)})`;
|
||||
if (r.available_bytes != null) msg += ` — ${formatBytes(r.available_bytes)} available`;
|
||||
showStorageStatus(msg, 'success');
|
||||
} else {
|
||||
showStorageStatus(`${t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showStorageStatus(t('admin.error_network', { message: e.message }), 'error');
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = `<i class="fas fa-vial"></i> ${escapeHtml(t('admin.storage_test_connection') || 'Test Connection')}`;
|
||||
}
|
||||
|
||||
/* ── Migration ── */
|
||||
|
||||
let migrationPollTimer = null;
|
||||
|
||||
function showMigrationMsg(msg, type) {
|
||||
const el = document.getElementById('migration-status-msg');
|
||||
el.textContent = msg;
|
||||
el.className = `alert alert-${type}`;
|
||||
el.style.display = '';
|
||||
}
|
||||
|
||||
function updateMigrationUI(m) {
|
||||
// Status badge
|
||||
const badge = document.getElementById('migration-status-badge');
|
||||
badge.textContent = (m.status || 'idle').charAt(0).toUpperCase() + (m.status || 'idle').slice(1);
|
||||
badge.className = `badge badge-migration badge-migration--${m.status || 'idle'}`;
|
||||
|
||||
const isActive = m.status === 'running' || m.status === 'paused';
|
||||
const isCompleted = m.status === 'completed';
|
||||
|
||||
// Progress section
|
||||
const progressSection = document.getElementById('migration-progress-section');
|
||||
progressSection.style.display = (isActive || isCompleted) ? '' : 'none';
|
||||
|
||||
if (m.total_blobs > 0) {
|
||||
const pct = Math.round((m.migrated_blobs / m.total_blobs) * 100);
|
||||
document.getElementById('migration-progress-fill').style.width = `${pct}%`;
|
||||
document.getElementById('migration-progress-label').textContent =
|
||||
`${m.migrated_blobs.toLocaleString()} / ${m.total_blobs.toLocaleString()} blobs (${pct}%)`;
|
||||
document.getElementById('migration-bytes-label').textContent =
|
||||
`${formatBytes(m.migrated_bytes)} transferred`;
|
||||
|
||||
if (m.throughput_bytes_per_sec && m.status === 'running') {
|
||||
document.getElementById('migration-throughput').textContent =
|
||||
`${formatBytes(Math.round(m.throughput_bytes_per_sec))}/s`;
|
||||
const remaining = m.total_blobs - m.migrated_blobs;
|
||||
if (remaining > 0 && m.throughput_bytes_per_sec > 0) {
|
||||
const avgBlobSize = m.migrated_bytes / Math.max(m.migrated_blobs, 1);
|
||||
const etaSecs = Math.round((remaining * avgBlobSize) / m.throughput_bytes_per_sec);
|
||||
const etaMin = Math.ceil(etaSecs / 60);
|
||||
document.getElementById('migration-eta').textContent =
|
||||
`~${etaMin} min remaining`;
|
||||
}
|
||||
} else {
|
||||
document.getElementById('migration-throughput').textContent = '';
|
||||
document.getElementById('migration-eta').textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Failed blobs section
|
||||
const failedSection = document.getElementById('migration-failed-section');
|
||||
if (m.failed_blobs && m.failed_blobs.length > 0) {
|
||||
failedSection.style.display = '';
|
||||
document.getElementById('migration-failed-count').textContent = m.failed_blobs.length;
|
||||
document.getElementById('migration-failed-list').textContent = m.failed_blobs.join('\n');
|
||||
} else {
|
||||
failedSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Button visibility
|
||||
document.getElementById('btn-start-migration').style.display =
|
||||
(!isActive && !isCompleted) ? '' : 'none';
|
||||
document.getElementById('btn-pause-migration').style.display =
|
||||
m.status === 'running' ? '' : 'none';
|
||||
document.getElementById('btn-resume-migration').style.display =
|
||||
m.status === 'paused' ? '' : 'none';
|
||||
document.getElementById('btn-verify-migration').style.display =
|
||||
isCompleted ? '' : 'none';
|
||||
document.getElementById('btn-complete-migration').style.display =
|
||||
isCompleted ? '' : 'none';
|
||||
}
|
||||
|
||||
async function loadMigrationStatus() {
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/storage/migration`, {
|
||||
headers: headers(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const m = await resp.json();
|
||||
updateMigrationUI(m);
|
||||
|
||||
// Auto-poll while running
|
||||
if (m.status === 'running') {
|
||||
if (!migrationPollTimer) {
|
||||
migrationPollTimer = setInterval(loadMigrationStatus, 2000);
|
||||
}
|
||||
} else if (migrationPollTimer) {
|
||||
clearInterval(migrationPollTimer);
|
||||
migrationPollTimer = null;
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
const btn = document.getElementById('btn-start-migration');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/storage/migration/start`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ concurrency: 4 })
|
||||
});
|
||||
if (resp.ok) {
|
||||
showMigrationMsg(t('admin.migration_started') || 'Migration started', 'success');
|
||||
loadMigrationStatus();
|
||||
} else {
|
||||
const e = await resp.json().catch(() => ({}));
|
||||
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMigrationMsg(t('admin.error_network', { message: e.message }), 'error');
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
async function pauseMigration() {
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/storage/migration/pause`, {
|
||||
method: 'POST', headers: headers(), credentials: 'same-origin'
|
||||
});
|
||||
if (resp.ok) {
|
||||
showMigrationMsg(t('admin.migration_paused_msg') || 'Migration paused', 'success');
|
||||
loadMigrationStatus();
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function resumeMigration() {
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/storage/migration/resume`, {
|
||||
method: 'POST', headers: headers(), credentials: 'same-origin'
|
||||
});
|
||||
if (resp.ok) {
|
||||
showMigrationMsg(t('admin.migration_resumed_msg') || 'Migration resumed', 'success');
|
||||
loadMigrationStatus();
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function verifyMigration() {
|
||||
const btn = document.getElementById('btn-verify-migration');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(t('admin.migration_verifying') || 'Verifying...')}`;
|
||||
const resultDiv = document.getElementById('migration-verify-result');
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/storage/migration/verify`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ sample_size: 100 })
|
||||
});
|
||||
const r = await resp.json();
|
||||
resultDiv.style.display = '';
|
||||
if (r.passed) {
|
||||
resultDiv.innerHTML = `<div class="discovery-result ok"><strong><i class="fas fa-check-circle"></i> ${escapeHtml(t('admin.migration_verify_passed') || 'Verification passed')}</strong><p>${r.sample_checked} blobs checked, ${r.pg_blob_count} total in database</p></div>`;
|
||||
} else {
|
||||
const issues = [];
|
||||
if (r.missing_in_target.length) issues.push(`${r.missing_in_target.length} missing`);
|
||||
if (r.size_mismatches.length) issues.push(`${r.size_mismatches.length} size mismatches`);
|
||||
resultDiv.innerHTML = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(t('admin.migration_verify_failed') || 'Verification failed')}</strong><p>${issues.join(', ')}</p></div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.style.display = '';
|
||||
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = `<i class="fas fa-check-double"></i> ${escapeHtml(t('admin.migration_verify') || 'Verify Integrity')}`;
|
||||
}
|
||||
|
||||
async function completeMigration() {
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/storage/migration/complete`, {
|
||||
method: 'POST', headers: headers(), credentials: 'same-origin'
|
||||
});
|
||||
if (resp.ok) {
|
||||
showMigrationMsg(t('admin.migration_completed_msg') || 'Migration finalized. Restart the server to use the new backend.', 'success');
|
||||
loadMigrationStatus();
|
||||
} else {
|
||||
const e = await resp.json().catch(() => ({}));
|
||||
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMigrationMsg(t('admin.error_network', { message: e.message }), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const me = await fetch(`${API}/auth/me`, {
|
||||
@@ -775,6 +1123,9 @@ document.getElementById('tab-btn-users').addEventListener('click', function () {
|
||||
document.getElementById('tab-btn-oidc').addEventListener('click', function () {
|
||||
switchTab('oidc', this);
|
||||
});
|
||||
document.getElementById('tab-btn-storage').addEventListener('click', function () {
|
||||
switchTab('storage', this);
|
||||
});
|
||||
|
||||
document.getElementById('ds-registration').addEventListener('change', function () {
|
||||
toggleRegistration(this.checked);
|
||||
@@ -797,3 +1148,20 @@ document.getElementById('cu-submit').addEventListener('click', submitCreateUser)
|
||||
|
||||
document.getElementById('btn-close-reset-pw').addEventListener('click', closeResetPasswordModal);
|
||||
document.getElementById('rp-submit').addEventListener('click', submitResetPassword);
|
||||
|
||||
/* ── Storage event listeners ── */
|
||||
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
|
||||
r.addEventListener('change', function () {
|
||||
toggleS3Form(this.value === 's3');
|
||||
});
|
||||
});
|
||||
document.getElementById('storage-preset').addEventListener('change', onStoragePresetChange);
|
||||
document.getElementById('btn-test-storage').addEventListener('click', testStorageConnection);
|
||||
document.getElementById('btn-save-storage').addEventListener('click', saveStorageSettings);
|
||||
|
||||
/* ── Migration event listeners ── */
|
||||
document.getElementById('btn-start-migration').addEventListener('click', startMigration);
|
||||
document.getElementById('btn-pause-migration').addEventListener('click', pauseMigration);
|
||||
document.getElementById('btn-resume-migration').addEventListener('click', resumeMigration);
|
||||
document.getElementById('btn-verify-migration').addEventListener('click', verifyMigration);
|
||||
document.getElementById('btn-complete-migration').addEventListener('click', completeMigration);
|
||||
|
||||
+44
-1
@@ -566,7 +566,50 @@
|
||||
"error_password_short": "Password must be at least 8 characters",
|
||||
"error_generic": "Failed",
|
||||
"error_network": "Network error: {{message}}",
|
||||
"error_create_user": "Failed to create user"
|
||||
"error_create_user": "Failed to create user",
|
||||
"tab_storage": "Storage",
|
||||
"storage_title": "Storage Backend",
|
||||
"storage_current_backend": "Active Backend",
|
||||
"storage_total_blobs": "Total Blobs",
|
||||
"storage_total_size": "Total Size",
|
||||
"storage_dedup_ratio": "Dedup Ratio",
|
||||
"storage_backend": "Backend Type",
|
||||
"storage_local": "Local Filesystem",
|
||||
"storage_s3": "S3-Compatible",
|
||||
"storage_provider_preset": "Provider Preset",
|
||||
"storage_preset_custom": "Custom",
|
||||
"storage_endpoint_url": "Endpoint URL",
|
||||
"storage_endpoint_hint": "Leave empty for Amazon S3 default",
|
||||
"storage_bucket": "Bucket",
|
||||
"storage_region": "Region",
|
||||
"storage_access_key": "Access Key ID",
|
||||
"storage_secret_key": "Secret Access Key",
|
||||
"storage_secret_configured": "A secret key is already configured",
|
||||
"storage_key_placeholder": "Leave empty to keep current value",
|
||||
"storage_path_style": "Force Path Style",
|
||||
"storage_path_style_hint": "Required for MinIO and some S3-compatible providers",
|
||||
"storage_test_connection": "Test Connection",
|
||||
"storage_test_success": "Connection successful",
|
||||
"storage_test_failure": "Connection failed",
|
||||
"storage_save": "Save",
|
||||
"storage_saved": "Storage settings saved successfully",
|
||||
"storage_migration": "Backend Migration",
|
||||
"storage_migration_coming_soon": "Backend migration will be available in a future update.",
|
||||
"migration_status_label": "Status:",
|
||||
"migration_start": "Start Migration",
|
||||
"migration_pause": "Pause",
|
||||
"migration_resume": "Resume",
|
||||
"migration_verify": "Verify Integrity",
|
||||
"migration_complete": "Finalize",
|
||||
"migration_started": "Migration started",
|
||||
"migration_paused_msg": "Migration paused",
|
||||
"migration_resumed_msg": "Migration resumed",
|
||||
"migration_completed_msg": "Migration finalized. Restart the server to use the new backend.",
|
||||
"migration_verifying": "Verifying…",
|
||||
"migration_verify_passed": "Verification passed",
|
||||
"migration_verify_failed": "Verification failed",
|
||||
"migration_failed_blobs": "failed blobs",
|
||||
"testing": "Testing…"
|
||||
},
|
||||
"profile": {
|
||||
"page_title": "Profile",
|
||||
|
||||
+44
-1
@@ -566,7 +566,50 @@
|
||||
"error_password_short": "La contraseña debe tener al menos 8 caracteres",
|
||||
"error_generic": "Error",
|
||||
"error_network": "Error de red: {{message}}",
|
||||
"error_create_user": "Error al crear usuario"
|
||||
"error_create_user": "Error al crear usuario",
|
||||
"tab_storage": "Almacenamiento",
|
||||
"storage_title": "Backend de Almacenamiento",
|
||||
"storage_current_backend": "Backend Activo",
|
||||
"storage_total_blobs": "Total de Blobs",
|
||||
"storage_total_size": "Tamaño Total",
|
||||
"storage_dedup_ratio": "Ratio de Dedup",
|
||||
"storage_backend": "Tipo de Backend",
|
||||
"storage_local": "Sistema de Archivos Local",
|
||||
"storage_s3": "Compatible con S3",
|
||||
"storage_provider_preset": "Proveedor Preconfigurado",
|
||||
"storage_preset_custom": "Personalizado",
|
||||
"storage_endpoint_url": "URL del Endpoint",
|
||||
"storage_endpoint_hint": "Dejar vacío para usar Amazon S3 por defecto",
|
||||
"storage_bucket": "Bucket",
|
||||
"storage_region": "Región",
|
||||
"storage_access_key": "Access Key ID",
|
||||
"storage_secret_key": "Secret Access Key",
|
||||
"storage_secret_configured": "Ya hay una clave secreta configurada",
|
||||
"storage_key_placeholder": "Dejar vacío para mantener el valor actual",
|
||||
"storage_path_style": "Forzar Path Style",
|
||||
"storage_path_style_hint": "Requerido para MinIO y algunos proveedores compatibles con S3",
|
||||
"storage_test_connection": "Probar Conexión",
|
||||
"storage_test_success": "Conexión exitosa",
|
||||
"storage_test_failure": "Conexión fallida",
|
||||
"storage_save": "Guardar",
|
||||
"storage_saved": "Configuración de almacenamiento guardada correctamente",
|
||||
"storage_migration": "Migración de Backend",
|
||||
"storage_migration_coming_soon": "La migración de backend estará disponible en una futura actualización.",
|
||||
"migration_status_label": "Estado:",
|
||||
"migration_start": "Iniciar Migración",
|
||||
"migration_pause": "Pausar",
|
||||
"migration_resume": "Reanudar",
|
||||
"migration_verify": "Verificar Integridad",
|
||||
"migration_complete": "Finalizar",
|
||||
"migration_started": "Migración iniciada",
|
||||
"migration_paused_msg": "Migración pausada",
|
||||
"migration_resumed_msg": "Migración reanudada",
|
||||
"migration_completed_msg": "Migración finalizada. Reinicia el servidor para usar el nuevo backend.",
|
||||
"migration_verifying": "Verificando…",
|
||||
"migration_verify_passed": "Verificación exitosa",
|
||||
"migration_verify_failed": "Verificación fallida",
|
||||
"migration_failed_blobs": "blobs fallidos",
|
||||
"testing": "Probando…"
|
||||
},
|
||||
"profile": {
|
||||
"page_title": "Perfil",
|
||||
|
||||
Reference in New Issue
Block a user