feat(ui): per-job log modal + account label in status

Two related visibility improvements.

1. Status cell now shows which account the job is running on:
   "Upload · Primär", "Retry 2/3 · Fallback #1: <error>", etc.
   - _emitProgress passes task.accountId in every progress event
   - renderer maps accountId → position in config.hosters[hoster] and
     renders "Primär" for index 0 and "Fallback #N" for the rest
   - Applies to uploading/getting-server/retrying (static states like
     done/error already tell their own story)

2. Right-click on a job → "Log anzeigen" opens a modal with the full
   per-job trail: every rot-log entry tagged with that job's jobId
   plus every non-uploading progress transition. Replaces the need to
   grep account-rotation.log for a single filename.
   - UploadManager: all 13 job-scoped _rotLog calls now carry jobId
   - main.js: _jobLogCollector Map<jobId, Array<entry>> with 200-entry
     ring buffer per job; cleared on each new start-upload (fresh
     batch = fresh log). addJobs mid-batch keeps history.
   - New IPC 'get-job-log' returns the array; preload.js exposes
     window.api.getJobLog(jobId)
   - renderer: modal card + context-menu item "Log anzeigen";
     entries formatted as "[HH:MM:SS.mmm] [event] k=v k=v"; copy-to-
     clipboard button
This commit is contained in:
Administrator 2026-04-22 18:13:53 +02:00
parent 329f501a6e
commit b96ccf851a
5 changed files with 158 additions and 26 deletions

View File

@ -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 });

32
main.js
View File

@ -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<entry>
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

View File

@ -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));
},

View File

@ -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 ---

View File

@ -197,6 +197,22 @@
</div>
</div>
<div class="modal-overlay" id="jobLogModal" style="display:none">
<div class="modal-card" style="width:min(820px,96%);max-height:80vh;display:flex;flex-direction:column">
<div class="modal-header">
<div><h3 id="jobLogTitle">Upload-Log</h3></div>
<button class="icon-btn" id="closeJobLogBtn" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body" style="flex:1 1 auto;overflow:auto">
<pre id="jobLogBody" style="white-space:pre-wrap;font-family:ui-monospace,Consolas,Menlo,monospace;font-size:12px;line-height:1.4;margin:0">Keine Einträge.</pre>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="copyJobLogBtn">In Zwischenablage</button>
<button class="btn btn-primary" id="closeJobLogBtn2">Schließen</button>
</div>
</div>
</div>
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
<div class="modal-card" style="width:min(400px,100%)">
<div class="modal-header">
@ -241,6 +257,7 @@
<div class="context-menu" id="contextMenu" style="display:none">
<div class="ctx-item" data-action="start-selected">Ausgewählte starten</div>
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
<div class="ctx-item" data-action="show-log">Log anzeigen</div>
<div class="ctx-separator"></div>
<div class="ctx-item" data-action="copy-links">Links kopieren</div>
<div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div>