feat(account-rotation): dedicated logging + live toast notifications

To trace whether the fallback chain actually engages during real uploads,
every rotation decision now emits a structured 'rot-log' event from the
upload-manager. main.js persists each event to a new account-rotation.log
(same directory as fileuploader.log; falls back to Desktop then userData)
and also mirrors it into the main debug log with a [ROT] prefix for
single-file grepping.

Logged events:
  - batch-start (clears _failedAccounts / _accountOverrides)
  - pre-job-swap / pre-job-swap-blocked (job picks override before first try)
  - retries-exhausted / mark-failed (enters rotation loop)
  - rotate (switched to new account, retry starting)
  - rotation-end (no override / override already failed)
  - final-error (all accounts exhausted)
  - switchAccount (main resolved the next fallback)

The renderer shows a toast on 'rotate', 'rotation-end' and 'final-error'
so fallback behavior is visible live instead of buried in logs.
This commit is contained in:
Administrator 2026-04-19 22:57:19 +02:00
parent 9b5184f76f
commit 126b1e569a
4 changed files with 136 additions and 3 deletions

View File

@ -43,7 +43,17 @@ class UploadManager extends EventEmitter {
} }
switchAccount(hoster, fallbackAccount) { switchAccount(hoster, fallbackAccount) {
const prev = this._accountOverrides.get(hoster);
this._accountOverrides.set(hoster, fallbackAccount); this._accountOverrides.set(hoster, fallbackAccount);
this._rotLog('switchAccount', {
hoster,
prevOverrideId: prev ? prev.id : null,
toAccountId: fallbackAccount ? fallbackAccount.id : null
});
}
_rotLog(event, data) {
this.emit('rot-log', { ts: Date.now(), event, ...data });
} }
updateSettings(hosterSettings, globalSettings) { updateSettings(hosterSettings, globalSettings) {
@ -138,6 +148,7 @@ class UploadManager extends EventEmitter {
// straight to the fallback even after the original recovered. // straight to the fallback even after the original recovered.
this._failedAccounts.clear(); this._failedAccounts.clear();
this._accountOverrides.clear(); this._accountOverrides.clear();
this._rotLog('batch-start', { taskCount: tasks.length });
const { signal } = this.abortController; const { signal } = this.abortController;
const batchId = `batch-${Date.now()}`; const batchId = `batch-${Date.now()}`;
@ -262,10 +273,19 @@ class UploadManager extends EventEmitter {
if (task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId)) { if (task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId)) {
const override = this._accountOverrides.get(task.hoster); const override = this._accountOverrides.get(task.hoster);
if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) { if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) {
this._rotLog('pre-job-swap', {
hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id
});
task.accountId = override.id; task.accountId = override.id;
task.username = override.username; task.username = override.username;
task.password = override.password; task.password = override.password;
task.apiKey = override.apiKey; task.apiKey = override.apiKey;
} else {
this._rotLog('pre-job-swap-blocked', {
hoster: task.hoster, fileName, accountId: task.accountId,
hasOverride: !!override,
overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false
});
} }
} }
@ -471,14 +491,39 @@ class UploadManager extends EventEmitter {
// resolve the next fallback, then retry. Loop so A → B → C → ... works // resolve the next fallback, then retry. Loop so A → B → C → ... works
// for hosters with 3+ accounts (the old code only did one level: A → B // for hosters with 3+ accounts (the old code only did one level: A → B
// and stopped, even if C would have worked). // and stopped, even if C would have worked).
this._rotLog('retries-exhausted', {
hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
while (task.accountId && !this._failedAccounts.has(task.hoster + ':' + task.accountId)) { while (task.accountId && !this._failedAccounts.has(task.hoster + ':' + task.accountId)) {
if (signal.aborted || this.stopAfterActive) break; if (signal.aborted || this.stopAfterActive) break;
this._failedAccounts.set(task.hoster + ':' + task.accountId, true); this._failedAccounts.set(task.hoster + ':' + task.accountId, true);
this._rotLog('mark-failed', {
hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId }); this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId });
await this._sleep(800, signal); await this._sleep(800, signal);
const override = this._accountOverrides.get(task.hoster); const override = this._accountOverrides.get(task.hoster);
if (!override || this._failedAccounts.has(task.hoster + ':' + override.id)) break; if (!override) {
this._rotLog('rotation-end', {
hoster: task.hoster, fileName, reason: 'no-override-set',
lastFailedAccountId: task.accountId
});
break;
}
if (this._failedAccounts.has(task.hoster + ':' + override.id)) {
this._rotLog('rotation-end', {
hoster: task.hoster, fileName, reason: 'override-already-failed',
overrideId: override.id, lastFailedAccountId: task.accountId
});
break;
}
// Switch to fallback account and retry this file // Switch to fallback account and retry this file
this._rotLog('rotate', {
hoster: task.hoster, fileName,
fromAccountId: task.accountId, toAccountId: override.id
});
task.accountId = override.id; task.accountId = override.id;
task.username = override.username; task.username = override.username;
task.password = override.password; task.password = override.password;
@ -551,6 +596,9 @@ class UploadManager extends EventEmitter {
} }
const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler'; const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler';
this._rotLog('final-error', {
hoster: task.hoster, fileName, lastFailedAccountId: task.accountId, error
});
emitFinalStatus('error', { error }); emitFinalStatus('error', { error });
recordFinalResult('error', { error }); recordFinalResult('error', { error });
} catch (err) { } catch (err) {

73
main.js
View File

@ -68,6 +68,58 @@ function debugLog(msg) {
} catch {} } catch {}
} }
// Dedicated account-rotation log so users can trace fallback decisions
// without wading through general debug output. Writes to account-rotation.log
// in the same directory as fileuploader.log (honors user's configured path).
function getRotLogPath() {
const base = getLogFilePath();
const dir = path.dirname(base);
return path.join(dir, 'account-rotation.log');
}
const _rotLogBuffer = [];
let _rotLogFlushTimer = null;
let _rotLogWriting = false;
function _flushRotLog() {
if (_rotLogWriting || _rotLogBuffer.length === 0) return;
const chunk = _rotLogBuffer.join('');
_rotLogBuffer.length = 0;
_rotLogWriting = true;
const tryTargets = [
getRotLogPath(),
path.join(app.getPath('desktop') || app.getPath('userData'), 'account-rotation.log'),
path.join(app.getPath('userData'), 'account-rotation.log')
];
const write = (i) => {
if (i >= tryTargets.length) { _rotLogWriting = false; return; }
try {
fs.mkdirSync(path.dirname(tryTargets[i]), { recursive: true });
} catch {}
fs.appendFile(tryTargets[i], chunk, 'utf-8', (err) => {
if (err) return write(i + 1);
_rotLogWriting = false;
if (_rotLogBuffer.length) setImmediate(_flushRotLog);
});
};
write(0);
}
function rotLog(msg, ts) {
try {
const iso = new Date(ts || Date.now()).toISOString();
const line = `[${iso}] ${msg}\n`;
_rotLogBuffer.push(line);
// Mirror into the main debug log for single-file-grep convenience.
_debugLogBuffer.push(`[${iso}] [ROT] ${msg}\n`);
if (!_rotLogFlushTimer) {
_rotLogFlushTimer = setTimeout(() => { _rotLogFlushTimer = null; _flushRotLog(); }, 500);
}
if (!_debugLogFlushTimer) {
_debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500);
}
} catch {}
}
// Catch unhandled rejections from fire-and-forget async calls // Catch unhandled rejections from fire-and-forget async calls
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
debugLog(`UNHANDLED REJECTION: ${reason && reason.stack ? reason.stack : reason}`); debugLog(`UNHANDLED REJECTION: ${reason && reason.stack ? reason.stack : reason}`);
@ -802,6 +854,12 @@ app.on('before-quit', () => {
_uploadLogBuffer.length = 0; _uploadLogBuffer.length = 0;
} }
} catch {} } catch {}
try {
if (_rotLogBuffer.length) {
fs.appendFileSync(getRotLogPath(), _rotLogBuffer.join(''), 'utf-8');
_rotLogBuffer.length = 0;
}
} catch {}
}); });
// --- IPC Handlers --- // --- IPC Handlers ---
@ -1019,13 +1077,24 @@ ipcMain.handle('start-upload', (_event, payload) => {
const cfg = configStore.load(); const cfg = configStore.load();
const fallback = getNextFallbackAccount(cfg, hoster, accountId); const fallback = getNextFallbackAccount(cfg, hoster, accountId);
if (fallback) { if (fallback) {
debugLog(`account-failed: ${hoster} ${accountId}fallback to ${fallback.id}`); rotLog(`main: account-failed ${hoster} ${accountId}resolved fallback ${fallback.id}`);
uploadManager.switchAccount(hoster, fallback); uploadManager.switchAccount(hoster, fallback);
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id }); mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
} }
} else { } else {
debugLog(`account-failed: ${hoster} ${accountId} → no fallback available`); rotLog(`main: account-failed ${hoster} ${accountId} → NO fallback available (end of chain)`);
}
});
uploadManager.on('rot-log', (entry) => {
const { ts, event, ...rest } = entry;
const pairs = Object.entries(rest)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ');
rotLog(`[${event}] ${pairs}`, ts);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('account-rotation-log', entry);
} }
}); });

View File

@ -104,6 +104,9 @@ contextBridge.exposeInMainWorld('api', {
onUploadLogFallback: (callback) => { onUploadLogFallback: (callback) => {
ipcRenderer.on('upload-log-fallback', (_event, data) => callback(data)); ipcRenderer.on('upload-log-fallback', (_event, data) => callback(data));
}, },
onAccountRotationLog: (callback) => {
ipcRenderer.on('account-rotation-log', (_event, data) => callback(data));
},
// Remote Control // Remote Control
remoteGetSettings: () => ipcRenderer.invoke('remote:get-settings'), remoteGetSettings: () => ipcRenderer.invoke('remote:get-settings'),
remoteSaveSettings: (settings) => ipcRenderer.invoke('remote:save-settings', settings), remoteSaveSettings: (settings) => ipcRenderer.invoke('remote:save-settings', settings),

View File

@ -120,6 +120,19 @@ async function init() {
window.api.onUploadLogFallback((data) => { window.api.onUploadLogFallback((data) => {
alert('Der konfigurierte Log-Pfad konnte nicht beschrieben werden.\n\nNeue Einträge werden zwischenzeitlich hier gespeichert:\n' + (data && data.fallbackPath ? data.fallbackPath : '(Fallback)') + '\n\nBitte in den Einstellungen einen gültigen Pfad setzen.'); alert('Der konfigurierte Log-Pfad konnte nicht beschrieben werden.\n\nNeue Einträge werden zwischenzeitlich hier gespeichert:\n' + (data && data.fallbackPath ? data.fallbackPath : '(Fallback)') + '\n\nBitte in den Einstellungen einen gültigen Pfad setzen.');
}); });
window.api.onAccountRotationLog((entry) => {
// Surface only the user-visible rotation events as toasts; full detail
// goes to account-rotation.log. Keep it quiet otherwise.
if (!entry || !entry.event) return;
const hosterLabel = entry.hoster ? getHosterLabel(entry.hoster) : '';
if (entry.event === 'rotate') {
showCopyToast(`${hosterLabel}: Account-Wechsel → Fallback`);
} else if (entry.event === 'rotation-end') {
showCopyToast(`${hosterLabel}: Keine weiteren Fallback-Accounts verfügbar`);
} else if (entry.event === 'final-error') {
showCopyToast(`${hosterLabel}: Alle Accounts ausgeschöpft`);
}
});
// Folder monitor: auto-queue new files // Folder monitor: auto-queue new files
window.api.onFolderMonitorNewFiles((files) => { window.api.onFolderMonitorNewFiles((files) => {