fix: reload authoritative host health history

Refresh host health from the persisted retention-filtered history after batch completion, allowing the existing generation guard to reject stale responses and removing the competing local merge path.
This commit is contained in:
Sucukdeluxe
2026-08-16 23:25:42 +02:00
parent 5ace779b14
commit 2eaa30c0a8
4 changed files with 139 additions and 113 deletions
-18
View File
@@ -39,23 +39,6 @@
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 : {};
@@ -297,7 +280,6 @@
const api = {
summarizePerHoster,
mergeHosterHealthHistory,
summarizeHosterHealth,
classifyErrorCategory,
summarizeBatchErrors,
+1 -14
View File
@@ -3612,22 +3612,9 @@ function _handleProgressImpl(data) {
persistQueueStateSoon();
}
function recordHosterHealthBatch(summary) {
if (!summary || typeof summary !== 'object' || !Array.isArray(summary.files)) return;
const current = window._historyForStats;
if (!Array.isArray(current)) {
if (current === null) loadHistory();
return;
}
window._historyForStats = window.Stats.mergeHosterHealthHistory(current, summary);
_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
@@ -3661,7 +3648,7 @@ function handleBatchDone(summary, options = {}) {
// the user actually switches to it — skips an IPC + full table rebuild per
// batch-done when the user is watching the upload view.
_historyDirty = true;
if (_isHistoryTabActive()) loadHistory();
if (options.historyPersisted !== false || _isHistoryTabActive()) loadHistory();
const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone;
if (options.historyPersisted !== false && removeOnDone) {
+27 -18
View File
@@ -1,6 +1,5 @@
const test = require('node:test');
const assert = require('node:assert');
const stats = require('../lib/stats');
const {
summarizePerHoster,
summarizeHosterHealth,
@@ -8,7 +7,7 @@ const {
summarizeBatchErrors,
isRetryableCategory,
mergeSkippedIntoSummary
} = stats;
} = require('../lib/stats');
function makeBatch(timestamp, results) {
return {
@@ -201,8 +200,7 @@ test('summarizeHosterHealth excludes disabled accounts from every problem-state
});
});
test('mergeHosterHealthHistory preserves the full snapshot and lets the summarizer choose the newest 50 batches', () => {
assert.strictEqual(typeof stats.mergeHosterHealthHistory, 'function');
test('summarizeHosterHealth selects the newest batches without mutating the full loaded snapshot', () => {
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' }]),
@@ -213,16 +211,11 @@ test('mergeHosterHealthHistory preserves the full snapshot and lets the summariz
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'];
history.push(completed);
const loadedOrder = [...history];
const summary = summarizeHosterHealth(history, { 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(history, loadedOrder);
assert.deepStrictEqual({
sampleSize: summary.sampleSize,
successful: summary.successful,
@@ -234,12 +227,28 @@ test('mergeHosterHealthHistory preserves the full snapshot and lets the summariz
});
});
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' }]);
test('summarizeHosterHealth preserves account health while history is unavailable', () => {
const options = {
hosters: {
'voe.sx': [{ id: 'unavailable-history', enabled: true, authType: 'api', apiKey: 'key' }]
},
accountStatuses: {
'unavailable-history': { status: 'error' }
}
};
assert.strictEqual(stats.mergeHosterHealthHistory(null, completed), null);
assert.strictEqual(stats.mergeHosterHealthHistory(undefined, completed), undefined);
for (const history of [null, undefined]) {
const summary = summarizeHosterHealth(history, options)['voe.sx'];
assert.deepStrictEqual({
sampleSize: summary.sampleSize,
configuredAccounts: summary.configuredAccounts,
accountProblems: summary.accountProblems
}, {
sampleSize: 0,
configuredAccounts: 1,
accountProblems: 1
});
}
});
test('summarizeHosterHealth excludes invalid and future timestamps from every time statistic', () => {
+111 -63
View File
@@ -993,18 +993,6 @@ setTimeout(async () => {
unchecked: document.querySelector('[data-hoster-health-row="byse.sx"] [data-health="accounts"]')?.textContent.trim()
};
setUiLanguage('de');
window._historyForStats = [];
_invalidateHosterLifetimeCache();
handleBatchDone({
id: 'health-completed-batch',
timestamp: new Date().toISOString(),
total: 1,
succeeded: 1,
failed: 0,
skipped: 0,
files: [{ name: 'completed.bin', size: 2048, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 2 }] }]
}, { historyPersisted: true, deferPersistence: true });
const completedSample = document.querySelector('[data-hoster-health-row="voe.sx"] [data-health="sample"]')?.textContent.trim();
config = previousConfig;
accountStatuses = previousStatuses;
_sessionFailedKeys = previousSessionFailedKeys;
@@ -1012,12 +1000,11 @@ setTimeout(async () => {
else delete window._historyForStats;
_invalidateHosterLifetimeCache();
renderAccounts();
return { empty, german, english, completedSample };
return { empty, german, english };
})()\`);
check('Host health overview renders clean empty and unchecked account states', hosterHealthStates.empty === 'Noch keine Hoster-Daten.' && hosterHealthStates.german.unchecked === 'Nicht geprüft');
check('Host health overview renders counts, existing-rate semantics, effective historical throughput, recent failures, and account problems', hosterHealthStates.german.sample === '3' && hosterHealthStates.german.outcomes === '1 / 1 / 1' && hosterHealthStates.german.rate === '50 %' && hosterHealthStates.german.throughput === '512 kB/s' && hosterHealthStates.german.lastSuccess !== 'Nie' && hosterHealthStates.german.recentFailures === '1' && hosterHealthStates.german.accountProblems === '1');
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;
@@ -1025,7 +1012,6 @@ setTimeout(async () => {
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, []])) };
@@ -1063,51 +1049,8 @@ setTimeout(async () => {
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 };
states = { invalidAndDisabled };
} finally {
loadHistory = originalLoadHistory;
config = previousConfig;
accountStatuses = previousStatuses;
_sessionFailedKeys = previousSessionFailedKeys;
@@ -1119,10 +1062,115 @@ setTimeout(async () => {
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);
const healthHistoryResolvers = [];
ipcMain.removeHandler('get-history');
ipcMain.handle('get-history', () => new Promise(resolve => { healthHistoryResolvers.push(resolve); }));
const healthBeforeReload = await wc.executeJavaScript(\`(() => {
window.__healthReloadPrevious = {
config,
accountStatuses,
sessionFailedKeys: _sessionFailedKeys,
hadHistory: Object.hasOwn(window, '_historyForStats'),
history: window._historyForStats,
historyEverLoaded: _historyEverLoaded,
historyDirty: _historyDirty
};
config = { ...config, hosters: Object.fromEntries(HOSTERS.map(name => [name, []])) };
accountStatuses = {};
_sessionFailedKeys = new Set();
window._historyForStats = [{
id: 'health-before-batch',
timestamp: new Date(Date.now() - 60000).toISOString(),
files: [{ name: 'before.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }]
}];
_invalidateHosterLifetimeCache();
renderAccounts();
window.__healthStaleReload = loadHistory();
const row = document.querySelector('[data-hoster-health-row="voe.sx"]');
return {
sample: row?.querySelector('[data-health="sample"]')?.textContent.trim(),
outcomes: row?.querySelector('[data-health="outcomes"]')?.textContent.trim()
};
})()\`);
await waitUntil(() => healthHistoryResolvers.length === 1);
const healthPendingReload = await wc.executeJavaScript(\`(() => {
handleBatchDone({
id: 'health-pruned-completed',
timestamp: new Date().toISOString(),
total: 1,
succeeded: 1,
failed: 0,
skipped: 0,
files: [{ name: 'pruned.bin', size: 2048, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }]
}, { historyPersisted: true, deferPersistence: true });
const row = document.querySelector('[data-hoster-health-row="voe.sx"]');
return {
sample: row?.querySelector('[data-health="sample"]')?.textContent.trim(),
outcomes: row?.querySelector('[data-health="outcomes"]')?.textContent.trim(),
ids: window._historyForStats.map(batch => batch.id)
};
})()\`);
const healthPostBatchReloadStarted = Boolean(await waitUntil(() => healthHistoryResolvers.length === 2, 750));
const retainedHealthHistory = [{
id: 'health-retained-authoritative',
timestamp: new Date().toISOString(),
files: [{ name: 'retained.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'error' }] }]
}];
const staleHealthHistory = [{
id: 'health-stale-before-batch',
timestamp: new Date().toISOString(),
files: [{ name: 'stale.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }]
}, {
id: 'health-pruned-completed',
timestamp: new Date().toISOString(),
files: [{ name: 'pruned.bin', size: 2048, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }]
}];
if (healthPostBatchReloadStarted) {
healthHistoryResolvers[1](retainedHealthHistory);
await waitUntil(() => wc.executeJavaScript('window._historyForStats?.[0]?.id === "health-retained-authoritative"'));
} else {
healthHistoryResolvers[0](staleHealthHistory);
await wc.executeJavaScript('window.__healthStaleReload');
}
const healthAfterAuthoritativeReload = await wc.executeJavaScript(\`(() => {
const row = document.querySelector('[data-hoster-health-row="voe.sx"]');
return {
sample: row?.querySelector('[data-health="sample"]')?.textContent.trim(),
outcomes: row?.querySelector('[data-health="outcomes"]')?.textContent.trim(),
ids: window._historyForStats.map(batch => batch.id)
};
})()\`);
if (healthPostBatchReloadStarted) {
healthHistoryResolvers[0](staleHealthHistory);
await wc.executeJavaScript('window.__healthStaleReload');
}
const healthAfterStaleReload = await wc.executeJavaScript(\`(() => {
const row = document.querySelector('[data-hoster-health-row="voe.sx"]');
return {
sample: row?.querySelector('[data-health="sample"]')?.textContent.trim(),
outcomes: row?.querySelector('[data-health="outcomes"]')?.textContent.trim(),
ids: window._historyForStats.map(batch => batch.id)
};
})()\`);
restoreInitialIpcHandler('get-history');
await wc.executeJavaScript(\`(() => {
const previous = window.__healthReloadPrevious;
config = previous.config;
accountStatuses = previous.accountStatuses;
_sessionFailedKeys = previous.sessionFailedKeys;
_historyEverLoaded = previous.historyEverLoaded;
_historyDirty = previous.historyDirty;
if (previous.hadHistory) window._historyForStats = previous.history;
else delete window._historyForStats;
delete window.__healthReloadPrevious;
delete window.__healthStaleReload;
_invalidateHosterLifetimeCache();
renderAccounts();
})()\`);
check('A persisted batch starts a fresh Health reload without replacing existing values with a loading or local state', healthPostBatchReloadStarted && healthBeforeReload.sample === '1' && healthBeforeReload.outcomes === '1 / 0 / 0' && healthPendingReload.sample === '1' && healthPendingReload.outcomes === '1 / 0 / 0' && healthPendingReload.ids.join('|') === 'health-before-batch');
check('The authoritative post-batch reload removes batches already pruned by retention from Health', healthAfterAuthoritativeReload.sample === '1' && healthAfterAuthoritativeReload.outcomes === '0 / 1 / 0' && healthAfterAuthoritativeReload.ids.join('|') === 'health-retained-authoritative');
check('A delayed older History response cannot overwrite the newest Health snapshot', healthAfterStaleReload.sample === '1' && healthAfterStaleReload.outcomes === '0 / 1 / 0' && healthAfterStaleReload.ids.join('|') === 'health-retained-authoritative');
await captureVisual('02-accounts.png');