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:
parent
cb64c6cd91
commit
2e0a8c9d39
@ -227,16 +227,29 @@ class DoodstreamUploader {
|
|||||||
bodyStream.push(preambleBuffer);
|
bodyStream.push(preambleBuffer);
|
||||||
bytesSent += preambleBuffer.length;
|
bytesSent += preambleBuffer.length;
|
||||||
|
|
||||||
// Pipe file
|
// Pipe file with throttle support
|
||||||
fileStream.on('data', (chunk) => {
|
fileStream.on('data', (chunk) => {
|
||||||
if (signal && signal.aborted) {
|
if (signal && signal.aborted) {
|
||||||
fileStream.destroy();
|
fileStream.destroy();
|
||||||
bodyStream.destroy();
|
bodyStream.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (throttle) {
|
||||||
|
fileStream.pause();
|
||||||
|
throttle.consume(chunk.length, signal).then(() => {
|
||||||
bodyStream.push(chunk);
|
bodyStream.push(chunk);
|
||||||
bytesSent += chunk.length;
|
bytesSent += chunk.length;
|
||||||
if (progressCb) progressCb(Math.max(0, bytesSent - preambleBuffer.length), fileSize);
|
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', () => {
|
fileStream.on('end', () => {
|
||||||
@ -262,10 +275,16 @@ class DoodstreamUploader {
|
|||||||
headersTimeout: 60000
|
headersTimeout: 60000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statusCode = uploadRes.statusCode;
|
||||||
const resText = await uploadRes.body.text();
|
const resText = await uploadRes.body.text();
|
||||||
let payload;
|
let payload;
|
||||||
try { payload = JSON.parse(resText); } catch {}
|
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) {
|
if (!payload) {
|
||||||
// Try to extract from HTML response
|
// Try to extract from HTML response
|
||||||
const match = resText.match(/filecode['":\s]+['"]([a-zA-Z0-9]+)['"]/i);
|
const match = resText.match(/filecode['":\s]+['"]([a-zA-Z0-9]+)['"]/i);
|
||||||
@ -276,7 +295,11 @@ class DoodstreamUploader {
|
|||||||
file_code: match[1]
|
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
|
// Parse result
|
||||||
@ -289,7 +312,7 @@ class DoodstreamUploader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!item) {
|
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 || '';
|
const fileCode = item.filecode || item.file_code || '';
|
||||||
|
|||||||
@ -310,10 +310,10 @@ function restoreQueueStateFromConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPersistedQueueState() {
|
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]));
|
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)) {
|
if (job.file && !selectedFileMap.has(job.file)) {
|
||||||
selectedFileMap.set(job.file, {
|
selectedFileMap.set(job.file, {
|
||||||
path: 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,9 +335,10 @@ function buildPersistedQueueState() {
|
|||||||
file: job.file,
|
file: job.file,
|
||||||
fileName: job.fileName,
|
fileName: job.fileName,
|
||||||
hoster: job.hoster,
|
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,
|
bytesTotal: job.bytesTotal || 0,
|
||||||
error: job.error || null,
|
error: job.status === 'aborted' ? null : (job.error || null),
|
||||||
result: job.result || null,
|
result: job.result || null,
|
||||||
maxAttempts: job.maxAttempts || 0
|
maxAttempts: job.maxAttempts || 0
|
||||||
}))
|
}))
|
||||||
@ -998,6 +999,7 @@ function handleProgress(data) {
|
|||||||
scheduleQueueRender();
|
scheduleQueueRender();
|
||||||
updateQueueActionButtons();
|
updateQueueActionButtons();
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
|
updateStatsPanel();
|
||||||
persistQueueStateSoon();
|
persistQueueStateSoon();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1035,7 +1037,7 @@ function handleBatchDone(summary) {
|
|||||||
renderQueueTable();
|
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();
|
else clearPersistedQueueStateSoon();
|
||||||
|
|
||||||
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
|
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
|
||||||
@ -1051,6 +1053,21 @@ function handleStats(data) {
|
|||||||
activeJobs: data.activeJobs || 0
|
activeJobs: data.activeJobs || 0
|
||||||
};
|
};
|
||||||
updateStatusBar();
|
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 ---
|
// --- 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 ---
|
// --- Start ---
|
||||||
init();
|
init();
|
||||||
|
|||||||
@ -96,9 +96,13 @@
|
|||||||
<div class="resize-handle" id="recentFilesResizer"></div>
|
<div class="resize-handle" id="recentFilesResizer"></div>
|
||||||
<div class="recent-files-panel" id="recentFilesPanel">
|
<div class="recent-files-panel" id="recentFilesPanel">
|
||||||
<div class="recent-files-header">
|
<div class="recent-files-header">
|
||||||
<h3>Files</h3>
|
<div class="recent-tabs">
|
||||||
<span class="recent-files-hint">Zuletzt erzeugte Upload-Links</span>
|
<button class="recent-tab active" data-panel="filesTab">Files</button>
|
||||||
|
<button class="recent-tab" data-panel="statsTab">Stats</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
|
||||||
|
</div>
|
||||||
|
<div class="recent-tab-body active" id="filesTab">
|
||||||
<div class="recent-files-table-wrap">
|
<div class="recent-files-table-wrap">
|
||||||
<table class="recent-files-table">
|
<table class="recent-files-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -113,6 +117,31 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -374,14 +374,67 @@ body {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(0, 0, 0, 0.12);
|
background: rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
.recent-files-header h3 {
|
.recent-tabs {
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
font-weight: 600;
|
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 {
|
.recent-files-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
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 {
|
.recent-files-table-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user