feat: stats panel, abort persistence, doodstream error logging

- Stats tab in recent panel (queue counts, sizes, speed, ETA, run time)
- Aborted jobs persist across restart (saved as queued)
- Doodstream: throttle support, better error messages with HTTP status
- Recent panel tab switching (Files / Stats)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-11 03:14:06 +01:00
parent cb64c6cd91
commit 2e0a8c9d39
4 changed files with 214 additions and 29 deletions

View File

@ -227,16 +227,29 @@ class DoodstreamUploader {
bodyStream.push(preambleBuffer);
bytesSent += preambleBuffer.length;
// Pipe file
// Pipe file with throttle support
fileStream.on('data', (chunk) => {
if (signal && signal.aborted) {
fileStream.destroy();
bodyStream.destroy();
return;
}
bodyStream.push(chunk);
bytesSent += chunk.length;
if (progressCb) progressCb(Math.max(0, bytesSent - preambleBuffer.length), fileSize);
if (throttle) {
fileStream.pause();
throttle.consume(chunk.length, signal).then(() => {
bodyStream.push(chunk);
bytesSent += chunk.length;
if (progressCb) progressCb(Math.max(0, bytesSent - preambleBuffer.length), fileSize);
fileStream.resume();
}).catch(() => {
fileStream.destroy();
bodyStream.destroy();
});
} else {
bodyStream.push(chunk);
bytesSent += chunk.length;
if (progressCb) progressCb(Math.max(0, bytesSent - preambleBuffer.length), fileSize);
}
});
fileStream.on('end', () => {
@ -262,10 +275,16 @@ class DoodstreamUploader {
headersTimeout: 60000
});
const statusCode = uploadRes.statusCode;
const resText = await uploadRes.body.text();
let payload;
try { payload = JSON.parse(resText); } catch {}
if (statusCode >= 400) {
const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200);
throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`);
}
if (!payload) {
// Try to extract from HTML response
const match = resText.match(/filecode['":\s]+['"]([a-zA-Z0-9]+)['"]/i);
@ -276,7 +295,11 @@ class DoodstreamUploader {
file_code: match[1]
};
}
throw new Error('Doodstream Upload: Keine gueltige Antwort erhalten');
throw new Error(`Doodstream Upload: Keine gueltige Antwort (HTTP ${statusCode}, Body: ${resText.slice(0, 150)})`);
}
if (payload.status && Number(payload.status) !== 200 && payload.msg) {
throw new Error(`Doodstream Upload: ${payload.msg}`);
}
// Parse result
@ -289,7 +312,7 @@ class DoodstreamUploader {
}
if (!item) {
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || 'Unbekannter Fehler'}`);
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`);
}
const fileCode = item.filecode || item.file_code || '';

View File

@ -310,10 +310,10 @@ function restoreQueueStateFromConfig() {
}
function buildPersistedQueueState() {
const unfinishedJobs = queueJobs.filter(job => !['done', 'skipped', 'aborted'].includes(job.status));
const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status));
const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file]));
for (const job of unfinishedJobs) {
for (const job of persistableJobs) {
if (job.file && !selectedFileMap.has(job.file)) {
selectedFileMap.set(job.file, {
path: job.file,
@ -323,7 +323,7 @@ function buildPersistedQueueState() {
}
}
if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped', 'aborted'].includes(job.status))) {
if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) {
return null;
}
@ -335,9 +335,10 @@ function buildPersistedQueueState() {
file: job.file,
fileName: job.fileName,
hoster: job.hoster,
status: job.status,
// Save aborted jobs as queued so they survive restart
status: job.status === 'aborted' ? 'queued' : job.status,
bytesTotal: job.bytesTotal || 0,
error: job.error || null,
error: job.status === 'aborted' ? null : (job.error || null),
result: job.result || null,
maxAttempts: job.maxAttempts || 0
}))
@ -998,6 +999,7 @@ function handleProgress(data) {
scheduleQueueRender();
updateQueueActionButtons();
updateStatusBar();
updateStatsPanel();
persistQueueStateSoon();
}
@ -1035,7 +1037,7 @@ function handleBatchDone(summary) {
renderQueueTable();
}
if (queueJobs.some((job) => !['done', 'skipped', 'aborted'].includes(job.status))) persistQueueStateSoon(true);
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true);
else clearPersistedQueueStateSoon();
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
@ -1051,6 +1053,21 @@ function handleStats(data) {
activeJobs: data.activeJobs || 0
};
updateStatusBar();
updateStatsPanel();
// Track run time
if (data.state === 'uploading' || data.state === 'stopping') {
if (!statsStartTime) {
statsStartTime = Date.now();
statsRunTimer = setInterval(() => {
const el = document.getElementById('statRunTime');
if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000));
}, 1000);
}
} else if (data.state === 'idle' && statsRunTimer) {
clearInterval(statsRunTimer);
statsRunTimer = null;
}
}
// --- Retry ---
@ -2220,5 +2237,68 @@ function showCopyToast(msg) {
}
}
// --- Recent panel tabs ---
document.querySelectorAll('.recent-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.recent-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.recent-tab-body').forEach(b => b.classList.remove('active'));
tab.classList.add('active');
const panel = document.getElementById(tab.dataset.panel);
if (panel) panel.classList.add('active');
const hint = document.getElementById('recentFilesHint');
if (hint) hint.textContent = tab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links';
});
});
// --- Stats panel update ---
let statsStartTime = 0;
let statsRunTimer = null;
function formatBytes(bytes) {
if (bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i];
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function updateStatsPanel() {
const total = queueJobs.length;
const done = queueJobs.filter(j => j.status === 'done').length;
const inProgress = queueJobs.filter(j => ['uploading', 'getting-server', 'retrying'].includes(j.status)).length;
const errors = queueJobs.filter(j => j.status === 'error').length;
const remaining = total - done - errors;
const totalSize = queueJobs.reduce((s, j) => s + (j.bytesTotal || 0), 0);
const remainingSize = queueJobs.filter(j => !['done', 'error', 'skipped'].includes(j.status))
.reduce((s, j) => s + ((j.bytesTotal || 0) - (j.bytesUploaded || 0)), 0);
const el = (id) => document.getElementById(id);
if (el('statQueueTotal')) el('statQueueTotal').textContent = total;
if (el('statQueueDone')) el('statQueueDone').textContent = done;
if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining;
if (el('statQueueInProgress')) el('statQueueInProgress').textContent = inProgress;
if (el('statQueueError')) el('statQueueError').textContent = errors;
if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(totalSize);
if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(remainingSize);
const speed = lastUploadStats.globalSpeedKbs || 0;
if (el('statSpeed')) el('statSpeed').textContent = speed > 0 ? formatBytes(speed * 1024) + '/s' : '0 B/s';
if (el('statEta')) {
if (speed > 0 && remainingSize > 0) {
el('statEta').textContent = formatDuration(Math.round(remainingSize / (speed * 1024)));
} else {
el('statEta').textContent = '--:--';
}
}
if (el('statSessionBytes')) el('statSessionBytes').textContent = formatBytes(lastUploadStats.totalBytes || 0);
}
// --- Start ---
init();

View File

@ -96,21 +96,50 @@
<div class="resize-handle" id="recentFilesResizer"></div>
<div class="recent-files-panel" id="recentFilesPanel">
<div class="recent-files-header">
<h3>Files</h3>
<span class="recent-files-hint">Zuletzt erzeugte Upload-Links</span>
<div class="recent-tabs">
<button class="recent-tab active" data-panel="filesTab">Files</button>
<button class="recent-tab" data-panel="statsTab">Stats</button>
</div>
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
</div>
<div class="recent-files-table-wrap">
<table class="recent-files-table">
<thead>
<tr>
<th class="col-date">Datum</th>
<th class="col-filename">Dateiname</th>
<th class="col-host">Host</th>
<th class="col-link">Link</th>
</tr>
</thead>
<tbody id="recentFilesBody"></tbody>
</table>
<div class="recent-tab-body active" id="filesTab">
<div class="recent-files-table-wrap">
<table class="recent-files-table">
<thead>
<tr>
<th class="col-date">Datum</th>
<th class="col-filename">Dateiname</th>
<th class="col-host">Host</th>
<th class="col-link">Link</th>
</tr>
</thead>
<tbody id="recentFilesBody"></tbody>
</table>
</div>
</div>
<div class="recent-tab-body" id="statsTab">
<div class="stats-grid">
<div class="stats-col">
<h4>Files in queue (count)</h4>
<div class="stats-row"><span>total:</span><span id="statQueueTotal">0</span></div>
<div class="stats-row"><span>done:</span><span id="statQueueDone">0</span></div>
<div class="stats-row"><span>remaining:</span><span id="statQueueRemaining">0</span></div>
<div class="stats-row"><span>in progress:</span><span id="statQueueInProgress">0</span></div>
<div class="stats-row"><span>error:</span><span id="statQueueError">0</span></div>
</div>
<div class="stats-col">
<h4>File size in queue</h4>
<div class="stats-row"><span>total:</span><span id="statSizeTotal">0 B</span></div>
<div class="stats-row"><span>remaining:</span><span id="statSizeRemaining">0 B</span></div>
</div>
<div class="stats-col">
<h4>Session</h4>
<div class="stats-row"><span>Upload speed:</span><span id="statSpeed">0 B/s</span></div>
<div class="stats-row"><span>Remaining time:</span><span id="statEta">--:--</span></div>
<div class="stats-row"><span>Run time:</span><span id="statRunTime">00:00:00</span></div>
<div class="stats-row"><span>Uploaded (this run):</span><span id="statSessionBytes">0 B</span></div>
</div>
</div>
</div>
</div>
</div>

View File

@ -374,14 +374,67 @@ body {
border-bottom: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
}
.recent-files-header h3 {
font-size: 13px;
font-weight: 600;
.recent-tabs {
display: flex;
gap: 0;
}
.recent-tab {
padding: 4px 14px;
font-size: 12px;
font-weight: 500;
background: transparent;
border: 1px solid var(--border);
border-bottom: none;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.recent-tab:first-child { border-radius: 4px 0 0 0; }
.recent-tab:last-child { border-radius: 0 4px 0 0; }
.recent-tab.active {
background: rgba(255,255,255,0.06);
color: var(--text);
border-bottom-color: transparent;
}
.recent-tab:hover:not(.active) {
background: rgba(255,255,255,0.03);
color: var(--text);
}
.recent-tab-body { display: none; flex: 1; min-height: 0; overflow: auto; }
.recent-tab-body.active { display: flex; flex-direction: column; }
.recent-files-hint {
font-size: 11px;
color: var(--text-dim);
}
.stats-grid {
display: flex;
gap: 32px;
padding: 12px 20px;
flex: 1;
}
.stats-col {
flex: 1;
min-width: 0;
}
.stats-col h4 {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stats-row {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: 2px 0;
color: var(--text-dim);
}
.stats-row span:last-child {
color: var(--text);
font-variant-numeric: tabular-nums;
}
.recent-files-table-wrap {
flex: 1;
min-height: 0;