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.
This commit is contained in:
Sucukdeluxe
2026-08-16 22:46:15 +02:00
parent 287363aee7
commit 0ee749617c
7 changed files with 537 additions and 3 deletions
+122
View File
@@ -2,6 +2,7 @@ const test = require('node:test');
const assert = require('node:assert');
const {
summarizePerHoster,
summarizeHosterHealth,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory,
@@ -76,6 +77,127 @@ test('summarizePerHoster reports skipped uploads without lowering the host succe
assert.strictEqual(summary.rate, 0.5);
});
test('summarizeHosterHealth keeps skipped results outside the success rate and calculates effective historical throughput', () => {
const now = new Date('2026-08-16T12:00:00.000Z');
const history = [{
timestamp: '2026-08-16T10:00:00.000Z',
files: [
{ name: 'one.bin', size: 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 2 }] },
{ name: 'two.bin', size: 2 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 2 }] },
{ name: 'failed.bin', size: 8 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'error', durationSec: 1 }] },
{ name: 'skipped.bin', size: 16 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'skipped', durationSec: 1 }] }
]
}];
const summary = summarizeHosterHealth(history, { now })['voe.sx'];
assert.deepStrictEqual({
sampleSize: summary.sampleSize,
successful: summary.successful,
failed: summary.failed,
skipped: summary.skipped,
successRate: summary.successRate,
effectiveBytes: summary.effectiveBytes,
effectiveDurationSec: summary.effectiveDurationSec,
effectiveBytesPerSecond: summary.effectiveBytesPerSecond
}, {
sampleSize: 4,
successful: 2,
failed: 1,
skipped: 1,
successRate: 2 / 3,
effectiveBytes: 3 * 1024 * 1024,
effectiveDurationSec: 4,
effectiveBytesPerSecond: 786432
});
});
test('summarizeHosterHealth reports the newest successful batch and failures in the current seven-day window', () => {
const now = new Date('2026-08-16T12:00:00.000Z');
const history = [
makeBatch(Date.parse('2026-08-09T11:59:59.999Z'), [{ hoster: 'byse.sx', status: 'error' }]),
makeBatch(Date.parse('2026-08-09T12:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }]),
makeBatch(Date.parse('2026-08-15T09:00:00.000Z'), [{ hoster: 'byse.sx', status: 'done', durationSec: 4 }]),
makeBatch(Date.parse('2026-08-16T11:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }]),
makeBatch(Date.parse('2026-08-16T13:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }])
];
const summary = summarizeHosterHealth(history, { now })['byse.sx'];
assert.strictEqual(summary.lastSuccessAt, '2026-08-15T09:00:00.000Z');
assert.strictEqual(summary.failuresLast7Days, 2);
});
test('summarizeHosterHealth uses only the 50 chronologically newest batches', () => {
const history = [makeBatch(1, [{ hoster: 'doodstream.com', status: 'done', durationSec: 1 }])];
for (let timestamp = 2; timestamp <= 51; timestamp++) {
history.push(makeBatch(timestamp, [{ hoster: 'doodstream.com', status: 'error' }]));
}
history.reverse();
const summary = summarizeHosterHealth(history, { now: new Date(100000) })['doodstream.com'];
assert.deepStrictEqual({
sampleSize: summary.sampleSize,
successful: summary.successful,
failed: summary.failed,
lastSuccessAt: summary.lastSuccessAt
}, {
sampleSize: 50,
successful: 0,
failed: 50,
lastSuccessAt: null
});
});
test('summarizeHosterHealth combines configured accounts, current statuses, and session failures without double counting', () => {
const hosters = {
'voe.sx': [
{ id: 'ready', enabled: true, authType: 'login', username: 'ready@example.invalid', password: 'secret' },
{ id: 'failed', enabled: true, authType: 'login', username: 'failed@example.invalid', password: 'secret' },
{ id: 'unchecked', enabled: true, authType: 'login', username: 'unchecked@example.invalid', password: 'secret' },
{ id: 'disabled', enabled: false, authType: 'login', username: 'disabled@example.invalid', password: 'secret' },
{ id: 'session', enabled: true, authType: 'login', username: 'session@example.invalid', password: 'secret' }
],
'clouddrop.cc': []
};
const accountStatuses = {
ready: { status: 'ok' },
failed: { status: 'error' },
unchecked: { status: 'unchecked' },
disabled: { status: 'error' },
session: { status: 'ok' }
};
const summary = summarizeHosterHealth([], {
now: new Date('2026-08-16T12:00:00.000Z'),
hosters,
accountStatuses,
sessionFailedKeys: new Set(['voe.sx:failed', 'voe.sx:session'])
});
assert.deepStrictEqual({
configuredAccounts: summary['voe.sx'].configuredAccounts,
accountProblems: summary['voe.sx'].accountProblems,
uncheckedAccounts: summary['voe.sx'].uncheckedAccounts,
checkingAccounts: summary['voe.sx'].checkingAccounts
}, {
configuredAccounts: 5,
accountProblems: 3,
uncheckedAccounts: 1,
checkingAccounts: 0
});
assert.deepStrictEqual({
sampleSize: summary['clouddrop.cc'].sampleSize,
configuredAccounts: summary['clouddrop.cc'].configuredAccounts,
successRate: summary['clouddrop.cc'].successRate
}, {
sampleSize: 0,
configuredAccounts: 0,
successRate: null
});
});
test('classifyErrorCategory: file-rejected phrases', () => {
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
+98 -3
View File
@@ -919,6 +919,9 @@ setTimeout(async () => {
const accountsActive = await wc.executeJavaScript('document.getElementById("accounts-view")?.classList.contains("active")');
check('Accounts tab active', accountsActive);
const hosterHealthSemantics = await wc.executeJavaScript('(() => { const section = document.getElementById("hosterHealthOverview"); const table = section?.querySelector("table"); const list = document.getElementById("accountsList"); return { labelled: section?.getAttribute("role") === "region" && section?.getAttribute("aria-labelledby") === "hosterHealthTitle", table: Boolean(table && table.querySelector("caption") && table.querySelectorAll("thead th").length === 8), beforeAccounts: Boolean(section && list && (section.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING)) }; })()');
check('Host health overview is an accessible table above account groups', hosterHealthSemantics.labelled && hosterHealthSemantics.table && hosterHealthSemantics.beforeAccounts);
const accountsWorkspaceLayout = await wc.executeJavaScript('(() => { const view = document.getElementById("accounts-view"); const sidebar = view?.querySelector(":scope > .view-sidebar"); const main = view?.querySelector(":scope > .view-main"); if (!sidebar || !main) return false; const sidebarRect = sidebar.getBoundingClientRect(); const mainRect = main.getBoundingClientRect(); return sidebarRect.width > 0 && mainRect.width > 0 && sidebarRect.right <= mainRect.left; })()');
check('Accounts view separates sidebar and main workspace', accountsWorkspaceLayout === true);
@@ -931,6 +934,91 @@ setTimeout(async () => {
const accountHeaderControlHeights = await wc.executeJavaScript('(() => [document.getElementById("accountsRunHealthCheckBtn"), document.querySelector(".accounts-auto-check"), document.getElementById("addAccountBtn")].map(element => element?.getBoundingClientRect().height || 0))()');
check('Accounts header actions share one rendered height', accountHeaderControlHeights.every(height => height > 0 && Math.abs(height - accountHeaderControlHeights[0]) <= 0.5));
const hosterHealthStates = await wc.executeJavaScript(\`(() => {
const previousConfig = config;
const previousStatuses = accountStatuses;
const previousSessionFailedKeys = _sessionFailedKeys;
const hadHistory = Object.hasOwn(window, '_historyForStats');
const previousHistory = window._historyForStats;
config = { ...config, hosters: Object.fromEntries(HOSTERS.map(name => [name, []])) };
accountStatuses = {};
_sessionFailedKeys = new Set();
window._historyForStats = [];
_invalidateHosterLifetimeCache();
renderAccounts();
const empty = document.querySelector('#hosterHealthOverview [data-hoster-health-empty]')?.textContent.trim() || '';
config.hosters['voe.sx'] = [
{ id: 'health-ready', enabled: true, authType: 'login', username: 'ready@example.invalid', password: 'secret' },
{ id: 'health-failed', enabled: true, authType: 'login', username: 'failed@example.invalid', password: 'secret' }
];
config.hosters['byse.sx'] = [
{ id: 'health-unchecked', enabled: true, authType: 'api', apiKey: 'unchecked-key' }
];
accountStatuses = {
'health-ready': { status: 'ok' },
'health-failed': { status: 'error' },
'health-unchecked': { status: 'unchecked' }
};
_sessionFailedKeys = new Set(['voe.sx:health-failed']);
window._historyForStats = [{
timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
files: [{
name: 'health.bin',
size: 1024 * 1024,
results: [
{ hoster: 'voe.sx', status: 'done', durationSec: 2 },
{ hoster: 'voe.sx', status: 'error', durationSec: 1 },
{ hoster: 'voe.sx', status: 'skipped', durationSec: 1 }
]
}]
}];
_invalidateHosterLifetimeCache();
renderAccounts();
const voe = document.querySelector('[data-hoster-health-row="voe.sx"]');
const byse = document.querySelector('[data-hoster-health-row="byse.sx"]');
const german = {
sample: voe?.querySelector('[data-health="sample"]')?.textContent.trim(),
outcomes: voe?.querySelector('[data-health="outcomes"]')?.textContent.trim(),
rate: voe?.querySelector('[data-health="rate"]')?.textContent.trim(),
throughput: voe?.querySelector('[data-health="throughput"]')?.textContent.trim(),
lastSuccess: voe?.querySelector('[data-health="last-success"]')?.textContent.trim(),
recentFailures: voe?.querySelector('[data-health="recent-failures"]')?.textContent.trim(),
accountProblems: voe?.querySelector('[data-health="accounts"]')?.textContent.trim(),
unchecked: byse?.querySelector('[data-health="accounts"]')?.textContent.trim()
};
setUiLanguage('en');
const english = {
title: document.getElementById('hosterHealthTitle')?.textContent.trim(),
throughput: document.querySelector('#hosterHealthOverview th[data-health-column="throughput"]')?.textContent.trim(),
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;
if (hadHistory) window._historyForStats = previousHistory;
else delete window._historyForStats;
_invalidateHosterLifetimeCache();
renderAccounts();
return { empty, german, english, completedSample };
})()\`);
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');
await captureVisual('02-accounts.png');
const accountListValid = await wc.executeJavaScript('Boolean(document.querySelector("#accountsList .accounts-empty") || document.querySelectorAll("#accountsList .account-hoster-group").length)');
@@ -2539,9 +2627,10 @@ setTimeout(async () => {
const lifetimeRefreshState = await wc.executeJavaScript(\`(() => {
const group = document.querySelector('[data-hoster-group="voe.sx"]');
const meta = group?.querySelector('[data-hoster-lifetime="voe.sx"]');
return { sameGroup: group === window.__uiLifetimeGroup, visible: Boolean(meta && !meta.hidden), text: meta?.textContent.trim() || '' };
const healthSample = document.querySelector('[data-hoster-health-row="voe.sx"] [data-health="sample"]')?.textContent.trim() || '';
return { sameGroup: group === window.__uiLifetimeGroup, visible: Boolean(meta && !meta.hidden), text: meta?.textContent.trim() || '', healthSample };
})()\`);
check('Loaded history refreshes hoster lifetime success without replacing the account group', lifetimeRefreshState.sameGroup && lifetimeRefreshState.visible && lifetimeRefreshState.text === '100% ok (1)');
check('Loaded history refreshes hoster lifetime success and health without replacing the account group', lifetimeRefreshState.sameGroup && lifetimeRefreshState.visible && lifetimeRefreshState.text === '100% ok (1)' && lifetimeRefreshState.healthSample === '1');
historyFixture = historyFixtureBeforeLifetimeCheck;
await wc.executeJavaScript('loadHistory()');
await wc.executeJavaScript('HOSTERS.forEach(name => { config.hosters[name] = []; }); accountStatuses = {}; renderAccounts()');
@@ -3384,6 +3473,11 @@ setTimeout(async () => {
const autoCheckVisible = Boolean(autoCheck && getComputedStyle(autoCheck).display !== 'none' && autoCheck.getBoundingClientRect().width > 0);
const accountsMain = document.querySelector('#accounts-view .view-main');
const accountsMainFits = fits(accountsMain);
const healthOverview = document.getElementById('hosterHealthOverview');
const healthScroller = healthOverview?.querySelector('.hoster-health-scroll');
const healthRect = healthOverview?.getBoundingClientRect();
const accountsRect = accountsMain?.getBoundingClientRect();
const healthOverviewFits = Boolean(healthRect && accountsRect && healthRect.left >= accountsRect.left - 1 && healthRect.right <= accountsRect.right + 1 && fits(healthOverview) && healthScroller?.scrollWidth >= healthScroller?.clientWidth);
document.querySelector('.tab[data-view="upload"]').click();
const telemetry = document.getElementById('uploadTelemetry');
const availability = document.getElementById('uploadAvailability');
@@ -3401,6 +3495,7 @@ setTimeout(async () => {
logRowsFit,
autoCheckVisible,
accountsMainFits,
healthOverviewFits,
telemetryVisible: Boolean(telemetry && getComputedStyle(telemetry).display !== 'none'),
availabilityVisible: Boolean(availability && getComputedStyle(availability).display !== 'none'),
speedGraphs
@@ -3418,7 +3513,7 @@ setTimeout(async () => {
check('Minimum window keeps the settings header compact', compactSettingsHeader <= 58);
check('Minimum settings sidebar, search, and log rows stay contained', minimumResponsiveContract.settingsSidebarFits && minimumResponsiveContract.settingsSearchFits && minimumResponsiveContract.logRowsFit);
if (!(minimumResponsiveContract.autoCheckVisible && minimumResponsiveContract.accountsMainFits)) console.log('Minimum responsive contract: ' + JSON.stringify(minimumResponsiveContract));
check('Minimum Accounts keeps auto-check reachable and content contained', minimumResponsiveContract.autoCheckVisible && minimumResponsiveContract.accountsMainFits);
check('Minimum Accounts keeps auto-check and host health reachable with content contained', minimumResponsiveContract.autoCheckVisible && minimumResponsiveContract.accountsMainFits && minimumResponsiveContract.healthOverviewFits);
check('Minimum Uploads preserves availability and telemetry information', minimumResponsiveContract.telemetryVisible && minimumResponsiveContract.availabilityVisible);
check('Minimum window keeps the speed graph visible and contained on every main tab', minimumResponsiveContract.speedGraphs.length === 4 && minimumResponsiveContract.speedGraphs.every(Boolean));