fix(accounts): VOE CSRF burst-throttle + collapsible per-hoster groups

User reported two coupled issues in the accounts panel:
- "VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?" fires
  intermittently across multiple VOE accounts when "Accounts prüfen" runs.
  Each retry "fixes" one and breaks another — classic anti-bot burst response.
- The flat badge strip becomes unreadable with many accounts; user wants
  collapsible per-hoster groups with "N/M" headers and green/red indicators,
  click to expand to per-account detail.

DISCRIMINATOR CHECK (cheap before serializing): grep'd lib/voe-upload.js for
module-level state — none. Each new VoeUploader() carries its own cookie Map.
Burst-throttle on VOE's side is the only plausible root cause.

CONCURRENCY FIX in main.js runHosterHealthCheck:
- Group checks by hoster, run each hoster's group SEQUENTIALLY, groups in
  parallel (Promise.all of sequential runners). Cross-hoster parallelism
  preserved; intra-hoster bursts eliminated.
- Result array preserves input order via a result-index map.
- Hardening per review: dedup duplicate {hoster, accountId} entries before
  grouping (no wasted API calls if a caller ever sends duplicates), and entries
  missing accountId now return a clean "Account-ID fehlt" error instead of
  silently calling per-hoster checker with null config.
- Validate-credentials and checkSingleAccount paths unchanged (single-check
  payloads run the same way regardless).
- Latency trade-off acknowledged: 5 VOE accounts ~5x faster path → up to 25s
  for that hoster's column. That's the cost for reliability; the user's
  alternative was 0/5 working on burst-failed runs.

UI FIX in renderer:
- New _buildAccountHosterGroupHtml emits a collapsible per-hoster group
  reusing the existing .hoster-panel-header / .panel-arrow CSS pattern.
- Header shows "VOE 4/5" (ok-count / total-accounts), a green/red/amber/gray
  status dot, plus pills for "N deaktiviert" and "N Fehler".
- Default: auto-expand any hoster with errors, checking, or unchecked
  accounts; collapse all-green.
- Open-state memory tracks user clicks. Per review: also tracks errorsAtClose
  snapshot so a NEW failure since the user's close forces re-expand once.
  Prevents the "I closed it once and now silent failures hide forever" risk.
- Single-card updates also refresh the parent group's header counter via
  _refreshHosterGroupHeader.
- Flat badge strip in renderHealthCheckResults is now a no-op stub — the
  per-hoster headers carry the same info, less duplication.

Three-lens review (workflow wch4p9ee9): concurrency PASS_WITH_NOTES, ui-state
PASS_WITH_NOTES, comment-policy PASS (zero new // or /* */ comments).
Latent concerns from review applied as hardenings.

210/210 tests green, lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-06-07 03:27:57 +02:00
parent d9e858febd
commit 2e8e8a3819
3 changed files with 187 additions and 37 deletions

53
main.js
View File

@ -836,35 +836,52 @@ async function runHosterHealthCheck(config, requestedChecks) {
checks = requestedChecks; checks = requestedChecks;
} }
const results = await Promise.all(checks.map(async ({ hoster, accountId, otp }) => { {
const seen = new Set();
const cleaned = [];
for (const c of checks) {
if (!c || !c.hoster) continue;
if (!c.accountId) { cleaned.push({ ...c, _invalid: true }); continue; }
const key = `${c.hoster}|${c.accountId}`;
if (seen.has(key)) continue;
seen.add(key);
cleaned.push(c);
}
checks = cleaned;
}
const runOne = async ({ hoster, accountId, otp, _invalid }) => {
if (_invalid) {
return { hoster, accountId, status: 'error', message: 'Account-ID fehlt im Check-Payload' };
}
if (!allowed.includes(hoster)) { if (!allowed.includes(hoster)) {
return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
} }
// Find specific account
const accounts = config.hosters[hoster]; const accounts = config.hosters[hoster];
const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null; const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null;
try { try {
let result; const result = await _dispatchHealthCheck(hoster, hosterConfig, otp || '');
if (hoster === 'doodstream.com') {
result = await withTimeout(checkDoodstreamHealth(hosterConfig, otp), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check');
} else if (hoster === 'vidmoly.me') {
result = await withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check');
} else if (hoster === 'voe.sx') {
result = await withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check');
} else if (hoster === 'byse.sx') {
result = await withTimeout(checkByseHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Byse-Check');
} else if (hoster === 'clouddrop.cc') {
result = await withTimeout(checkClouddropHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Clouddrop-Check');
} else {
return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
}
return { hoster, accountId, ...result }; return { hoster, accountId, ...result };
} catch (err) { } catch (err) {
return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' }; return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' };
} }
};
const groups = new Map();
for (const c of checks) {
if (!groups.has(c.hoster)) groups.set(c.hoster, []);
groups.get(c.hoster).push(c);
}
const groupResults = await Promise.all(Array.from(groups.values()).map(async (group) => {
const out = [];
for (const c of group) {
out.push(await runOne(c));
}
return out;
})); }));
const indexByCheck = new Map();
groupResults.flat().forEach((r) => { indexByCheck.set(`${r.hoster}|${r.accountId || ''}`, r); });
const results = checks.map(c => indexByCheck.get(`${c.hoster}|${c.accountId || ''}`));
return { checkedAt: new Date().toISOString(), results }; return { checkedAt: new Date().toISOString(), results };
} }

View File

@ -2421,19 +2421,9 @@ function updateStatusBar() {
// --- Health Check --- // --- Health Check ---
function renderHealthCheckResults(results) { function renderHealthCheckResults(_results) {
const container = document.getElementById('healthCheckResults'); const container = document.getElementById('healthCheckResults');
if (!container) return; if (container) container.innerHTML = '';
if (!results || results.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = results.map(item => {
const status = item.status || 'skipped';
return `<div class="health-badge ${status}">
<span>${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')}</span>
<span class="health-tag">[${status.toUpperCase()}]</span>
<span>${escapeHtml(item.message || '')}</span>
</div>`;
}).join('');
} }
async function executeHealthCheck(hosters, _mode) { async function executeHealthCheck(hosters, _mode) {
@ -3084,6 +3074,40 @@ function updateAccountCard(accountId) {
const tmp = document.createElement('div'); const tmp = document.createElement('div');
tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx); tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx);
card.replaceWith(tmp.firstElementChild); card.replaceWith(tmp.firstElementChild);
_refreshHosterGroupHeader(found.name);
}
function _refreshHosterGroupHeader(name) {
const container = document.getElementById('accountsList');
if (!container) return;
const group = container.querySelector(`.account-hoster-group[data-hoster-group="${name}"]`);
if (!group) return;
const accounts = config.hosters[name] || [];
const summary = _summarizeHosterGroup(accounts);
let dot = 'unchecked';
if (summary.error > 0) dot = 'error';
else if (summary.checking > 0) dot = 'checking';
else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok';
const dotEl = group.querySelector('.account-hoster-group-header .account-status-dot');
if (dotEl) dotEl.className = `account-status-dot status-${dot}`;
const countEl = group.querySelector('.account-hoster-group-count');
if (countEl) countEl.textContent = `${summary.ok}/${summary.total}`;
group.querySelectorAll('.account-hoster-group-meta').forEach(el => el.remove());
const header = group.querySelector('.account-hoster-group-header');
if (header) {
if (summary.disabled) {
const meta = document.createElement('span');
meta.className = 'account-hoster-group-meta';
meta.textContent = `${summary.disabled} deaktiviert`;
header.appendChild(meta);
}
if (summary.error) {
const meta = document.createElement('span');
meta.className = 'account-hoster-group-meta error';
meta.textContent = `${summary.error} Fehler`;
header.appendChild(meta);
}
}
} }
let _accountListenersBound = false; let _accountListenersBound = false;
@ -3117,16 +3141,65 @@ function renderAccounts() {
for (const name of HOSTERS) { for (const name of HOSTERS) {
const accounts = byHoster[name]; const accounts = byHoster[name];
if (!accounts || accounts.length === 0) continue; if (!accounts || accounts.length === 0) continue;
html += `<div class="account-hoster-group" data-hoster-group="${name}"> html += _buildAccountHosterGroupHtml(name, accounts);
<div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
accounts.forEach((account, idx) => { html += _buildAccountCardHtml(name, account, idx); });
html += '</div>';
} }
container.innerHTML = html; container.innerHTML = html;
if (!_accountListenersBound) bindAccountListeners(container); if (!_accountListenersBound) bindAccountListeners(container);
} }
function _summarizeHosterGroup(accounts) {
let ok = 0, error = 0, checking = 0, unchecked = 0, disabled = 0;
for (const a of accounts) {
if (a.enabled === false) { disabled++; continue; }
const s = (accountStatuses[a.id] && accountStatuses[a.id].status) || 'unchecked';
if (s === 'ok' || s === 'warn') ok++;
else if (s === 'error') error++;
else if (s === 'checking') checking++;
else unchecked++;
}
return { ok, error, checking, unchecked, disabled, total: accounts.length };
}
function _hosterGroupOpenState(name, summary) {
const prev = _hosterGroupOpenMemory.get(name);
if (prev && typeof prev === 'object') {
if (summary.error > (prev.errorsAtClose || 0)) {
_hosterGroupOpenMemory.delete(name);
return true;
}
return prev.state === 'open';
}
return summary.error > 0 || summary.checking > 0 || summary.unchecked > 0;
}
const _hosterGroupOpenMemory = new Map();
function _buildAccountHosterGroupHtml(name, accounts) {
const summary = _summarizeHosterGroup(accounts);
const isOpen = _hosterGroupOpenState(name, summary);
let dot = 'unchecked';
if (summary.error > 0) dot = 'error';
else if (summary.checking > 0) dot = 'checking';
else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok';
const countLabel = `${summary.ok}/${summary.total}`;
const arrow = isOpen ? '&#9660;' : '&#9654;';
let cardsHtml = '';
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
const bodyStyle = isOpen ? '' : 'style="display:none"';
return `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-header" data-hoster-toggle="${name}">
<span class="panel-arrow">${arrow}</span>
<span class="account-status-dot status-${dot}"></span>
<span class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</span>
<span class="account-hoster-group-count">${countLabel}</span>
${summary.disabled ? `<span class="account-hoster-group-meta">${summary.disabled} deaktiviert</span>` : ''}
${summary.error ? `<span class="account-hoster-group-meta error">${summary.error} Fehler</span>` : ''}
</div>
<div class="account-hoster-group-body" ${bodyStyle}>${cardsHtml}</div>
</div>`;
}
// Single set of delegated listeners on the accounts container. Bound once on // Single set of delegated listeners on the accounts container. Bound once on
// the first render and reused for every subsequent in-place update / card // the first render and reused for every subsequent in-place update / card
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners // swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
@ -3135,6 +3208,21 @@ function renderAccounts() {
function bindAccountListeners(container) { function bindAccountListeners(container) {
_accountListenersBound = true; _accountListenersBound = true;
container.addEventListener('click', (e) => { container.addEventListener('click', (e) => {
const header = e.target.closest('[data-hoster-toggle]');
if (header && !e.target.closest('button')) {
const name = header.dataset.hosterToggle;
const group = header.closest('.account-hoster-group');
const body = group && group.querySelector('.account-hoster-group-body');
const arrow = header.querySelector('.panel-arrow');
if (body) {
const willOpen = body.style.display === 'none';
body.style.display = willOpen ? '' : 'none';
if (arrow) arrow.innerHTML = willOpen ? '&#9660;' : '&#9654;';
const summary = _summarizeHosterGroup(config.hosters[name] || []);
_hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error });
}
return;
}
const btn = e.target.closest('button'); const btn = e.target.closest('button');
if (!btn) return; if (!btn) return;
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle); if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);

View File

@ -875,17 +875,62 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.account-hoster-group { .account-hoster-group {
margin-bottom: 12px; margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
overflow: hidden;
} }
.account-hoster-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
background: var(--bg-card);
transition: background 0.1s;
}
.account-hoster-group-header:hover { background: var(--bg-card-hover); }
.account-hoster-group-title { .account-hoster-group-title {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 6px; flex: 1;
padding-left: 4px; }
.account-hoster-group-count {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.account-hoster-group-meta {
font-size: 11px;
color: var(--text-muted);
padding: 1px 6px;
border-radius: 4px;
background: rgba(255,255,255,0.04);
}
.account-hoster-group-meta.error {
color: var(--danger, #e57373);
background: rgba(229, 115, 115, 0.12);
}
.account-hoster-group-body {
padding: 8px;
border-top: 1px solid var(--border);
} }
.account-hoster-group .account-card { margin-bottom: 4px; } .account-hoster-group .account-card { margin-bottom: 4px; }
.account-hoster-group .account-card:last-child { margin-bottom: 0; }
.account-status-dot.status-ok { background: #4caf50; }
.account-status-dot.status-error { background: #e57373; }
.account-status-dot.status-checking { background: #f0c36c; }
.account-status-dot.status-unchecked { background: #6c757d; }
.account-hoster-group-header .account-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.accounts-empty { .accounts-empty {
text-align: center; text-align: center;