mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-08 10:32:13 -03:00
A regular Refresh walked every configured root sequentially, so a full 25 TB drive delayed the roots behind it, and the dialog sat on "Checking for changes..." at 0 % for the whole walk with no way to tell it was working. On a cold external drive the walk itself dominates the cost, so the fix is to overlap the drives and to show what the walk is doing. Backend (py/services/model_scanner.py): * Extract the per-root walk into the synchronous _walk_root_for_reconcile() worker and merge its results on the event loop afterwards, in configured root order: which business path wins a file reachable through several roots must not depend on the order the workers happened to finish in. * Group roots by device (_root_device_key: drive letter on Windows, st_dev on POSIX) and run one worker per device. Roots sharing a device stay sequential, so directory claims and the overlap dedup (#871, #1041) keep their configured-order semantics; different devices run in parallel. * Track walk progress per root (_ReconcileWalkTracker), weighted by each root's cached entry count because the real file count is only known once the walk ends. The bar splits into walk (0-50 %) and new-file (50-99 %) phases so it never jumps backwards, and the walk broadcasts files seen, active roots and the ETA counters. * Replace the Windows case-insensitive fallback -- a scan of every cached path per miss, i.e. O(files x cached) -- with a lazily built lower-cased index (_CachedPathLookups, also now guarding the realpath alias index for worker threads), and lift the os.name == "nt" gate into the module-level _CASE_INSENSITIVE_PATHS so the branch is testable off Windows. * Excluded-model membership is a set lookup instead of a list scan. Frontend: * render the walk phase as "Checking for changes... <roots> (N files)" with the ETA, and reset the ETA tracker when the stage changes: the per-file rate of counting files says nothing about processing them. * add common.scanProgress.walkFiles; the other locales keep the sanctioned [TODO: Translate] placeholder until the translation pass. Verified: no-change reconcile over 10k files/500 dirs 101 ms and 10k/5000 dirs 230 ms (was 93/229 ms, within noise); a two-device sandbox walk runs both roots concurrently, names them in the progress messages and finishes with added=15, removed=0; 3665 passed, 7 skipped; frontend 1456 passed, vue widgets 96 passed.
72 lines
2.7 KiB
JavaScript
72 lines
2.7 KiB
JavaScript
import { translate } from './i18nHelpers.js';
|
|
|
|
/**
|
|
* Format a remaining-time estimate for scan progress display.
|
|
* @param {number} remainingMs - Estimated remaining time in milliseconds
|
|
* @returns {string} Localized ETA text
|
|
*/
|
|
export function formatScanRemainingTime(remainingMs) {
|
|
if (remainingMs < 60000) {
|
|
return translate('common.scanProgress.eta.lessThanMinute', {}, 'Less than a minute remaining');
|
|
}
|
|
if (remainingMs < 3600000) {
|
|
const minutes = Math.round(remainingMs / 60000);
|
|
return translate('common.scanProgress.eta.minutes', { minutes }, `~${minutes} min remaining`);
|
|
}
|
|
const hours = Math.floor(remainingMs / 3600000);
|
|
const minutes = Math.round((remainingMs % 3600000) / 60000);
|
|
return translate('common.scanProgress.eta.hours', { hours, minutes }, `~${hours} hr ${minutes} min remaining`);
|
|
}
|
|
|
|
/**
|
|
* Create an ETA tracker for scan progress. Uses an exponential moving
|
|
* average (0.7/0.3) over the observed per-file processing time, mirroring
|
|
* the estimator in components/initialization.js.
|
|
* @returns {{ update: (processed: number, total: number) => (string|null), reset: () => void }}
|
|
*/
|
|
export function createScanEtaTracker() {
|
|
let startTime = null;
|
|
let lastProcessed = 0;
|
|
let averageMsPerFile = null;
|
|
|
|
return {
|
|
/**
|
|
* Update with the latest counters.
|
|
* @returns {string|null} Localized ETA text, or null when not applicable
|
|
*/
|
|
update(processed, total) {
|
|
if (!total || total <= 0 || processed >= total) {
|
|
return null;
|
|
}
|
|
const now = Date.now();
|
|
if (startTime === null) {
|
|
// First sample only anchors the timer; not enough data yet
|
|
startTime = now;
|
|
lastProcessed = processed;
|
|
return translate('initialization.estimatingTime', {}, 'Estimating time...');
|
|
}
|
|
if (processed > lastProcessed) {
|
|
const msPerFile = (now - startTime) / processed;
|
|
averageMsPerFile = averageMsPerFile === null
|
|
? msPerFile
|
|
: averageMsPerFile * 0.7 + msPerFile * 0.3;
|
|
lastProcessed = processed;
|
|
}
|
|
if (averageMsPerFile === null) {
|
|
return translate('initialization.estimatingTime', {}, 'Estimating time...');
|
|
}
|
|
return formatScanRemainingTime((total - lastProcessed) * averageMsPerFile);
|
|
},
|
|
|
|
/**
|
|
* Drop all samples. Call when the scan moves to another stage: the
|
|
* per-file rate of one stage says nothing about the next one.
|
|
*/
|
|
reset() {
|
|
startTime = null;
|
|
lastProcessed = 0;
|
|
averageMsPerFile = null;
|
|
}
|
|
};
|
|
}
|