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:
Diocrafts
2026-04-14 21:33:38 +02:00
parent 6fc632af7e
commit cd3733b459
26 changed files with 6870 additions and 308 deletions
+180
View File
@@ -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>
+174
View File
@@ -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;
}
+368
View File
@@ -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
View File
@@ -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
View File
@@ -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",