From 0ee749617c25d54ed442fdd539b20c9bba392f49 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:46:15 +0200 Subject: [PATCH] feat: add host health overview Summarize up to fifty recent batches and current account state into a local host health table with success rates, effective historical throughput, recent failures, last success, and bilingual accessible empty states. --- lib/stats.js | 100 ++++++++++++++++++++++++++++++++++++ renderer/app.js | 68 ++++++++++++++++++++++++ renderer/i18n.js | 14 +++++ renderer/index.html | 26 ++++++++++ renderer/styles.css | 109 +++++++++++++++++++++++++++++++++++++++ tests/stats.test.js | 122 ++++++++++++++++++++++++++++++++++++++++++++ tests/ui-smoke.js | 101 ++++++++++++++++++++++++++++++++++-- 7 files changed, 537 insertions(+), 3 deletions(-) diff --git a/lib/stats.js b/lib/stats.js index fade21b..1b531aa 100644 --- a/lib/stats.js +++ b/lib/stats.js @@ -39,6 +39,105 @@ return out; } + function summarizeHosterHealth(history, options = {}) { + const out = {}; + const hosters = options.hosters && typeof options.hosters === 'object' ? options.hosters : {}; + const accountStatuses = options.accountStatuses && typeof options.accountStatuses === 'object' ? options.accountStatuses : {}; + const failedKeys = new Set(options.sessionFailedKeys instanceof Set + ? options.sessionFailedKeys + : (Array.isArray(options.sessionFailedKeys) ? options.sessionFailedKeys : [])); + const nowCandidate = options.now instanceof Date + ? options.now.getTime() + : (Number.isFinite(options.now) ? Number(options.now) : Date.parse(options.now)); + const nowMs = Number.isFinite(nowCandidate) ? nowCandidate : Date.now(); + const recentCutoff = nowMs - 7 * 24 * 60 * 60 * 1000; + + const ensure = (name) => out[name] || (out[name] = { + sampleSize: 0, + successful: 0, + failed: 0, + skipped: 0, + successRate: null, + effectiveBytes: 0, + effectiveDurationSec: 0, + effectiveBytesPerSecond: null, + lastSuccessAt: null, + failuresLast7Days: 0, + configuredAccounts: 0, + accountProblems: 0, + uncheckedAccounts: 0, + checkingAccounts: 0 + }); + + const hasCredentials = (account) => { + if (!account || typeof account !== 'object' || !account.id) return false; + if (account.authType === 'api') return Boolean(String(account.apiKey || '').trim()); + if (account.authType === 'login') return Boolean(String(account.username || '').trim() && String(account.password || '').trim()); + return Boolean(String(account.apiKey || '').trim() || (String(account.username || '').trim() && String(account.password || '').trim())); + }; + + for (const [name, accountsValue] of Object.entries(hosters)) { + const bucket = ensure(name); + const accounts = Array.isArray(accountsValue) ? accountsValue : []; + bucket.configuredAccounts = accounts.length; + for (const account of accounts) { + const status = accountStatuses[account?.id]?.status || 'unchecked'; + const unavailable = account?.enabled === false || !hasCredentials(account); + const problem = unavailable || failedKeys.has(`${name}:${account?.id || ''}`) || ['error', 'warn', 'otp_required'].includes(status); + if (problem) bucket.accountProblems++; + if (!unavailable && status === 'unchecked') bucket.uncheckedAccounts++; + if (!unavailable && status === 'checking') bucket.checkingAccounts++; + } + } + + const batches = (Array.isArray(history) ? history : []) + .map((batch, index) => { + const timestampMs = batch?.timestamp ? Date.parse(batch.timestamp) : NaN; + return { batch, index, timestampMs: Number.isFinite(timestampMs) ? timestampMs : -Infinity }; + }) + .sort((a, b) => b.timestampMs - a.timestampMs || b.index - a.index) + .slice(0, 50); + + for (const { batch, timestampMs } of batches) { + if (!batch || !Array.isArray(batch.files)) continue; + for (const file of batch.files) { + if (!file || !Array.isArray(file.results)) continue; + const fileSize = Number(file.size); + for (const result of file.results) { + if (!result?.hoster) continue; + const bucket = ensure(result.hoster); + bucket.sampleSize++; + if (result.status === 'done') { + bucket.successful++; + if (Number.isFinite(timestampMs) && timestampMs !== -Infinity) { + const previous = bucket.lastSuccessAt ? Date.parse(bucket.lastSuccessAt) : -Infinity; + if (timestampMs > previous) bucket.lastSuccessAt = new Date(timestampMs).toISOString(); + } + const durationSec = Number(result.durationSec); + if (Number.isFinite(fileSize) && fileSize > 0 && Number.isFinite(durationSec) && durationSec > 0) { + bucket.effectiveBytes += fileSize; + bucket.effectiveDurationSec += durationSec; + } + } else if (result.status === 'skipped') { + bucket.skipped++; + } else { + bucket.failed++; + if (timestampMs >= recentCutoff && timestampMs <= nowMs) bucket.failuresLast7Days++; + } + } + } + } + + for (const bucket of Object.values(out)) { + const attempted = bucket.successful + bucket.failed; + bucket.successRate = attempted > 0 ? bucket.successful / attempted : null; + bucket.effectiveBytesPerSecond = bucket.effectiveDurationSec > 0 + ? bucket.effectiveBytes / bucket.effectiveDurationSec + : null; + } + return out; + } + function classifyErrorCategory(err) { if (!err || typeof err !== 'string') return 'unknown'; const s = err.toLowerCase(); @@ -170,6 +269,7 @@ const api = { summarizePerHoster, + summarizeHosterHealth, classifyErrorCategory, summarizeBatchErrors, mergeSkippedIntoSummary, diff --git a/renderer/app.js b/renderer/app.js index 0cf8b36..6cbde53 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3612,9 +3612,21 @@ function _handleProgressImpl(data) { persistQueueStateSoon(); } +function recordHosterHealthBatch(summary) { + if (!summary || typeof summary !== 'object' || !Array.isArray(summary.files)) return; + const current = Array.isArray(window._historyForStats) ? window._historyForStats : []; + const next = summary.id + ? current.filter(batch => batch?.id !== summary.id) + : current.filter(batch => batch !== summary); + window._historyForStats = [summary, ...next].slice(0, 50); + _invalidateHosterLifetimeCache(); + renderHosterHealthOverview(); +} + function handleBatchDone(summary, options = {}) { uploading = false; applySummaryResults(summary, options.historyPersisted !== false); + if (options.historyPersisted !== false) recordHosterHealthBatch(summary); _deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes // Prune session-stats sets to current queue contents. Without this, IDs // of jobs that were removed from queueJobs (via removeFromQueueOnDone @@ -5851,6 +5863,7 @@ function updateAccountCard(accountId) { _refreshHosterGroupHeader(found.name); updateAccountSidebarSummary(); _applyAccountSidebarFilter(); + renderHosterHealthOverview(); } function _refreshHosterGroupHeader(name) { @@ -5885,6 +5898,56 @@ function _refreshHosterGroupHeader(name) { let _accountListenersBound = false; +function renderHosterHealthOverview() { + const body = document.getElementById('hosterHealthBody'); + if (!body || !window.Stats?.summarizeHosterHealth) return; + if (window._historyForStats === undefined) { + body.innerHTML = `${escapeHtml(localizeUiText('Wird geladen…'))}`; + return; + } + if (window._historyForStats === null) { + body.innerHTML = `${escapeHtml(localizeUiText('Verlauf nicht verfügbar.'))}`; + return; + } + const summary = window.Stats.summarizeHosterHealth(window._historyForStats, { + now: new Date(), + hosters: config.hosters, + accountStatuses, + sessionFailedKeys: _sessionFailedKeys + }); + const names = [...HOSTERS, ...Object.keys(summary).filter(name => !HOSTERS.includes(name))] + .filter(name => summary[name] && (summary[name].sampleSize > 0 || summary[name].configuredAccounts > 0)); + if (names.length === 0) { + body.innerHTML = `${escapeHtml(localizeUiText('Noch keine Hoster-Daten.'))}`; + return; + } + body.innerHTML = names.map(name => { + const row = summary[name]; + const rate = row.successRate === null ? localizeUiText('Nicht geprüft') : `${Math.round(row.successRate * 100)} %`; + const throughput = row.effectiveBytesPerSecond === null + ? localizeUiText('Nicht geprüft') + : formatSpeed(row.effectiveBytesPerSecond / 1024); + const lastSuccess = row.lastSuccessAt ? formatDateTime(row.lastSuccessAt).text : localizeUiText('Nie'); + let accounts = '—'; + if (row.configuredAccounts > 0) { + if (row.accountProblems > 0) accounts = String(row.accountProblems); + else if (row.checkingAccounts > 0) accounts = localizeUiText('Prüfung läuft'); + else if (row.uncheckedAccounts > 0) accounts = localizeUiText('Nicht geprüft'); + else accounts = '0'; + } + return ` + ${escapeHtml(getHosterLabel(name))} + ${row.sampleSize} + ${row.successful} / ${row.failed} / ${row.skipped} + ${escapeHtml(rate)} + ${escapeHtml(throughput)} + ${escapeHtml(lastSuccess)} + ${row.failuresLast7Days} + ${escapeHtml(accounts)} + `; + }).join(''); +} + function renderAccounts() { const container = document.getElementById('accountsList'); if (!container) return; @@ -5892,6 +5955,7 @@ function renderAccounts() { const allAccounts = getAllAccountsFlat(); updateAccountSidebarSummary(allAccounts); + renderHosterHealthOverview(); const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn'); if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning; @@ -6951,6 +7015,9 @@ async function loadHistory() { history = await window.api.getHistory(); } catch (error) { if (generation !== _historyLoadGeneration) return; + window._historyForStats = null; + _invalidateHosterLifetimeCache(); + renderHosterHealthOverview(); historyRowsData = []; historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 }; updateHistorySidebarSummary(); @@ -6965,6 +7032,7 @@ async function loadHistory() { _historyDirty = false; _invalidateHosterLifetimeCache(); _refreshAccountHosterLifetimeStats(); + renderHosterHealthOverview(); const retSel = document.getElementById('historyRetentionSelect'); if (retSel) { retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; diff --git a/renderer/i18n.js b/renderer/i18n.js index 00007b9..e65e7ad 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -176,6 +176,20 @@ ['Hoster-Zugangsdaten verwalten und prüfen', 'Manage and verify host account credentials'], ['Accounts prüfen', 'Check accounts'], ['Account hinzufügen', 'Add account'], + ['Hoster-Gesundheit', 'Host health'], + ['Hoster-Gesundheit aus lokalem Verlauf und aktuellem Accountstatus', 'Host health from local history and current account status'], + ['Lokale Werte aus höchstens 50 Batches. Der effektive historische Durchsatz kann Wartezeiten und Wiederholungen enthalten.', 'Local values from up to 50 batches. Effective historical throughput can include waits and retries.'], + ['Stichprobe', 'Sample'], + ['Erfolg / Fehler / Übersprungen', 'Success / Failed / Skipped'], + ['Erfolgsrate', 'Success rate'], + ['Effektiver historischer Durchsatz', 'Effective historical throughput'], + ['Letzter Erfolg', 'Last success'], + ['Fehler (7 Tage)', 'Failed (7 days)'], + ['Account-Probleme', 'Account issues'], + ['Noch keine Hoster-Daten.', 'No host data yet.'], + ['Verlauf nicht verfügbar.', 'History unavailable.'], + ['Prüfung läuft', 'Check in progress'], + ['Nie', 'Never'], ['Noch keine Hoster', 'No hosts yet'], ['Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.', 'Add your first host account. Credentials are verified before saving.'], ['Alle ausklappen', 'Expand all'], diff --git a/renderer/index.html b/renderer/index.html index a62d9dc..b61ba5c 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -423,6 +423,32 @@
+
+
+

Hoster-Gesundheit

+

Lokale Werte aus höchstens 50 Batches. Der effektive historische Durchsatz kann Wartezeiten und Wiederholungen enthalten.

+
+
+ + + + + + + + + + + + + + + + + +
Hoster-Gesundheit aus lokalem Verlauf und aktuellem Accountstatus
HosterStichprobeErfolg / Fehler / ÜbersprungenErfolgsrateEffektiver historischer DurchsatzLetzter ErfolgFehler (7 Tage)Account-Probleme
Wird geladen…
+
+