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:
+100
@@ -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,
|
||||
|
||||
@@ -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 = `<tr><td colspan="8" data-hoster-health-empty>${escapeHtml(localizeUiText('Wird geladen…'))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
if (window._historyForStats === null) {
|
||||
body.innerHTML = `<tr><td colspan="8" data-hoster-health-empty>${escapeHtml(localizeUiText('Verlauf nicht verfügbar.'))}</td></tr>`;
|
||||
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 = `<tr><td colspan="8" data-hoster-health-empty>${escapeHtml(localizeUiText('Noch keine Hoster-Daten.'))}</td></tr>`;
|
||||
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 `<tr data-hoster-health-row="${escapeAttr(name)}">
|
||||
<th scope="row">${escapeHtml(getHosterLabel(name))}</th>
|
||||
<td data-health="sample">${row.sampleSize}</td>
|
||||
<td data-health="outcomes">${row.successful} / ${row.failed} / ${row.skipped}</td>
|
||||
<td data-health="rate">${escapeHtml(rate)}</td>
|
||||
<td data-health="throughput">${escapeHtml(throughput)}</td>
|
||||
<td data-health="last-success">${escapeHtml(lastSuccess)}</td>
|
||||
<td data-health="recent-failures">${row.failuresLast7Days}</td>
|
||||
<td data-health="accounts">${escapeHtml(accounts)}</td>
|
||||
</tr>`;
|
||||
}).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';
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -423,6 +423,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-check-results account-health-results" id="healthCheckResults"></div>
|
||||
<section class="hoster-health-overview" id="hosterHealthOverview" role="region" aria-labelledby="hosterHealthTitle" aria-describedby="hosterHealthHint">
|
||||
<div class="hoster-health-heading">
|
||||
<h3 id="hosterHealthTitle">Hoster-Gesundheit</h3>
|
||||
<p id="hosterHealthHint">Lokale Werte aus höchstens 50 Batches. Der effektive historische Durchsatz kann Wartezeiten und Wiederholungen enthalten.</p>
|
||||
</div>
|
||||
<div class="hoster-health-scroll" tabindex="0">
|
||||
<table>
|
||||
<caption class="hoster-health-caption">Hoster-Gesundheit aus lokalem Verlauf und aktuellem Accountstatus</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Hoster</th>
|
||||
<th scope="col">Stichprobe</th>
|
||||
<th scope="col">Erfolg / Fehler / Übersprungen</th>
|
||||
<th scope="col">Erfolgsrate</th>
|
||||
<th scope="col" data-health-column="throughput">Effektiver historischer Durchsatz</th>
|
||||
<th scope="col">Letzter Erfolg</th>
|
||||
<th scope="col">Fehler (7 Tage)</th>
|
||||
<th scope="col">Account-Probleme</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hosterHealthBody">
|
||||
<tr><td colspan="8" data-hoster-health-empty>Wird geladen…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<div class="accounts-list" id="accountsList"></div>
|
||||
<div class="accounts-list-footer" id="accountsListFooter" style="display:none">
|
||||
<button class="btn btn-secondary" id="toggleAllAccountsBtn">Alle ausklappen</button>
|
||||
|
||||
@@ -3681,6 +3681,115 @@ input[type="checkbox"] {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.hoster-health-overview {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 10px 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hoster-health-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.hoster-health-heading h3 {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hoster-health-heading p {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hoster-health-scroll {
|
||||
max-width: 100%;
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.hoster-health-scroll:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hoster-health-scroll table {
|
||||
width: 100%;
|
||||
min-width: 880px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hoster-health-caption {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.hoster-health-scroll th,
|
||||
.hoster-health-scroll td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.hoster-health-scroll thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hoster-health-scroll tbody th {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hoster-health-scroll tbody tr:last-child > * {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.hoster-health-scroll tbody tr:hover > * {
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.hoster-health-scroll [data-hoster-health-empty] {
|
||||
height: 38px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accounts-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user