diff --git a/README.md b/README.md index 5ec1b96..450be18 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Folder monitoring watches for new files while the application is running. Config 7. Select the destination hosts and decide whether matching jobs start automatically. 8. Enable monitoring and save the settings. -The status card summarizes the complete automation state. **Inactive** means monitoring is disabled or has no usable path. **Active** means the watcher and reconciliation are running. **Paused** means the persistent manual pause is in effect. **Queue limit reached** means matching files are being deferred until capacity becomes available. **Folder disconnected** means the configured folder is currently missing or unreadable and will be checked again. **Error** reports another monitoring failure. The card also shows reachability, current queue use, today's counters, the latest detected file, reconciliation times, and the latest error when one exists. +The status card summarizes the complete automation state. **Inactive** means monitoring is disabled or has no usable path. **Active** means monitoring is enabled and configured and no higher-priority paused, disconnected, error, or queue-limit state currently applies. **Paused** means the persistent manual pause is in effect. **Queue limit reached** means matching files are being deferred until capacity becomes available. **Folder disconnected** means the configured folder is currently missing or unreadable and will be checked again. **Error** reports another monitoring failure. The card also shows reachability, current queue use, today's counters, the latest detected file, reconciliation times, and the latest error when one exists. **Test folder monitoring** performs a read-only full scan with the current folder, filter, subfolder, destination, size-limit, processed-file, and queue-limit rules. It reports aggregate counts without changing the queue, selected files, telemetry, history, logs, source files, settings, or the one-time existing-file option. diff --git a/renderer/app.js b/renderer/app.js index 993e68c..b140e04 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -522,6 +522,14 @@ function createAutomationStatusSnapshot() { const normalized = window.AutomationControl.normalizeAutomationSettings(folderSettings); const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs); const availableSlots = normalized.queueLimitJobs === 0 ? null : Math.max(0, normalized.queueLimitJobs - currentJobCount); + const configuredTargetCount = new Set((Array.isArray(folderSettings.hosters) ? folderSettings.hosters : []) + .map(value => String(value || '').trim()) + .filter(Boolean)).size; + const queueLimited = folderSettings.enabled === true + && String(folderSettings.folderPath || '').trim().length > 0 + && normalized.queueLimitJobs !== 0 + && configuredTargetCount > 0 + && availableSlots < configuredTargetCount; const telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry); const error = String(automationRuntimeStatus.error || automationRuntimeStatus.monitorError || telemetry.lastError || ''); const startedAt = automationTimestamp(automationRuntimeStatus.startedAt) || automationRuntimeStartedAt; @@ -538,7 +546,7 @@ function createAutomationStatusSnapshot() { queueLimitJobs: normalized.queueLimitJobs, currentJobCount, availableSlots, - queueLimited: normalized.queueLimitJobs !== 0 && availableSlots === 0, + queueLimited, telemetry, error, startedAt, @@ -740,8 +748,25 @@ async function applyAutomationEvaluation(evaluation) { const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath)); const admittedFiles = candidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path))); const deferredFiles = candidates.filter(candidate => deferredPaths.has(normalizeAutomationPath(candidate.path))); + const telemetryDelta = { + detected: evaluation.summary.found, + queued: admittedFiles.length, + skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable, + deferred: deferredFiles.length, + lastDetectedName: admittedFiles.at(-1)?.name || '' + }; if (admittedFiles.length === 0) { - return freezeAutomationValue({ admittedFiles: [], deferredFiles, paused, dryRun: false }); + const telemetryResult = await persistAutomationTelemetry(telemetryDelta); + return freezeAutomationValue({ + ok: telemetryResult.warning === '', + error: null, + warning: telemetryResult.warning || null, + admittedFiles: [], + deferredFiles, + paused, + dryRun: false, + plannedJobs: 0 + }); } const newJobs = admittedFiles.flatMap(file => file.eligibleHosters.map(hoster => createAutomationPreviewJob(file, hoster))); queueJobs.push(...newJobs); @@ -793,13 +818,7 @@ async function applyAutomationEvaluation(evaluation) { return freezeAutomationValue({ ok: false, error: result.error, warning: null, admittedFiles: [], deferredFiles, paused: /pausiert/i.test(result.error), dryRun: false }); } } - const telemetryResult = await persistAutomationTelemetry({ - detected: evaluation.summary.found, - queued: admittedFiles.length, - skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable, - deferred: deferredFiles.length, - lastDetectedName: admittedFiles.at(-1)?.name || '' - }); + const telemetryResult = await persistAutomationTelemetry(telemetryDelta); return freezeAutomationValue({ ok: telemetryResult.warning === '', error: null, diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index af1caad..a3e3595 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -846,6 +846,49 @@ contextBridge.exposeInMainWorld('api', { queueLimited: statusSnapshot.queueLimited, frozen: Object.isFrozen(statusSnapshot) && Object.isFrozen(statusSnapshot.telemetry) }; + configureAtomicState(14998); + config.globalSettings.folderMonitor.autoStart = true; + config.globalSettings.folderMonitor.telemetry = { + dateKey: new Date().toLocaleDateString('en-CA'), + detected: 0, + queued: 0, + skipped: 0, + deferred: 0 + }; + window.api.configureAutomationProbe({ paused: false, runtimeStatus: { running: true, reachable: true, folderPath: 'C:\\watch' } }); + const zeroAdmissionFile = { path: 'C:\\watch\\four-targets.mkv', name: 'four-targets.mkv', size: 1024 * 1024, mtimeMs: 1 }; + const zeroAdmissionEvaluation = await evaluateAutomationCandidates([zeroAdmissionFile], { dryRun: false, trigger: 'watcher' }); + const zeroAdmissionResult = await applyAutomationEvaluation(zeroAdmissionEvaluation); + const zeroAdmissionProbe = await window.api.getAutomationProbeState(); + const limitedSnapshot = createAutomationStatusSnapshot(); + config.globalSettings.folderMonitor.queueLimitJobs = 0; + const unlimitedSnapshot = createAutomationStatusSnapshot(); + config.globalSettings.folderMonitor.queueLimitJobs = 15000; + config.globalSettings.folderMonitor.enabled = false; + const disabledSnapshot = createAutomationStatusSnapshot(); + config.globalSettings.folderMonitor.enabled = true; + const zeroAdmission = { + evaluatedAdmitted: zeroAdmissionEvaluation.admittedFiles.map(file => file.name), + evaluatedDeferred: zeroAdmissionEvaluation.deferredFiles.map(file => file.name), + appliedAdmitted: zeroAdmissionResult.admittedFiles.map(file => file.name), + appliedDeferred: zeroAdmissionResult.deferredFiles.map(file => file.name), + telemetry: { + detected: config.globalSettings.folderMonitor.telemetry.detected, + queued: config.globalSettings.folderMonitor.telemetry.queued, + skipped: config.globalSettings.folderMonitor.telemetry.skipped, + deferred: config.globalSettings.folderMonitor.telemetry.deferred + }, + telemetrySaves: zeroAdmissionProbe.mutationCalls.filter(call => call[0] === 'settings').length, + mainAdmissions: zeroAdmissionProbe.mutationCalls.filter(call => call[0] === 'start' || call[0] === 'inject').length, + status: { + state: limitedSnapshot.state, + currentJobCount: limitedSnapshot.currentJobCount, + availableSlots: limitedSnapshot.availableSlots, + queueLimited: limitedSnapshot.queueLimited + }, + unlimited: { availableSlots: unlimitedSnapshot.availableSlots, queueLimited: unlimitedSnapshot.queueLimited }, + disabled: { state: disabledSnapshot.state, queueLimited: disabledSnapshot.queueLimited } + }; const stressStartedAt = performance.now(); const stressQueue = Array.from({ length: 14996 }, (_, index) => ({ id: 'stress-queue-' + index, @@ -1759,7 +1802,7 @@ contextBridge.exposeInMainWorld('api', { startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length }; - return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, manualHostTransactional, atomic, status, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; + return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; })()`; const automationControlCenterScript = `(async () => { const waitFor = async predicate => { @@ -1884,6 +1927,13 @@ contextBridge.exposeInMainWorld('api', { { id: 'ui-preview', file: 'C:\\\\ui-preview.mkv', fileName: 'ui-preview.mkv', hoster: 'doodstream.com', status: 'preview', bytesTotal: 1 }, { id: 'ui-error', file: 'C:\\\\ui-error.mkv', fileName: 'ui-error.mkv', hoster: 'doodstream.com', status: 'error', bytesTotal: 1 } ]; + config.globalSettings.pendingQueue = buildPersistedQueueState(); + queueJobs = []; + selectedFiles = []; + selectedUploadHosters = []; + rebuildJobIndex(); + restoreQueueStateFromConfig(); + const restoredPreviewBeforeResume = JSON.stringify(queueJobs.find(job => job.id === 'ui-preview')); uploadSidebarFilter = 'all'; queueSearchQuery = ''; queueHosterFilter = ''; @@ -1914,6 +1964,7 @@ contextBridge.exposeInMainWorld('api', { pauseButton?.click(); await new Promise(resolve => setTimeout(resolve, 0)); const afterResumeProbe = await window.api.getAutomationProbeState(); + const restoredPreviewAfterResume = queueJobs.find(job => job.id === 'ui-preview'); const resumedLabel = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null; selectedJobIds.clear(); selectedJobIds.add('ui-preview'); @@ -1932,6 +1983,11 @@ contextBridge.exposeInMainWorld('api', { calls: afterPauseProbe.mutationCalls.filter(call => call[0] === 'resume' || call[0] === 'pause').map(call => call[0]), resumedLabel, startDisabledAfterResume, + restoredPreviewPresent: Boolean(restoredPreviewAfterResume), + restoredPreviewStatus: restoredPreviewAfterResume?.status || null, + restoredPreviewByteIdentical: JSON.stringify(restoredPreviewAfterResume) === restoredPreviewBeforeResume, + resumeStartCalls: afterResumeProbe.mutationCalls.filter(call => call[0] === 'start').length, + resumeAddCalls: afterResumeProbe.mutationCalls.filter(call => call[0] === 'inject').length, pausedLabel, pausedLabelEnglish, configPaused: config.globalSettings.folderMonitor.paused @@ -2527,6 +2583,18 @@ app.whenReady().then(async () => { queueLimited: true, frozen: true }); + assert.deepEqual(result.automationPipeline.zeroAdmission, { + evaluatedAdmitted: [], + evaluatedDeferred: ['four-targets.mkv'], + appliedAdmitted: [], + appliedDeferred: ['four-targets.mkv'], + telemetry: { detected: 1, queued: 0, skipped: 0, deferred: 1 }, + telemetrySaves: 1, + mainAdmissions: 0, + status: { state: 'queue-limited', currentJobCount: 14998, availableSlots: 2, queueLimited: true }, + unlimited: { availableSlots: null, queueLimited: false }, + disabled: { state: 'inactive', queueLimited: false } + }); assert.equal(result.automationPipeline.stress.candidateCount, 15000); assert.equal(result.automationPipeline.stress.currentJobCount, 14996); assert.equal(result.automationPipeline.stress.plannedJobs, 4); @@ -2884,6 +2952,11 @@ app.whenReady().then(async () => { reuploadSelectedBtn: false, retryFailedBtn: false }, + restoredPreviewPresent: true, + restoredPreviewStatus: 'preview', + restoredPreviewByteIdentical: true, + resumeStartCalls: 0, + resumeAddCalls: 0, pausedLabel: 'Fortsetzen', pausedLabelEnglish: 'Resume', configPaused: true