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:
parent
d9e858febd
commit
2e8e8a3819
53
main.js
53
main.js
@ -836,35 +836,52 @@ async function runHosterHealthCheck(config, 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)) {
|
||||
return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
|
||||
}
|
||||
|
||||
// Find specific account
|
||||
const accounts = config.hosters[hoster];
|
||||
const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null;
|
||||
|
||||
try {
|
||||
let result;
|
||||
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' };
|
||||
}
|
||||
const result = await _dispatchHealthCheck(hoster, hosterConfig, otp || '');
|
||||
return { hoster, accountId, ...result };
|
||||
} catch (err) {
|
||||
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 };
|
||||
}
|
||||
|
||||
120
renderer/app.js
120
renderer/app.js
@ -2421,19 +2421,9 @@ function updateStatusBar() {
|
||||
|
||||
// --- Health Check ---
|
||||
|
||||
function renderHealthCheckResults(results) {
|
||||
function renderHealthCheckResults(_results) {
|
||||
const container = document.getElementById('healthCheckResults');
|
||||
if (!container) return;
|
||||
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('');
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
|
||||
async function executeHealthCheck(hosters, _mode) {
|
||||
@ -3084,6 +3074,40 @@ function updateAccountCard(accountId) {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx);
|
||||
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;
|
||||
@ -3117,16 +3141,65 @@ function renderAccounts() {
|
||||
for (const name of HOSTERS) {
|
||||
const accounts = byHoster[name];
|
||||
if (!accounts || accounts.length === 0) continue;
|
||||
html += `<div class="account-hoster-group" data-hoster-group="${name}">
|
||||
<div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
|
||||
accounts.forEach((account, idx) => { html += _buildAccountCardHtml(name, account, idx); });
|
||||
html += '</div>';
|
||||
html += _buildAccountHosterGroupHtml(name, accounts);
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
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 ? '▼' : '▶';
|
||||
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
|
||||
// the first render and reused for every subsequent in-place update / card
|
||||
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
|
||||
@ -3135,6 +3208,21 @@ function renderAccounts() {
|
||||
function bindAccountListeners(container) {
|
||||
_accountListenersBound = true;
|
||||
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 ? '▼' : '▶';
|
||||
const summary = _summarizeHosterGroup(config.hosters[name] || []);
|
||||
_hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
|
||||
|
||||
@ -875,17 +875,62 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
|
||||
.account-hoster-group {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.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: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 {
|
||||
text-align: center;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user