With many models, or models on a network drive, every visit to the scan page read every folder and, after a restart, every new model's header again (79 s for 1041 models on an AutoDL model disk). api/model_heads.py keeps what each file's header says by real path, size and time in user/anomalous/model_heads.json (at most 50,000 files, the longest unused dropped first, written a few seconds after the last change, only once setup_routes called keep_on_disk); get_metadata reads headers through it, so a header is read once per file version. scan_summary keeps the last summary (memory, and scan_summary.json when it changed); ?quick=1 hands it out at once and the page shows it with "showing the last counts while they are updated", then the fresh ones. Requests meanwhile await one count (_fresh). The models found on Civitai (components apart, at most 5000 kept) are listed by GET /anomalous/scan_matched; the page's "Found on Civitai" count opens them, with Civitai's name, base model, a box to find one by name and Open. Checked on the isolated ComfyUI reading the real loras and checkpoints read-only (scans stubbed; no file there changed): the last counts showed at once also after a restart, the fresh ones replaced them, the found list had 68 models with names, the box filtered, Open and Back returned to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
14 KiB
JavaScript
295 lines
14 KiB
JavaScript
/**
|
|
* The scan page's lists, each a page of its own behind a count on the scan page: the models
|
|
* not scanned yet, the unmatched ones (both without the components), the components (VAE, text
|
|
* encoders…: found, not found and not scanned, with why not being found is fine), the model
|
|
* files a scan never reads (other formats), each model the last scan changed
|
|
* (GET /anomalous/scan_summary, /anomalous/last_scan), and the models found on Civitai, with
|
|
* a box to find one by name (/anomalous/scan_matched, read when the list opens).
|
|
* On the scan page itself the last scan is one summary card with a way to its list.
|
|
* Every row opens its model; rows offer "scan" / "look up again" and a Civitai search. Two
|
|
* more lists: the new models whose header looks like no drawing model ("don't scan" / "scan
|
|
* as usual"), and the models set to "don't scan" ("scan it after all").
|
|
* The page passes what the buttons do (`actions`); this module only renders.
|
|
*/
|
|
|
|
import { translate as t } from './locales.js';
|
|
import { dayLabel, timeLabel } from './activity_log.js';
|
|
import { countsLine, fileOutcome, isPending, reasonText, scanScope, STATUS_MARKS } from './scan_results.js';
|
|
import { typeLabel } from './ui_model_types.js';
|
|
import { scanIcon } from './scan_icons.js';
|
|
import { civitaiDownWhy } from './net_failure.js';
|
|
|
|
const SHOWN_ROWS = 100; // more behind "show all"
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function button(className, label, onClick) {
|
|
const node = el('button', className, label);
|
|
node.type = 'button';
|
|
node.onclick = onClick;
|
|
return node;
|
|
}
|
|
|
|
/** Opens a listed model's detail; its back button returns to `onBack` (the list it came from). */
|
|
export async function openListedModel(owner, item, onBack) {
|
|
const params = new URLSearchParams({ type: item.type, path_idx: item.path_idx, rel: item.rel });
|
|
const data = await fetch(`/anomalous/scan_model?${params}`).then(res => res.json()).catch(() => ({}));
|
|
if (!data.model) {
|
|
alert(t('scanModelGone', { name: item.filename }));
|
|
return;
|
|
}
|
|
owner.hideAllPanels();
|
|
owner.historyStack = [];
|
|
owner.currentType = data.model.type;
|
|
owner.currentPathIdx = data.model.path_idx;
|
|
owner.currentSubfolder = data.model.subfolder || '/';
|
|
owner.currentDetailModel = data.model;
|
|
owner.recipeModelReturn = onBack; // set after hideAllPanels, which clears it
|
|
owner.showDetail(data.model);
|
|
}
|
|
|
|
/** Civitai's model search for a file name, to find a model by hand. */
|
|
function searchCivitai(item) {
|
|
const query = item.filename.replace(/\.safetensors$/i, '').replace(/[_-]+/g, ' ');
|
|
window.open(`https://civitai.com/search/models?query=${encodeURIComponent(query)}`, '_blank', 'noopener');
|
|
}
|
|
|
|
function where(item) {
|
|
const folder = item.rel.includes('/') ? item.rel.slice(0, item.rel.lastIndexOf('/')) : '';
|
|
return [item.type ? typeLabel({ type: item.type }) : '', folder].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
/** One model: mark, file name, where it is, what about it, and its buttons. */
|
|
function row(item, detail, buttons, mark = '') {
|
|
const line = el('div', `anomalous-scan-row${item.status ? ` is-${item.status}` : ''}`);
|
|
if (mark) line.append(el('span', 'anomalous-scan-row-mark', mark));
|
|
const copy = el('div', 'anomalous-scan-row-copy');
|
|
const name = el('strong', 'anomalous-scan-row-name', item.filename);
|
|
name.title = item.rel;
|
|
copy.append(name, el('small', 'anomalous-scan-row-where', where(item)));
|
|
if (detail) copy.append(el('small', 'anomalous-scan-row-detail', detail));
|
|
const actions = el('div', 'anomalous-scan-row-actions');
|
|
for (const [label, onClick, disabled] of buttons) {
|
|
const node = button('anomalous-scan-row-btn', label, onClick);
|
|
node.disabled = Boolean(disabled);
|
|
actions.append(node);
|
|
}
|
|
line.append(copy, actions);
|
|
return line;
|
|
}
|
|
|
|
/** Buttons for an unmatched model. */
|
|
function unmatchedButtons(item, actions) {
|
|
return [
|
|
[t('scanRowOpen'), () => actions.open(item)],
|
|
...(actions.offline ? [] : [[t('scanRowRetry'), () => actions.scan([item], { retry: true }), actions.busy]]),
|
|
[t('scanRowSearch'), () => searchCivitai(item)],
|
|
];
|
|
}
|
|
|
|
/** Rows into `list`, the first `limit`, then a button for the rest. */
|
|
function fillRows(list, items, render, limit) {
|
|
const shown = items.slice(0, limit);
|
|
list.replaceChildren(...shown.map(render));
|
|
if (items.length > shown.length) {
|
|
list.append(button('anomalous-scan-more', t('scanShowAll', { count: items.length }), () => fillRows(list, items, render, Infinity)));
|
|
}
|
|
}
|
|
|
|
/** "Last scan · when · what · how long". */
|
|
function resultTitle(result) {
|
|
const seconds = Math.max(0, Math.round((result.finished || 0) - (result.started || 0)));
|
|
const target = result.kind === 'one' ? result.files?.[0]?.filename : '';
|
|
return t('scanResultTitle', {
|
|
when: `${dayLabel(result.finished)} ${timeLabel(result.finished)}`,
|
|
scope: scanScope(result, target),
|
|
duration: seconds < 60 ? t('scanSeconds', { count: seconds }) : t('scanMinutes', { count: Math.round(seconds / 60) }),
|
|
});
|
|
}
|
|
|
|
/** What the reader must know about a scan: offline, Civitai gone mid-way, errors. */
|
|
function resultNotes(result) {
|
|
const notes = [];
|
|
if (result.options?.offline_only) notes.push(el('p', 'anomalous-scan-muted', t('scanResultOffline')));
|
|
if (result.civitai_down) {
|
|
notes.push(el('p', 'anomalous-scan-note is-warn', t('scanResultCivitaiDown')));
|
|
const why = civitaiDownWhy(result.civitai_error);
|
|
if (why) notes.push(el('p', 'anomalous-scan-muted', why));
|
|
}
|
|
for (const error of result.errors || []) notes.push(el('p', 'anomalous-scan-note is-error', error));
|
|
return notes;
|
|
}
|
|
|
|
/** The last scan at the foot of the scan page, in one line: when, what, the counts, a way to each model. Null before the first scan. */
|
|
export function renderLastScan(result, onDetails) {
|
|
if (!result?.counts) return null;
|
|
const box = el('section', 'anomalous-scan-last');
|
|
const files = result.files || [];
|
|
const line = el('div', 'anomalous-scan-last-line');
|
|
line.append(scanIcon('history'), el('span', 'anomalous-scan-last-text', `${resultTitle(result)} · ${
|
|
files.length ? countsLine(result.counts) : t('scanResultNothing', { count: result.counts.unchanged || 0 })}`));
|
|
if (files.length) line.append(button('anomalous-pick-link', t('scanResultDetails', { count: files.length }), onDetails));
|
|
box.append(line, ...resultNotes(result));
|
|
return box;
|
|
}
|
|
|
|
/** A list's models, title, why they are here and the row for one of them. */
|
|
function listOf(view, summary, last, actions) {
|
|
if (view === 'matched') {
|
|
const found = summary?.matched_models;
|
|
const items = found?.models || [];
|
|
return {
|
|
items,
|
|
loading: !found,
|
|
search: true,
|
|
title: t('scanListMatched', { count: found?.total ?? summary?.matched ?? 0 }),
|
|
hint: t('scanListMatchedHint'),
|
|
render: item => row(item, [item.name, item.base].filter(Boolean).join(' · '), [[t('scanRowOpen'), () => actions.open(item)]], '✓'),
|
|
};
|
|
}
|
|
if (view === 'new') {
|
|
const items = (summary?.new_models || []).filter(item => !item.component);
|
|
return {
|
|
items,
|
|
title: t('scanListNew', { count: items.length }),
|
|
hint: t('scanListNewHint'),
|
|
all: items.length ? [t('scanListScanAll', { count: items.length }), () => actions.scan(items)] : null,
|
|
render: item => row(item, item.not_drawing ? t('scanRowNotDrawing') : '', [
|
|
[t('scanRowOpen'), () => actions.open(item)],
|
|
[t('scanRowScan'), () => actions.scan([item]), actions.busy],
|
|
]),
|
|
};
|
|
}
|
|
if (view === 'notdrawing') {
|
|
const items = (summary?.new_models || []).filter(item => item.not_drawing);
|
|
return {
|
|
items,
|
|
title: t('scanListNotDrawing', { count: items.length }),
|
|
hint: t('scanListNotDrawingHint'),
|
|
all: items.length ? [t('scanPageSkipThem'), () => actions.choose(items, 'skip')] : null,
|
|
render: item => row(item, '', [
|
|
[t('scanRowOpen'), () => actions.open(item)],
|
|
[t('scanRowSkip'), () => actions.choose([item], 'skip')],
|
|
[t('scanRowKeep'), () => actions.choose([item], 'scan')],
|
|
]),
|
|
};
|
|
}
|
|
if (view === 'excluded') {
|
|
const items = summary?.excluded_models || [];
|
|
return {
|
|
items,
|
|
title: t('scanListExcluded', { count: items.length }),
|
|
hint: t('scanListExcludedHint'),
|
|
all: items.length ? [t('scanListExcludedAll'), () => actions.choose(items, '')] : null,
|
|
render: item => row(item, '', [
|
|
[t('scanRowOpen'), () => actions.open(item)],
|
|
[t('scanRowUnskip'), () => actions.choose([item], '')],
|
|
]),
|
|
};
|
|
}
|
|
if (view === 'components') {
|
|
const order = { new: 0, unmatched: 1, matched: 2 };
|
|
const items = [...(summary?.component_models || [])].sort((a, b) => order[a.state] - order[b.state]);
|
|
const fresh = items.filter(item => item.state === 'new');
|
|
const detail = item => (item.state === 'matched' ? t('scanPageMatched') : item.state === 'new' ? t('scanPageNew')
|
|
: [reasonText(item.reason), item.reason === 'not_found' ? t('scanFingerprintKept') : ''].filter(Boolean).join(' · '));
|
|
return {
|
|
items,
|
|
title: t('scanListParts', { count: items.length }),
|
|
hint: t('scanListPartsHint'),
|
|
all: fresh.length ? [t('scanListScanAll', { count: fresh.length }), () => actions.scan(fresh)] : null,
|
|
render: item => row(item.reason === 'damaged' ? { ...item, status: 'damaged' } : item, detail(item),
|
|
item.state === 'unmatched' ? unmatchedButtons(item, actions)
|
|
: item.state === 'new' ? [[t('scanRowOpen'), () => actions.open(item)], [t('scanRowScan'), () => actions.scan([item]), actions.busy]]
|
|
: [[t('scanRowOpen'), () => actions.open(item)]],
|
|
item.state === 'matched' ? '✓' : item.reason === 'damaged' ? '⚠' : item.state === 'unmatched' ? '≈' : ''),
|
|
};
|
|
}
|
|
if (view === 'skipped') {
|
|
const items = summary?.skipped_models || [];
|
|
return {
|
|
items,
|
|
title: t('scanListSkipped', { count: items.length }),
|
|
hint: t('scanListSkippedHint'),
|
|
render: item => row(item, '', [[t('scanRowOpen'), () => actions.open(item)]]),
|
|
};
|
|
}
|
|
if (view === 'result') {
|
|
const items = last?.files || [];
|
|
return {
|
|
items,
|
|
title: t('scanResultList'),
|
|
render: item => row(item, fileOutcome(item),
|
|
item.status === 'inferred' ? unmatchedButtons(item, actions) : [[t('scanRowOpen'), () => actions.open(item)]],
|
|
STATUS_MARKS[item.status] || ''),
|
|
};
|
|
}
|
|
// Unmatched: damaged files first (to download again), then those the next online scan looks up by itself.
|
|
const rank = item => (item.reason === 'damaged' ? 2 : isPending(item.reason) ? 1 : 0);
|
|
const items = (summary?.unmatched_models || []).filter(item => !item.component).sort((a, b) => rank(b) - rank(a));
|
|
// Civitai does not know it and the file is no known image model: say what it may be.
|
|
const guess = item => (item.base ? `≈ ${item.base}` : item.reason === 'not_found' ? t('scanReasonNotImage') : '');
|
|
return {
|
|
items,
|
|
title: t('scanListUnmatched', { count: items.length }),
|
|
hint: t(actions.offline ? 'scanListUnmatchedHintOffline' : 'scanListUnmatchedHint'),
|
|
all: items.length && !actions.offline ? [t('scanPageRetry', { count: items.length }), () => actions.scan(items, { retry: true })] : null,
|
|
render: item => row(item.reason === 'damaged' ? { ...item, status: 'damaged' } : item,
|
|
[reasonText(item.reason), guess(item), item.reason === 'not_found' ? t('scanFingerprintKept') : ''].filter(Boolean).join(' · '),
|
|
unmatchedButtons(item, actions), item.reason === 'damaged' ? '⚠' : '≈'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One list as a page ('new' | 'unmatched' | 'matched' | 'components' | 'skipped' | 'result' | 'notdrawing' | 'excluded'): back to the scan page, what
|
|
* these models are and why they are here, a button for all of them, then the rows. A scan
|
|
* started here shows its progress in `progressHost`, above the rows.
|
|
*/
|
|
export function renderListPage(view, summary, last, actions, progressHost) {
|
|
const list = listOf(view, summary, last, actions);
|
|
const page = el('div', 'anomalous-scan-page');
|
|
page.append(button('anomalous-scan-back', t('scanBack'), actions.back), el('h1', 'anomalous-scan-title', list.title));
|
|
if (view === 'result' && last?.counts) {
|
|
page.append(el('div', 'anomalous-scan-card-title', resultTitle(last)),
|
|
el('div', 'anomalous-scan-result-counts', countsLine(last.counts)), ...resultNotes(last));
|
|
}
|
|
if (list.hint) page.append(el('p', 'anomalous-scan-lead', list.hint));
|
|
if (list.all) {
|
|
const all = button('anomalous-scan-secondary', actions.busy ? t('scanPageScanning') : list.all[0], list.all[1]);
|
|
all.disabled = actions.busy;
|
|
const bar = el('div', 'anomalous-scan-actions');
|
|
bar.append(all);
|
|
page.append(bar);
|
|
}
|
|
page.append(progressHost);
|
|
if (list.loading || !list.items.length) {
|
|
page.append(el('p', 'anomalous-scan-muted', t(list.loading ? 'loading' : 'scanListEmpty')));
|
|
return page;
|
|
}
|
|
const card = el('section', 'anomalous-scan-card');
|
|
const rows = el('div', 'anomalous-scan-rows');
|
|
fillRows(rows, list.items, list.render, SHOWN_ROWS);
|
|
if (list.search) {
|
|
const box = el('input', 'anomalous-pick-search');
|
|
box.type = 'search';
|
|
box.placeholder = t('scanListSearch');
|
|
box.oninput = () => {
|
|
const words = box.value.trim().toLowerCase();
|
|
const shown = words ? list.items.filter(item => `${item.filename} ${item.name || ''}`.toLowerCase().includes(words)) : list.items;
|
|
fillRows(rows, shown, list.render, SHOWN_ROWS);
|
|
};
|
|
card.append(box);
|
|
}
|
|
card.append(rows);
|
|
page.append(card);
|
|
if (view === 'result' && last?.counts?.unchanged) {
|
|
page.append(el('p', 'anomalous-scan-muted', t('scanResultUnchanged', { count: last.counts.unchanged })));
|
|
}
|
|
return page;
|
|
}
|