From 5ace779b14b503e8c4ee7f4c521a22b8d4c013a4 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:03:18 +0200 Subject: [PATCH] fix: correct host health sampling Keep the complete loaded history snapshot, sample only valid non-future recent batches, count seven-day failures independently, ignore disabled accounts in issue counters, and preserve loading and error states across batch completion. --- lib/stats.js | 45 +++++++++++++--- renderer/app.js | 11 ++-- tests/stats.test.js | 126 +++++++++++++++++++++++++++++++++++++++++++- tests/ui-smoke.js | 105 ++++++++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 15 deletions(-) diff --git a/lib/stats.js b/lib/stats.js index 1b531aa..21b6641 100644 --- a/lib/stats.js +++ b/lib/stats.js @@ -39,6 +39,23 @@ return out; } + function mergeHosterHealthHistory(history, batch) { + if (!Array.isArray(history)) return history; + if (!batch?.id) return [...history, batch]; + const merged = []; + let replaced = false; + for (const existing of history) { + if (existing?.id === batch.id) { + if (!replaced) merged.push(batch); + replaced = true; + } else { + merged.push(existing); + } + } + if (!replaced) merged.push(batch); + return merged; + } + function summarizeHosterHealth(history, options = {}) { const out = {}; const hosters = options.hosters && typeof options.hosters === 'object' ? options.hosters : {}; @@ -81,8 +98,9 @@ const accounts = Array.isArray(accountsValue) ? accountsValue : []; bucket.configuredAccounts = accounts.length; for (const account of accounts) { + if (account?.enabled === false) continue; const status = accountStatuses[account?.id]?.status || 'unchecked'; - const unavailable = account?.enabled === false || !hasCredentials(account); + const unavailable = !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++; @@ -90,11 +108,13 @@ } } - const batches = (Array.isArray(history) ? history : []) + const validBatches = (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 }; + return { batch, index, timestampMs }; }) + .filter(({ timestampMs }) => Number.isFinite(timestampMs) && timestampMs <= nowMs); + const batches = [...validBatches] .sort((a, b) => b.timestampMs - a.timestampMs || b.index - a.index) .slice(0, 50); @@ -109,10 +129,8 @@ 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 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; @@ -122,12 +140,22 @@ bucket.skipped++; } else { bucket.failed++; - if (timestampMs >= recentCutoff && timestampMs <= nowMs) bucket.failuresLast7Days++; } } } } + for (const { batch, timestampMs } of validBatches) { + if (timestampMs < recentCutoff || !Array.isArray(batch?.files)) continue; + for (const file of batch.files) { + if (!Array.isArray(file?.results)) continue; + for (const result of file.results) { + if (!result?.hoster || result.status === 'done' || result.status === 'skipped') continue; + ensure(result.hoster).failuresLast7Days++; + } + } + } + for (const bucket of Object.values(out)) { const attempted = bucket.successful + bucket.failed; bucket.successRate = attempted > 0 ? bucket.successful / attempted : null; @@ -269,6 +297,7 @@ const api = { summarizePerHoster, + mergeHosterHealthHistory, summarizeHosterHealth, classifyErrorCategory, summarizeBatchErrors, diff --git a/renderer/app.js b/renderer/app.js index 6cbde53..fbc18b6 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3614,11 +3614,12 @@ function _handleProgressImpl(data) { 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); + const current = window._historyForStats; + if (!Array.isArray(current)) { + if (current === null) loadHistory(); + return; + } + window._historyForStats = window.Stats.mergeHosterHealthHistory(current, summary); _invalidateHosterLifetimeCache(); renderHosterHealthOverview(); } diff --git a/tests/stats.test.js b/tests/stats.test.js index 95fa2ae..760cf70 100644 --- a/tests/stats.test.js +++ b/tests/stats.test.js @@ -1,5 +1,6 @@ const test = require('node:test'); const assert = require('node:assert'); +const stats = require('../lib/stats'); const { summarizePerHoster, summarizeHosterHealth, @@ -7,7 +8,7 @@ const { summarizeBatchErrors, isRetryableCategory, mergeSkippedIntoSummary -} = require('../lib/stats'); +} = stats; function makeBatch(timestamp, results) { return { @@ -150,6 +151,127 @@ test('summarizeHosterHealth uses only the 50 chronologically newest batches', () }); }); +test('summarizeHosterHealth counts seven-day failures across all valid batches outside the 50-batch sample', () => { + const now = new Date('2026-08-16T12:00:00.000Z'); + const history = Array.from({ length: 60 }, (_, index) => makeBatch( + now.getTime() - (index + 1) * 60 * 60 * 1000, + [{ hoster: 'voe.sx', status: 'error' }] + )); + + const summary = summarizeHosterHealth(history, { now })['voe.sx']; + + assert.deepStrictEqual({ + sampleSize: summary.sampleSize, + failed: summary.failed, + failuresLast7Days: summary.failuresLast7Days + }, { + sampleSize: 50, + failed: 50, + failuresLast7Days: 60 + }); +}); + +test('summarizeHosterHealth excludes disabled accounts from every problem-state counter', () => { + const summary = summarizeHosterHealth([], { + hosters: { + 'voe.sx': [ + { id: 'disabled-error', enabled: false, authType: 'api', apiKey: 'key' }, + { id: 'disabled-unchecked', enabled: false, authType: 'api', apiKey: 'key' }, + { id: 'disabled-checking', enabled: false, authType: 'api', apiKey: 'key' } + ] + }, + accountStatuses: { + 'disabled-error': { status: 'error' }, + 'disabled-unchecked': { status: 'unchecked' }, + 'disabled-checking': { status: 'checking' } + }, + sessionFailedKeys: new Set(['voe.sx:disabled-error']) + })['voe.sx']; + + assert.deepStrictEqual({ + configuredAccounts: summary.configuredAccounts, + accountProblems: summary.accountProblems, + uncheckedAccounts: summary.uncheckedAccounts, + checkingAccounts: summary.checkingAccounts + }, { + configuredAccounts: 3, + accountProblems: 0, + uncheckedAccounts: 0, + checkingAccounts: 0 + }); +}); + +test('mergeHosterHealthHistory preserves the full snapshot and lets the summarizer choose the newest 50 batches', () => { + assert.strictEqual(typeof stats.mergeHosterHealthHistory, 'function'); + const now = new Date('2026-08-16T12:00:00.000Z'); + const history = Array.from({ length: 60 }, (_, index) => ({ + ...makeBatch(now.getTime() - (index + 1) * 60 * 60 * 1000, [{ hoster: 'voe.sx', status: 'error' }]), + id: `loaded-${index}` + })); + const completed = { + ...makeBatch(now.getTime(), [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }]), + id: 'completed' + }; + + const merged = stats.mergeHosterHealthHistory(history, completed); + const replacement = { ...completed, total: 2 }; + const deduplicated = stats.mergeHosterHealthHistory(merged, replacement); + const summary = summarizeHosterHealth(deduplicated, { now })['voe.sx']; + + assert.strictEqual(merged.length, 61); + assert.deepStrictEqual(merged.slice(0, 60), history); + assert.strictEqual(merged[60], completed); + assert.strictEqual(deduplicated.length, 61); + assert.strictEqual(deduplicated[60], replacement); + assert.deepStrictEqual({ + sampleSize: summary.sampleSize, + successful: summary.successful, + failed: summary.failed + }, { + sampleSize: 50, + successful: 1, + failed: 49 + }); +}); + +test('mergeHosterHealthHistory preserves null and undefined loading states', () => { + assert.strictEqual(typeof stats.mergeHosterHealthHistory, 'function'); + const completed = makeBatch(Date.parse('2026-08-16T12:00:00.000Z'), [{ hoster: 'voe.sx', status: 'done' }]); + + assert.strictEqual(stats.mergeHosterHealthHistory(null, completed), null); + assert.strictEqual(stats.mergeHosterHealthHistory(undefined, completed), undefined); +}); + +test('summarizeHosterHealth excludes invalid and future timestamps from every time statistic', () => { + const now = new Date('2026-08-16T12:00:00.000Z'); + const history = [ + { + id: 'invalid', + timestamp: 'not-a-date', + files: [{ name: 'invalid.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }] + }, + makeBatch(Date.parse('2026-08-16T12:00:00.001Z'), [{ hoster: 'voe.sx', status: 'error' }]) + ]; + + const summary = summarizeHosterHealth(history, { now, hosters: { 'voe.sx': [] } })['voe.sx']; + + assert.deepStrictEqual({ + sampleSize: summary.sampleSize, + successful: summary.successful, + failed: summary.failed, + skipped: summary.skipped, + lastSuccessAt: summary.lastSuccessAt, + failuresLast7Days: summary.failuresLast7Days + }, { + sampleSize: 0, + successful: 0, + failed: 0, + skipped: 0, + lastSuccessAt: null, + failuresLast7Days: 0 + }); +}); + test('summarizeHosterHealth combines configured accounts, current statuses, and session failures without double counting', () => { const hosters = { 'voe.sx': [ @@ -183,7 +305,7 @@ test('summarizeHosterHealth combines configured accounts, current statuses, and checkingAccounts: summary['voe.sx'].checkingAccounts }, { configuredAccounts: 5, - accountProblems: 3, + accountProblems: 2, uncheckedAccounts: 1, checkingAccounts: 0 }); diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 00c2cb2..0df7324 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -1019,6 +1019,111 @@ setTimeout(async () => { check('Host health overview switches fully to English without a renderer restart', hosterHealthStates.english.title === 'Host health' && hosterHealthStates.english.throughput === 'Effective historical throughput' && hosterHealthStates.english.unchecked === 'Not checked'); check('A completed upload batch immediately refreshes host health history', hosterHealthStates.completedSample === '1'); + const hosterHealthRegressionStates = await wc.executeJavaScript(\`(() => { + const previousConfig = config; + const previousStatuses = accountStatuses; + const previousSessionFailedKeys = _sessionFailedKeys; + const hadHistory = Object.hasOwn(window, '_historyForStats'); + const previousHistory = window._historyForStats; + const originalLoadHistory = loadHistory; + let states; + try { + config = { ...config, hosters: Object.fromEntries(HOSTERS.map(name => [name, []])) }; + config.hosters['voe.sx'] = [ + { id: 'health-disabled-error', enabled: false, authType: 'api', apiKey: 'disabled-key' }, + { id: 'health-disabled-unchecked', enabled: false, authType: 'api', apiKey: 'disabled-key' }, + { id: 'health-disabled-checking', enabled: false, authType: 'api', apiKey: 'disabled-key' } + ]; + accountStatuses = { + 'health-disabled-error': { status: 'error' }, + 'health-disabled-unchecked': { status: 'unchecked' }, + 'health-disabled-checking': { status: 'checking' } + }; + _sessionFailedKeys = new Set(['voe.sx:health-disabled-error']); + window._historyForStats = [ + { + id: 'health-invalid-time', + timestamp: 'not-a-date', + files: [{ name: 'invalid.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }] + }, + { + id: 'health-future-time', + timestamp: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + files: [{ name: 'future.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'error' }] }] + } + ]; + _invalidateHosterLifetimeCache(); + renderAccounts(); + const invalidRow = document.querySelector('[data-hoster-health-row="voe.sx"]'); + const invalidAndDisabled = { + sample: invalidRow?.querySelector('[data-health="sample"]')?.textContent.trim(), + outcomes: invalidRow?.querySelector('[data-health="outcomes"]')?.textContent.trim(), + lastSuccess: invalidRow?.querySelector('[data-health="last-success"]')?.textContent.trim(), + recentFailures: invalidRow?.querySelector('[data-health="recent-failures"]')?.textContent.trim(), + accountProblems: invalidRow?.querySelector('[data-health="accounts"]')?.textContent.trim() + }; + + const now = Date.now(); + const loaded = Array.from({ length: 60 }, (_, index) => ({ + id: 'health-loaded-' + index, + timestamp: new Date(now - (index + 1) * 60 * 60 * 1000).toISOString(), + files: [{ name: 'loaded-' + index + '.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'error' }] }] + })); + window._historyForStats = loaded; + const completed = { + id: 'health-merged-completed', + timestamp: new Date(now).toISOString(), + total: 1, + files: [{ name: 'completed.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }] + }; + recordHosterHealthBatch(completed); + const replacement = { ...completed, total: 2 }; + recordHosterHealthBatch(replacement); + const mergedRow = document.querySelector('[data-hoster-health-row="voe.sx"]'); + const merged = { + historyLength: window._historyForStats.length, + loadedOrderPreserved: loaded.every((batch, index) => window._historyForStats[index] === batch), + completedIndex: window._historyForStats.indexOf(replacement), + completedCount: window._historyForStats.filter(batch => batch?.id === replacement.id).length, + sample: mergedRow?.querySelector('[data-health="sample"]')?.textContent.trim(), + outcomes: mergedRow?.querySelector('[data-health="outcomes"]')?.textContent.trim(), + recentFailures: mergedRow?.querySelector('[data-health="recent-failures"]')?.textContent.trim() + }; + + let historyReloads = 0; + loadHistory = async () => { historyReloads++; }; + window._historyForStats = null; + recordHosterHealthBatch({ ...completed, id: 'health-null-completed' }); + const nullState = { + preserved: window._historyForStats === null, + reloads: historyReloads + }; + delete window._historyForStats; + const reloadsBeforeUndefined = historyReloads; + recordHosterHealthBatch({ ...completed, id: 'health-undefined-completed' }); + const undefinedState = { + preserved: !Object.hasOwn(window, '_historyForStats') && window._historyForStats === undefined, + reloads: historyReloads - reloadsBeforeUndefined + }; + states = { invalidAndDisabled, merged, nullState, undefinedState }; + } finally { + loadHistory = originalLoadHistory; + config = previousConfig; + accountStatuses = previousStatuses; + _sessionFailedKeys = previousSessionFailedKeys; + if (hadHistory) window._historyForStats = previousHistory; + else delete window._historyForStats; + _invalidateHosterLifetimeCache(); + renderAccounts(); + } + return states; + })()\`); + check('Host health excludes disabled accounts and invalid or future batches from rendered problem and time statistics', hosterHealthRegressionStates.invalidAndDisabled.sample === '0' && hosterHealthRegressionStates.invalidAndDisabled.outcomes === '0 / 0 / 0' && hosterHealthRegressionStates.invalidAndDisabled.lastSuccess === 'Nie' && hosterHealthRegressionStates.invalidAndDisabled.recentFailures === '0' && hosterHealthRegressionStates.invalidAndDisabled.accountProblems === '0'); + check('Completed batches preserve full history while the renderer samples 50 and counts all seven-day failures', hosterHealthRegressionStates.merged.historyLength === 61 && hosterHealthRegressionStates.merged.loadedOrderPreserved === true && hosterHealthRegressionStates.merged.completedIndex === 60 && hosterHealthRegressionStates.merged.sample === '50' && hosterHealthRegressionStates.merged.outcomes === '1 / 49 / 0' && hosterHealthRegressionStates.merged.recentFailures === '60'); + check('Completed batch history merging deduplicates by batch ID without moving the loaded snapshot', hosterHealthRegressionStates.merged.completedCount === 1); + check('Completed batches preserve null history failures and trigger a reload', hosterHealthRegressionStates.nullState.preserved === true && hosterHealthRegressionStates.nullState.reloads === 1); + check('Completed batches preserve undefined initial history loading without starting a second load', hosterHealthRegressionStates.undefinedState.preserved === true && hosterHealthRegressionStates.undefinedState.reloads === 0); + await captureVisual('02-accounts.png'); const accountListValid = await wc.executeJavaScript('Boolean(document.querySelector("#accountsList .accounts-empty") || document.querySelectorAll("#accountsList .account-hoster-group").length)');