feat: add expiring backups and live account checks
CI / verify (push) Canceled after 0s

This commit is contained in:
Sucukdeluxe
2026-09-01 15:43:20 +02:00
parent 96037f2e1c
commit 9e5c5126dd
19 changed files with 710 additions and 128 deletions
+102 -28
View File
@@ -65,6 +65,7 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
let hosterSettings = {};
let uploading = false;
let healthCheckRunning = false;
let healthCheckRequestSequence = 0;
let automationRuntimeStatus = Object.freeze({});
let automationRuntimeStatusAvailable = false;
let automationPauseResumeBusy = false;
@@ -87,6 +88,7 @@ let managedOnlineBackupAuthoritativeLoadGeneration = 0;
let managedOnlineBackupActiveMutations = 0;
let onlineBackupStatusContextGeneration = 0;
let managedOnlineBackupRefreshIssue = null;
let managedOnlineBackupExpiryTimer = null;
const managedOnlineBackupOperationQueues = new Map();
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
@@ -1345,6 +1347,10 @@ async function init() {
setUiLanguage(config.globalSettings?.language);
hosterSettings = config.hosterSettings || {};
autoHealthCheckEnabled = loadAutoCheckPreference();
if (config.globalSettings?.autoHealthCheckEnabled !== autoHealthCheckEnabled) {
config.globalSettings = { ...(config.globalSettings || {}), autoHealthCheckEnabled };
saveGlobalSettingsTracked(config.globalSettings).catch(() => {});
}
ensureAccountStatusEntries();
syncSelectedUploadHosters();
restoreQueueStateFromConfig();
@@ -3599,7 +3605,13 @@ function applyImportedConfig(importedConfig, message) {
accountStatuses = {};
ensureAccountStatusEntries();
syncSelectedUploadHosters();
autoHealthCheckEnabled = config.globalSettings?.autoHealthCheckEnabled !== false;
try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {}
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
const importedLanguage = setUiLanguage(config.globalSettings?.language);
const importedUrl = new URL(window.location.href);
importedUrl.searchParams.set('language', importedLanguage);
window.history.replaceState(null, '', importedUrl.href);
renderSettings();
renderAccounts();
renderHosterSummary();
@@ -3678,12 +3690,20 @@ function normalizeManagedOnlineBackups(entries) {
const candidates = [];
for (const entry of Array.isArray(entries) ? entries : []) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
if (Object.keys(entry).sort().join(',') !== 'createdAt,displayKey,id') continue;
const shape = Object.keys(entry).sort().join(',');
if (shape !== 'createdAt,displayKey,id' && shape !== 'createdAt,displayKey,expiresAt,id') continue;
if (!isCanonicalManagedOnlineBackupId(entry.id)) continue;
if (typeof entry.displayKey !== 'string' || !/^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey)) continue;
const createdAt = new Date(entry.createdAt);
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== entry.createdAt) continue;
candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt });
let expiresAt = null;
if (shape.includes('expiresAt') && entry.expiresAt !== null) {
const expiration = new Date(entry.expiresAt);
if (typeof entry.expiresAt !== 'string' || Number.isNaN(expiration.getTime()) || expiration.toISOString() !== entry.expiresAt || entry.expiresAt <= entry.createdAt) continue;
expiresAt = entry.expiresAt;
}
if (expiresAt !== null && new Date(expiresAt).getTime() <= Date.now()) continue;
candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt, expiresAt });
}
const counts = new Map();
for (const entry of candidates) counts.set(entry.id, (counts.get(entry.id) || 0) + 1);
@@ -3739,6 +3759,8 @@ function removeManagedOnlineBackup(id, focusTarget = null) {
function renderManagedOnlineBackups(focusTarget = undefined) {
const list = document.getElementById('managedOnlineBackupList');
if (!list) return;
clearTimeout(managedOnlineBackupExpiryTimer);
managedOnlineBackupExpiryTimer = null;
const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget;
const content = document.createDocumentFragment();
if (!managedOnlineBackupsAuthoritative) {
@@ -3761,7 +3783,13 @@ function renderManagedOnlineBackups(focusTarget = undefined) {
key.textContent = entry.displayKey;
const created = document.createElement('span');
created.className = 'online-backup-managed-created';
created.textContent = formatDateTime(entry.createdAt).text;
const createdLabel = document.createElement('span');
createdLabel.textContent = `${localizeUiText('Erstellt')}: ${formatDateTime(entry.createdAt).text}`;
const expirationLabel = document.createElement('span');
expirationLabel.textContent = entry.expiresAt
? `${localizeUiText('Gültig bis')}: ${formatDateTime(entry.expiresAt).text}`
: localizeUiText('Unbegrenzt gültig');
created.append(createdLabel, expirationLabel);
const actions = document.createElement('div');
actions.className = 'online-backup-managed-actions';
const copyButton = document.createElement('button');
@@ -3790,6 +3818,17 @@ function renderManagedOnlineBackups(focusTarget = undefined) {
}
list.replaceChildren(content);
restoreManagedOnlineBackupFocus(target);
const nextExpiry = managedOnlineBackups
.map(entry => entry.expiresAt ? new Date(entry.expiresAt).getTime() : 0)
.filter(timestamp => timestamp > Date.now())
.sort((left, right) => left - right)[0];
if (nextExpiry) {
const delay = Math.min(2_147_000_000, Math.max(0, nextExpiry - Date.now() + 50));
managedOnlineBackupExpiryTimer = setTimeout(() => {
managedOnlineBackupExpiryTimer = null;
loadManagedOnlineBackups();
}, delay);
}
}
function renderManagedOnlineBackupRefreshIssue() {
@@ -3948,10 +3987,13 @@ async function doOnlineBackupCreate() {
}
const authority = beginManagedOnlineBackupMutation();
const createButton = document.getElementById('createOnlineBackupBtn');
const retentionSelect = document.getElementById('onlineBackupRetentionSelect');
const retention = retentionSelect?.value || '7d';
if (createButton) createButton.disabled = true;
if (retentionSelect) retentionSelect.disabled = true;
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', authority.statusContext);
try {
const result = await window.api.createManagedOnlineBackup();
const result = await window.api.createManagedOnlineBackup(retention);
if (!result?.ok) {
setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext);
return;
@@ -3964,6 +4006,7 @@ async function doOnlineBackupCreate() {
} finally {
endManagedOnlineBackupMutation();
if (createButton?.isConnected) createButton.disabled = false;
if (retentionSelect?.isConnected) retentionSelect.disabled = false;
doOnlineBackupCreate.busy = false;
}
}
@@ -6013,33 +6056,46 @@ function showAppChoice({ message, title, confirmText, alternateText, cancelText
async function executeHealthCheck(hosters, _mode, generations) {
renderHealthCheckResults([]);
const result = await window.api.runHealthCheck({ hosters });
const rows = result && Array.isArray(result.results) ? result.results : [];
const checkedAt = result?.checkedAt || new Date().toISOString();
const currentRows = rows.filter((row) => {
const requestId = `hc-${Date.now()}-${++healthCheckRequestSequence}`;
const rowsByKey = new Map();
const applyResult = (row, checkedAt) => {
if (!row) return false;
const key = row.accountId || row.hoster;
const generation = generations?.get(key);
return generation === undefined || _isCurrentAccountStatusGeneration(key, generation);
});
const completedKeys = new Set();
currentRows.forEach((row) => {
const key = row.accountId || row.hoster;
if (key) {
completedKeys.add(key);
accountStatuses[key] = {
status: row.status || 'unchecked',
message: row.message || '',
checkedAt: row.checkedAt || checkedAt
};
}
});
for (const [key, generation] of generations || []) {
if (completedKeys.has(key) || !_isCurrentAccountStatusGeneration(key, generation)) continue;
accountStatuses[key] = { status: 'error', message: 'Keine Antwort vom Hoster erhalten', checkedAt };
if (!key || (generation !== undefined && !_isCurrentAccountStatusGeneration(key, generation))) return false;
rowsByKey.set(key, row);
accountStatuses[key] = {
status: row.status || 'unchecked',
message: row.message || '',
checkedAt: row.checkedAt || checkedAt
};
if (row.accountId) updateAccountCard(row.accountId);
else renderAccounts();
renderHosterModal();
renderHealthCheckResults([...rowsByKey.values()]);
return true;
};
const stopListening = typeof window.api.onHealthCheckResult === 'function'
? window.api.onHealthCheckResult((payload) => {
if (payload?.requestId === requestId) applyResult(payload.result, payload.checkedAt || new Date().toISOString());
})
: null;
let result;
try {
result = await window.api.runHealthCheck({ hosters, requestId });
} finally {
if (typeof stopListening === 'function') stopListening();
}
const rows = result && Array.isArray(result.results) ? result.results : [];
const checkedAt = result?.checkedAt || new Date().toISOString();
rows.forEach(row => applyResult(row, checkedAt));
for (const [key, generation] of generations || []) {
if (rowsByKey.has(key) || !_isCurrentAccountStatusGeneration(key, generation)) continue;
accountStatuses[key] = { status: 'error', message: 'Keine Antwort vom Hoster erhalten', checkedAt };
updateAccountCard(key);
}
const currentRows = [...rowsByKey.values()];
renderHealthCheckResults(currentRows);
renderAccounts();
renderHosterModal();
return currentRows;
}
@@ -6553,6 +6609,18 @@ function renderSettings() {
</section>
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
<footer class="online-backup-footer" data-settings-search-entry data-settings-search-section="Online-Backup" data-settings-search-label="Neuen Schlüssel erzeugen">
<div class="online-backup-retention-field">
<label for="onlineBackupRetentionSelect">Gültigkeitsdauer</label>
<span class="online-backup-retention-select">
<select id="onlineBackupRetentionSelect">
<option value="1d">24 Stunden</option>
<option value="3d">3 Tage</option>
<option value="7d" selected>7 Tage (Standard)</option>
<option value="31d">31 Tage</option>
<option value="forever">Unbegrenzt</option>
</select>
</span>
</div>
<button class="btn btn-primary" id="createOnlineBackupBtn">Neuen Schlüssel erzeugen</button>
</footer>
</section>
@@ -9283,6 +9351,8 @@ function setupListeners() {
autoToggle.addEventListener('change', (e) => {
autoHealthCheckEnabled = !!e.target.checked;
try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {}
config.globalSettings = { ...(config.globalSettings || {}), autoHealthCheckEnabled };
saveGlobalSettingsTracked(config.globalSettings).catch(() => {});
});
}
@@ -9967,8 +10037,12 @@ function formatDateTime(value) {
}
function loadAutoCheckPreference() {
try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; }
catch { return true; }
try {
const stored = localStorage.getItem(AUTO_CHECK_PREF_KEY);
if (stored === '0' || stored === '1') return stored === '1';
}
catch {}
return config.globalSettings?.autoHealthCheckEnabled !== false;
}
// --- Queue table column resizing (JDownloader-style) ---
+10
View File
@@ -378,6 +378,14 @@
['Verschlüsseltes Online-Backup', 'Encrypted online backup'],
['Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.', 'Encryption takes place only on this device. The server stores encrypted data only.'],
['Neuen Schlüssel erzeugen', 'Generate new key'],
['Gültigkeitsdauer', 'Validity period'],
['24 Stunden', '24 hours'],
['3 Tage', '3 days'],
['7 Tage (Standard)', '7 days (default)'],
['31 Tage', '31 days'],
['Erstellt', 'Created'],
['Gültig bis', 'Valid until'],
['Unbegrenzt gültig', 'Valid indefinitely'],
['Auf diesem Gerät erstellt', 'Created on this device'],
['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'],
['Schlüssel kopieren', 'Copy key'],
@@ -541,6 +549,8 @@
['Nicht alle Einstellungen konnten gespeichert werden', 'Not all settings could be saved'],
['Online-Schlüssel kopiert', 'Online key copied'],
['Online-Sicherung konnte nicht erstellt werden', 'Online backup could not be created'],
['Gültigkeitsdauer der Online-Sicherung ist ungültig', 'Online backup validity period is invalid'],
['Erstellungszeit der Online-Sicherung ist ungültig', 'Online backup creation time is invalid'],
['Online-Sicherungen konnten nicht geladen werden', 'Online backups could not be loaded'],
['Online-Sicherung konnte nicht kopiert werden', 'Online backup could not be copied'],
['Online-Sicherung konnte nicht importiert werden', 'Online backup could not be imported'],
+66 -1
View File
@@ -2173,7 +2173,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.online-backup-managed-row {
display: grid;
grid-template-columns: 168px 188px minmax(0, 1fr);
grid-template-columns: 168px 238px minmax(0, 1fr);
gap: 10px;
align-items: center;
min-width: 0;
@@ -2192,6 +2192,8 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
}
.online-backup-managed-created {
display: grid;
gap: 3px;
min-width: 0;
color: var(--text-dim);
font-size: 13px;
@@ -2240,10 +2242,64 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.online-backup-footer {
display: flex;
align-items: flex-end;
gap: 12px;
justify-content: flex-end;
min-width: 0;
}
.online-backup-retention-field {
display: grid;
gap: 6px;
min-width: 190px;
}
.online-backup-retention-field label {
color: var(--text);
font-size: 12px;
font-weight: 600;
}
.online-backup-retention-select {
position: relative;
}
.online-backup-retention-select::after {
position: absolute;
top: 50%;
right: 14px;
width: 7px;
height: 7px;
border-right: 2px solid var(--text-dim);
border-bottom: 2px solid var(--text-dim);
content: '';
pointer-events: none;
transform: translateY(-65%) rotate(45deg);
}
.online-backup-retention-select select {
width: 100%;
min-height: 34px;
padding: 7px 38px 7px 11px;
border: 1px solid var(--border);
border-radius: 6px;
appearance: none;
color: var(--text);
background: var(--bg-card);
font: inherit;
font-size: 13px;
}
.online-backup-retention-select select:focus {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 32%, transparent);
outline-offset: 1px;
}
.online-backup-retention-select:has(select:disabled)::after {
opacity: 0.5;
}
.online-backup-status {
min-height: 18px;
color: var(--text-dim);
@@ -2273,6 +2329,15 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.online-backup-key-row {
grid-template-columns: 1fr;
}
.online-backup-footer {
align-items: stretch;
flex-direction: column;
}
.online-backup-retention-field {
width: 100%;
}
}
.icon-sprite {