diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 2771c9e..a362910 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -342,7 +342,7 @@ class UploadManager extends EventEmitter { }; const emitFinalStatus = (status, payload = {}) => { - this._emitProgress(uploadId, fileName, task.hoster, { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status, progress: status === 'done' ? 1 : 0, @@ -378,7 +378,7 @@ class UploadManager extends EventEmitter { return; } - this._emitProgress(uploadId, fileName, task.hoster, { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status: 'queued', progress: 0, @@ -416,7 +416,7 @@ class UploadManager extends EventEmitter { const override = this._accountOverrides.get(task.hoster); if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) { this._rotLog('pre-job-swap', { - hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id + jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id }); task.accountId = override.id; task.username = override.username; @@ -424,7 +424,7 @@ class UploadManager extends EventEmitter { task.apiKey = override.apiKey; } else { this._rotLog('pre-job-swap-blocked', { - hoster: task.hoster, fileName, accountId: task.accountId, + jobId, hoster: task.hoster, fileName, accountId: task.accountId, hasOverride: !!override, overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false }); @@ -435,7 +435,7 @@ class UploadManager extends EventEmitter { if (signal.aborted || this.stopAfterActive) break; if (attempt > 1) { - this._emitProgress(uploadId, fileName, task.hoster, { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status: 'retrying', progress: 0, @@ -462,7 +462,7 @@ class UploadManager extends EventEmitter { let uploadSignalBundle = { signal, cleanup() {} }; try { - this._emitProgress(uploadId, fileName, task.hoster, { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status: 'getting-server', progress: 0, @@ -531,7 +531,7 @@ class UploadManager extends EventEmitter { ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; - this._emitProgress(uploadId, fileName, task.hoster, { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status: 'uploading', progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, @@ -590,7 +590,7 @@ class UploadManager extends EventEmitter { // jump straight to rotation. if (this._shouldSkipRetryOnAccountError(err)) { this._rotLog('fast-fail', { - hoster: task.hoster, fileName, accountId: task.accountId, + jobId, hoster: task.hoster, fileName, accountId: task.accountId, attempt, error: err && err.message ? err.message : String(err) }); break; @@ -622,7 +622,7 @@ class UploadManager extends EventEmitter { // already marked it failed). Otherwise the second job falls straight // through to final-error instead of using the already-resolved fallback. this._rotLog('retries-exhausted', { - hoster: task.hoster, fileName, accountId: task.accountId, + jobId, hoster: task.hoster, fileName, accountId: task.accountId, lastError: lastError ? lastError.message : null }); // File-specific rejection → same file will get the same verdict on @@ -630,7 +630,7 @@ class UploadManager extends EventEmitter { // retry siblings, just fail this file cleanly. if (this._isFileRejectedError(lastError)) { this._rotLog('skip-rotation-file-rejected', { - hoster: task.hoster, fileName, accountId: task.accountId, + jobId, hoster: task.hoster, fileName, accountId: task.accountId, lastError: lastError ? lastError.message : null }); const error = lastError.message || 'Datei abgelehnt'; @@ -643,7 +643,7 @@ class UploadManager extends EventEmitter { // can still try fresh. This file just errors out for now. if (this._isTransientNetworkError(lastError)) { this._rotLog('skip-rotation-transient', { - hoster: task.hoster, fileName, accountId: task.accountId, + jobId, hoster: task.hoster, fileName, accountId: task.accountId, lastError: lastError ? lastError.message : null }); const error = lastError.message || 'Netzwerkfehler'; @@ -657,48 +657,48 @@ class UploadManager extends EventEmitter { if (!alreadyMarked) { this._failedAccounts.set(task.hoster + ':' + task.accountId, true); this._rotLog('mark-failed', { - hoster: task.hoster, fileName, accountId: task.accountId, + jobId, hoster: task.hoster, fileName, accountId: task.accountId, lastError: lastError ? lastError.message : null }); this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId }); await this._sleep(800, signal); } else { this._rotLog('already-marked', { - hoster: task.hoster, fileName, accountId: task.accountId + jobId, hoster: task.hoster, fileName, accountId: task.accountId }); } const override = this._accountOverrides.get(task.hoster); if (!override) { this._rotLog('rotation-end', { - hoster: task.hoster, fileName, reason: 'no-override-set', + jobId, 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', + jobId, hoster: task.hoster, fileName, reason: 'override-already-failed', overrideId: override.id, lastFailedAccountId: task.accountId }); break; } if (override.id === task.accountId) { this._rotLog('rotation-end', { - hoster: task.hoster, fileName, reason: 'override-same-as-current', + jobId, hoster: task.hoster, fileName, reason: 'override-same-as-current', lastFailedAccountId: task.accountId }); break; } // Switch to fallback account and retry this file this._rotLog('rotate', { - hoster: task.hoster, fileName, + jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id }); task.accountId = override.id; task.username = override.username; task.password = override.password; task.apiKey = override.apiKey; - this._emitProgress(uploadId, fileName, task.hoster, { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, speedKbs: 0, elapsed: 0, remaining: 0, error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts @@ -709,7 +709,7 @@ class UploadManager extends EventEmitter { for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (signal.aborted || this.stopAfterActive) break; if (attempt > 1) { - this._emitProgress(uploadId, fileName, task.hoster, { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, speedKbs: 0, elapsed: 0, remaining: 0, error: lastError ? lastError.message : '', result: null, attempt, maxAttempts @@ -736,7 +736,7 @@ class UploadManager extends EventEmitter { activeEntry.bytesUploaded = bytesUploaded; const elapsed = Math.round((now - jobStart) / 1000); const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; - this._emitProgress(uploadId, fileName, task.hoster, { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, status: 'uploading', progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs, @@ -767,7 +767,7 @@ class UploadManager extends EventEmitter { const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler'; this._rotLog('final-error', { - hoster: task.hoster, fileName, lastFailedAccountId: task.accountId, error + jobId, hoster: task.hoster, fileName, lastFailedAccountId: task.accountId, error }); emitFinalStatus('error', { error }); recordFinalResult('error', { error }); diff --git a/main.js b/main.js index 49a5ca7..d6cd260 100644 --- a/main.js +++ b/main.js @@ -26,6 +26,19 @@ let uploadManager = null; // dead. Cleared on app restart (which is the user's signal for "try fresh"). const _sessionFailedAccounts = new Map(); // "hoster:accountId" -> true const _sessionAccountOverrides = new Map(); // hoster -> account object + +// Per-job log collector: backs the right-click "Log anzeigen" modal so the +// user can see the full rot-log + status trail for a single file without +// grepping account-rotation.log. Ring buffer per job keeps memory bounded. +const _jobLogCollector = new Map(); // jobId -> Array +const _MAX_LOG_ENTRIES_PER_JOB = 200; +function _appendJobLog(jobId, entry) { + if (!jobId) return; + let arr = _jobLogCollector.get(jobId); + if (!arr) { arr = []; _jobLogCollector.set(jobId, arr); } + if (arr.length >= _MAX_LOG_ENTRIES_PER_JOB) arr.shift(); + arr.push(entry); +} const folderMonitor = new FolderMonitor(); let remoteServer = null; let captureWindow = null; @@ -1120,6 +1133,11 @@ ipcMain.handle('start-upload', (_event, payload) => { if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.', skippedJobs }; + // Fresh collector for this new batch — old entries from the previous + // batch's jobs are dropped (user's signal for "fresh log" is starting a + // new upload; addJobs during a running batch keeps them). + _jobLogCollector.clear(); + // Pass hoster settings to the upload manager uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}); @@ -1127,6 +1145,11 @@ ipcMain.handle('start-upload', (_event, payload) => { // Only log state changes, not continuous progress updates if (data.status !== 'uploading') { debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`); + _appendJobLog(data.jobId, { + ts: Date.now(), kind: 'progress', status: data.status, + hoster: data.hoster, accountId: data.accountId || null, + error: data.error || null, attempt: data.attempt || 0, maxAttempts: data.maxAttempts || 0 + }); } // Write to fileuploader.log immediately when a single upload finishes if (data.status === 'done' && data.result) { @@ -1179,6 +1202,9 @@ ipcMain.handle('start-upload', (_event, payload) => { .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) .join(' '); rotLog(`[${event}] ${pairs}`, ts); + if (entry.jobId) { + _appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest }); + } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('account-rotation-log', entry); } @@ -1291,6 +1317,12 @@ ipcMain.handle('finish-after-active', () => { return true; }); +ipcMain.handle('get-job-log', (_event, jobId) => { + if (!jobId || typeof jobId !== 'string') return []; + const arr = _jobLogCollector.get(jobId); + return Array.isArray(arr) ? arr.slice() : []; +}); + ipcMain.handle('open-log-folder', async () => { // Reveal the active log file (or its directory) in the OS file manager. // Prefers the configured log path, then the rotation log, then just the diff --git a/preload.js b/preload.js index 2ce3d3f..654793b 100644 --- a/preload.js +++ b/preload.js @@ -108,6 +108,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('account-rotation-log', (_event, data) => callback(data)); }, openLogFolder: () => ipcRenderer.invoke('open-log-folder'), + getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId), onLogPathAutoUpdated: (callback) => { ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data)); }, diff --git a/renderer/app.js b/renderer/app.js index 28e11c3..2cbaecf 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1162,16 +1162,31 @@ function getStatusOrder(status) { return order[status] ?? 4; } +// "Primär" / "Fallback #1" / "Fallback #2"… derived from the job's current +// accountId position in the configured hoster account list. Returns '' if we +// can't resolve it (e.g. account was removed mid-session). +function getAccountLabel(job) { + if (!job || !job.accountId || !job.hoster) return ''; + const accounts = config && config.hosters && config.hosters[job.hoster]; + if (!Array.isArray(accounts)) return ''; + const idx = accounts.findIndex(a => a && a.id === job.accountId); + if (idx < 0) return ''; + return idx === 0 ? 'Primär' : `Fallback #${idx}`; +} + function getStatusText(job) { const shortErr = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : ''; + const acc = getAccountLabel(job); + const accSuffix = acc ? ` · ${acc}` : ''; switch (job.status) { case 'preview': return 'Bereit'; case 'queued': return 'Wartet'; - case 'getting-server': return 'Server...'; - case 'uploading': return 'Upload'; - case 'retrying': return shortErr - ? `Retry ${job.attempt}/${job.maxAttempts}: ${shortErr}` - : `Retry ${job.attempt}/${job.maxAttempts}`; + case 'getting-server': return `Server...${accSuffix}`; + case 'uploading': return `Upload${accSuffix}`; + case 'retrying': { + const base = `Retry ${job.attempt}/${job.maxAttempts}${accSuffix}`; + return shortErr ? `${base}: ${shortErr}` : base; + } case 'done': return 'Fertig'; case 'aborted': return 'Abgebrochen'; case 'error': return shortErr ? `Fehlgeschlagen: ${shortErr}` : 'Fehlgeschlagen'; @@ -1534,6 +1549,8 @@ async function handleContextAction(action) { if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } else if (action === 'retry-selected') { retrySelectedJobs(); + } else if (action === 'show-log') { + showJobLogModal(); } else if (action === 'delete-selected') { // Cancel active uploads for deleted jobs const activeIds = [...selectedJobIds].filter(id => { @@ -1854,6 +1871,9 @@ function handleProgress(data) { job.attempt = data.attempt || 0; job.maxAttempts = data.maxAttempts || 0; job.progress = data.progress || 0; + // Track which account the backend is currently using so the status cell + // can display "Primär" vs "Fallback #N" during rotation. + if (data.accountId) job.accountId = data.accountId; if (data.uploadId) { job.uploadId = data.uploadId; _jobIndexByUploadId.set(data.uploadId, job); @@ -1967,6 +1987,60 @@ function handleStats(data) { } } +// --- Per-job log modal --- +async function showJobLogModal() { + if (selectedJobIds.size === 0) return; + // Use the first selected job — log view is per-file, multi-select doesn't + // make sense here. + const jobId = [...selectedJobIds][0]; + const job = _jobIndexById.get(jobId); + const modal = document.getElementById('jobLogModal'); + const titleEl = document.getElementById('jobLogTitle'); + const bodyEl = document.getElementById('jobLogBody'); + if (!modal || !titleEl || !bodyEl) return; + + titleEl.textContent = job && job.fileName ? `Log · ${job.fileName}` : 'Upload-Log'; + bodyEl.textContent = 'Lade…'; + modal.style.display = 'flex'; + + let entries = []; + try { entries = await window.api.getJobLog(jobId); } catch {} + + if (!Array.isArray(entries) || entries.length === 0) { + bodyEl.textContent = 'Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).'; + return; + } + + const fmt = (e) => { + const t = new Date(e.ts || Date.now()).toLocaleTimeString('de-DE', { hour12: false }) + '.' + + String((e.ts || 0) % 1000).padStart(3, '0'); + if (e.kind === 'progress') { + const attempt = e.attempt ? ` (${e.attempt}/${e.maxAttempts || '?'})` : ''; + const acc = e.accountId ? ` acc=${e.accountId.slice(0, 32)}` : ''; + const err = e.error ? `\n → ${e.error}` : ''; + return `[${t}] status=${e.status}${attempt}${acc}${err}`; + } + // rot-log + const rest = Object.entries(e) + .filter(([k]) => !['ts', 'kind', 'event', 'jobId'].includes(k)) + .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) + .join(' '); + return `[${t}] [${e.event}] ${rest}`; + }; + bodyEl.textContent = entries.map(fmt).join('\n'); +} + +function hideJobLogModal() { + const m = document.getElementById('jobLogModal'); + if (m) m.style.display = 'none'; +} + +async function copyJobLogToClipboard() { + const body = document.getElementById('jobLogBody'); + if (!body || !body.textContent) return; + try { await window.api.copyToClipboard(body.textContent); showCopyToast('Log in Zwischenablage'); } catch {} +} + // --- Retry --- async function retrySelectedJobs() { const retryJobs = []; @@ -3715,6 +3789,14 @@ function setupListeners() { document.getElementById('deleteAccountModal').addEventListener('click', (e) => { if (e.target.id === 'deleteAccountModal') closeDeleteModal(); }); + + // Job log modal + document.getElementById('closeJobLogBtn')?.addEventListener('click', hideJobLogModal); + document.getElementById('closeJobLogBtn2')?.addEventListener('click', hideJobLogModal); + document.getElementById('copyJobLogBtn')?.addEventListener('click', copyJobLogToClipboard); + document.getElementById('jobLogModal')?.addEventListener('click', (e) => { + if (e.target.id === 'jobLogModal') hideJobLogModal(); + }); } // --- Update UI --- diff --git a/renderer/index.html b/renderer/index.html index e41b7cc..0d1d425 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -197,6 +197,22 @@ + +