feat: copy sanitized upload failure details
Expose a contextual copy action only for failed uploads, target the exact right-clicked job during multi-selection, and defensively redact URLs and credential-shaped values from clipboard diagnostics.
This commit is contained in:
+45
-2
@@ -2367,6 +2367,7 @@ let alwaysOnTopState = false;
|
||||
// never changes after it's created).
|
||||
let _hosterCountsCache = { sig: '', result: new Map() };
|
||||
let contextMenuReturnFocus = null;
|
||||
let contextMenuTargetJobId = null;
|
||||
function _getHosterCounts() {
|
||||
const sig = `${queueJobs.length}`;
|
||||
if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result;
|
||||
@@ -2382,6 +2383,7 @@ function _getHosterCounts() {
|
||||
function handleRowContextMenu(e, row) {
|
||||
e.preventDefault();
|
||||
const jobId = row.dataset.jobId;
|
||||
contextMenuTargetJobId = jobId;
|
||||
if (!selectedJobIds.has(jobId)) {
|
||||
selectedJobIds.clear();
|
||||
selectedJobIds.add(jobId);
|
||||
@@ -2394,6 +2396,9 @@ function handleRowContextMenu(e, row) {
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
const menu = document.getElementById('contextMenu');
|
||||
const targetJob = _jobIndexById.get(contextMenuTargetJobId);
|
||||
const copyFailureItem = menu.querySelector('[data-action="copy-failure-details"]');
|
||||
if (copyFailureItem) copyFailureItem.style.display = targetJob?.status === 'error' && (targetJob.error || targetJob.failureDetails) ? '' : 'none';
|
||||
// Update "Always on top" text
|
||||
const aotItem = menu.querySelector('[data-action="always-on-top"]');
|
||||
if (aotItem) aotItem.textContent = alwaysOnTopState ? 'Immer im Vordergrund ✓' : 'Immer im Vordergrund';
|
||||
@@ -2882,11 +2887,12 @@ document.getElementById('contextMenu').addEventListener('click', (e) => {
|
||||
if (!item) return;
|
||||
const action = item.dataset.action;
|
||||
if (!action) return;
|
||||
const targetJobId = contextMenuTargetJobId;
|
||||
hideContextMenu();
|
||||
handleContextAction(action);
|
||||
handleContextAction(action, targetJobId);
|
||||
});
|
||||
|
||||
async function handleContextAction(action) {
|
||||
async function handleContextAction(action, targetJobId = null) {
|
||||
_normalizeQueueSelectionToVisible();
|
||||
if (action === 'start-selected') {
|
||||
startSelectedUpload();
|
||||
@@ -2897,6 +2903,8 @@ async function handleContextAction(action) {
|
||||
retrySelectedJobs();
|
||||
} else if (action === 'show-log') {
|
||||
showJobLogModal();
|
||||
} else if (action === 'copy-failure-details') {
|
||||
await copyFailureDetails(targetJobId);
|
||||
} else if (action === 'delete-selected') {
|
||||
const count = selectedJobIds.size;
|
||||
if (!count || !await showAppConfirm({ title: 'Uploads entfernen?', message: count === 1 ? 'Ein ausgewählter Upload wird aus der Liste entfernt.' : `${count} ausgewählte Uploads werden aus der Liste entfernt.`, confirmText: 'Entfernen', danger: true })) return;
|
||||
@@ -3561,6 +3569,41 @@ function _handleStatsImpl(data) {
|
||||
}
|
||||
|
||||
// --- Per-job log modal ---
|
||||
function _sanitizeFailureClipboardValue(value, limit = 320) {
|
||||
let text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
text = text.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]');
|
||||
text = text.replace(/((?:api[_-]?key|token|password|cookie|authorization|session)\s*[:=]\s*)[^,;\s}"']+/gi, '$1[redacted]');
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
function formatFailureDetailsForClipboard(job) {
|
||||
if (!job || job.status !== 'error') return '';
|
||||
const english = getUiLocale() === 'en-US';
|
||||
const details = job.failureDetails && typeof job.failureDetails === 'object' ? job.failureDetails : {};
|
||||
const labels = english
|
||||
? { file: 'File', host: 'Host', account: 'Account', attempt: 'Attempt', error: 'Error', http: 'HTTP status', contentType: 'Content type', response: 'Response excerpt' }
|
||||
: { file: 'Datei', host: 'Hoster', account: 'Account', attempt: 'Versuch', error: 'Fehler', http: 'HTTP-Status', contentType: 'Inhaltstyp', response: 'Antwortauszug' };
|
||||
const account = getAccountLabel(job) || _sanitizeFailureClipboardValue(job.accountId, 80) || '–';
|
||||
return [
|
||||
`${labels.file}: ${_sanitizeFailureClipboardValue(job.fileName || job.file, 260) || '–'}`,
|
||||
`${labels.host}: ${_sanitizeFailureClipboardValue(job.hoster, 120) || '–'}`,
|
||||
`${labels.account}: ${account}`,
|
||||
`${labels.attempt}: ${Math.max(0, Number(job.attempt) || 0)} / ${Math.max(0, Number(job.maxAttempts) || 0)}`,
|
||||
`${labels.error}: ${_sanitizeFailureClipboardValue(job.error, 400) || '–'}`,
|
||||
details.httpStatus ? `${labels.http}: ${details.httpStatus}` : '',
|
||||
details.contentType ? `${labels.contentType}: ${_sanitizeFailureClipboardValue(details.contentType, 120)}` : '',
|
||||
details.responseSnippet ? `${labels.response}: ${_sanitizeFailureClipboardValue(details.responseSnippet, 320)}` : ''
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
async function copyFailureDetails(jobId) {
|
||||
const job = _jobIndexById.get(jobId);
|
||||
const text = formatFailureDetailsForClipboard(job);
|
||||
if (!text) return;
|
||||
await window.api.copyToClipboard(text);
|
||||
showCopyToast(localizeUiText('Fehlerdetails kopiert'));
|
||||
}
|
||||
|
||||
async function showJobLogModal() {
|
||||
const selectedJobs = _getVisibleSelectedQueueJobs();
|
||||
if (selectedJobs.length === 0) return;
|
||||
|
||||
@@ -129,6 +129,12 @@
|
||||
['Fertig', 'Completed'],
|
||||
['Fehler', 'Failed'],
|
||||
['geprüft', 'checked'],
|
||||
['Fehlerdetails kopieren', 'Copy failure details'],
|
||||
['Fehlerdetails kopiert', 'Failure details copied'],
|
||||
['Versuch', 'Attempt'],
|
||||
['HTTP-Status', 'HTTP status'],
|
||||
['Inhaltstyp', 'Content type'],
|
||||
['Antwortauszug', 'Response excerpt'],
|
||||
['Verfügbarkeit', 'Availability'],
|
||||
['Bereite Accounts', 'Ready accounts'],
|
||||
['Primär', 'Primary'],
|
||||
|
||||
@@ -637,6 +637,7 @@
|
||||
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="start-selected">Ausgewählte starten</div>
|
||||
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="retry-selected">Erneut versuchen</div>
|
||||
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="show-log">Log anzeigen</div>
|
||||
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="copy-failure-details" style="display:none">Fehlerdetails kopieren</div>
|
||||
<div class="ctx-separator"></div>
|
||||
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="copy-links">Links kopieren</div>
|
||||
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="copy-all-links">Alle Links kopieren</div>
|
||||
|
||||
@@ -21,6 +21,12 @@ test('translates the account check timestamp label', () => {
|
||||
assert.equal(translateText('checked', 'de'), 'geprüft');
|
||||
});
|
||||
|
||||
test('translates the failure detail clipboard action', () => {
|
||||
assert.equal(translateText('Fehlerdetails kopieren', 'en'), 'Copy failure details');
|
||||
assert.equal(translateText('Failure details copied', 'de'), 'Fehlerdetails kopiert');
|
||||
assert.equal(translateText('Antwortauszug', 'en'), 'Response excerpt');
|
||||
});
|
||||
|
||||
test('English is the fallback language and German remains selectable', () => {
|
||||
assert.equal(normalizeLanguage(), 'en');
|
||||
assert.equal(normalizeLanguage('fr'), 'en');
|
||||
|
||||
@@ -179,6 +179,7 @@ test('header occupies its final geometry before asynchronous initialization', ()
|
||||
assert.match(html, /class="update-progress-footer"[\s\S]*id="updateProgressDetails"[\s\S]*id="updateProgressSize"[\s\S]*id="updateProgressSpeed"[\s\S]*id="updateProgressEta"[\s\S]*id="updateProgressText"/u);
|
||||
assert.match(html, /id="queueFilterResetBtn"[^>]*disabled[^>]*>Filter zurücksetzen</u);
|
||||
assert.match(html, /class="queue-filter-summary"[\s\S]*id="queueActiveFilterCount"[^>]*>0</u);
|
||||
assert.match(html, /data-action="copy-failure-details"[^>]*style="display:none"[^>]*>Fehlerdetails kopieren</u);
|
||||
assert.doesNotMatch(html, /<\/div>\s*<div class="queue-filter-bar"/u);
|
||||
assert.match(css, /\.queue-filter-bar\s*\{[^}]*display:\s*flex;[^}]*margin-left:\s*auto;[^}]*border:\s*1px solid var\(--border\);[^}]*border-radius:\s*7px;/su);
|
||||
assert.match(css, /#updateProgressDetails\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*19ch 2ch 11ch 2ch 10ch;[^}]*column-gap:\s*0;[^}]*font-variant-numeric:\s*tabular-nums;/su);
|
||||
|
||||
@@ -2092,6 +2092,34 @@ setTimeout(async () => {
|
||||
if (!(recentCapScrollState.before > 0 && Math.abs(recentCapScrollState.delta - 28) <= 1)) console.log('Recent cap scroll state: ' + JSON.stringify(recentCapScrollState));
|
||||
check('A capped recent-upload list preserves the visible rows when a new item arrives', recentCapScrollState.before > 0 && Math.abs(recentCapScrollState.delta - 28) <= 1);
|
||||
|
||||
const failureClipboardContract = await wc.executeJavaScript(\`(() => {
|
||||
setUiLanguage('en');
|
||||
queueJobs = [
|
||||
{ id: 'failure-a', file: 'C:/ui/failure-a.mkv', fileName: 'failure-a.mkv', hoster: 'byse.sx', accountId: 'account-a', status: 'error', error: 'Wrong failure', attempt: 1, maxAttempts: 3, bytesUploaded: 0, bytesTotal: 100, progress: 0 },
|
||||
{ id: 'failure-b', file: 'C:/ui/failure-b.mkv', fileName: 'failure-b.mkv', hoster: 'doodstream.com', accountId: 'account-b', status: 'error', error: 'Request failed at https://secret.invalid/path?token=secret', attempt: 2, maxAttempts: 4, failureDetails: { httpStatus: 503, contentType: 'text/html', responseSnippet: 'apiKey=very-secret temporary gateway response' }, bytesUploaded: 0, bytesTotal: 100, progress: 0 }
|
||||
];
|
||||
selectedJobIds.clear();
|
||||
selectedJobIds.add('failure-a');
|
||||
selectedJobIds.add('failure-b');
|
||||
rebuildJobIndex();
|
||||
setUploadSidebarFilter('all');
|
||||
updateUploadView();
|
||||
renderQueueTable();
|
||||
const row = document.querySelector('[data-job-id="failure-b"]');
|
||||
handleRowContextMenu({ preventDefault() {}, clientX: 50, clientY: 50 }, row);
|
||||
const item = document.querySelector('[data-action="copy-failure-details"]');
|
||||
const text = formatFailureDetailsForClipboard(_jobIndexById.get(contextMenuTargetJobId));
|
||||
hideContextMenu();
|
||||
setUiLanguage('de');
|
||||
queueJobs = [];
|
||||
selectedJobIds.clear();
|
||||
rebuildJobIndex();
|
||||
updateUploadView();
|
||||
renderQueueTable();
|
||||
return { target: contextMenuTargetJobId, visible: getComputedStyle(item).display !== 'none', label: item.textContent.trim(), text };
|
||||
})()\`);
|
||||
check('Failure detail copying targets the right-clicked job and redacts sensitive values', failureClipboardContract.target === 'failure-b' && failureClipboardContract.visible === true && failureClipboardContract.label === 'Copy failure details' && failureClipboardContract.text.includes('File: failure-b.mkv') && failureClipboardContract.text.includes('HTTP status: 503') && failureClipboardContract.text.includes('Content type: text/html') && failureClipboardContract.text.includes('[URL]') && failureClipboardContract.text.includes('apiKey=[redacted]') && !failureClipboardContract.text.includes('secret.invalid') && !failureClipboardContract.text.includes('very-secret'));
|
||||
|
||||
const keyboardInteractionContract = await wc.executeJavaScript(\`(() => {
|
||||
HOSTERS.forEach(name => { config.hosters[name] = []; });
|
||||
config.hosters['byse.sx'] = [{ id: 'ui-keyboard-account', enabled: true, authType: 'api', apiKey: 'keyboard-key' }];
|
||||
|
||||
Reference in New Issue
Block a user