* feat(system): report configured, effective and watched sources
GET /prompt_manager/settings adds settings.monitoring_enabled, a sources
block (configured roots with valid/code/reason, effective roots with their
listing index and watch state, auto_detected) and the monitor state
(present, observer_alive, scheduled). Every path is a public path.
A source is "watching" only while a live observer scheduled it, "manual"
when monitoring is off or the process has no monitor, and otherwise
"restart_pending".
POST accepts monitoring_enabled as a strict bool, saved under
gallery.monitoring.enabled; restart_required_fields names it when the
running observer does not already match. A save that changes the sources
runs as a "Source change" records hold, so it is refused with 409 busy
while a scan runs. Case variants of one folder are saved once.
POST /prompt_manager/settings/validate-source {path} checks a path
lexically (no "..", no control characters, native path forms only) and
then against the gallery-root rules, answering a code and reason, and
writes nothing.
* fix(system): no existence oracle in source validation
A gallery root's containment in the allowed ComfyUI folders is now
decided before its existence and type, so a path outside them answers
"outside-allowed" whether or not it exists. validate-source also folds
the filesystem-root and home-directory codes into "outside-allowed", with
one reason, and logs the detail server-side only. Paths inside the
allowed folders still say "not-found" or "not-directory". The legacy
settings save shares the same check.
* feat(scan): scan one selected source
POST /prompt_manager/jobs/scan accepts roots: indexes into the effective
sources, strict non-negative ints (no bools or floats), deduplicated, at
most 64, non-empty when present. An index past the sources is a 400
"unknown-source" and starts nothing. The job walks only the selected
sources, resolved when it was requested, and keeps each file's listing
root_index. Its scope records files, roots (null for every source) and
output_folders, the number of folders walked, which the log's first line
counts. The legacy /scan route still scans every source, and attaching to
a scoped job keeps its payload keys.
* feat(system): manage sources and start scans natively
System > Sources & scans renders the concept's section on the server's
sources: one row per effective source with its watch state and a scan
scoped to that source, the 3.2.0 update notice with the legacy dismissal,
Add source checked by validate-source, Remove, monitoring and worker
threads as host drafts behind the one Save row, and Refresh files.
A save refused by a running scan names the job. Thumbnail generation
hands off to the legacy gallery until S5.
Deviations D98, D113 and D-NEW-S4-1 are recorded in TESTING.md.
* fix(security): decide source containment as text before any filesystem access
Every caller (validate-source, POST /settings, config.json at load) now
refuses a source through one check, sources.source_problem: the form
check first, then GalleryConfig.gallery_root_problem, which compares the
normalised spelling with the allowed folders before anything resolves,
stats or opens the path. A UNC share, the \\?\ and \\.\ namespaces, a
device or any outside folder is refused with no SMB request, no
executor stall and no existence or timing oracle; only then is the path
resolved and contained again, which still catches a symlink out. The
filesystem root and the home directory answer outside-allowed with the
same reason as any outside path, so no caller learns the home path.
Also:
- POST /settings runs the form checks and parses the roots off the loop;
an unusable source is a coded 400 invalid-source naming its public
path (as text) and its index.
- ComfyUI folders outside the base directory are path anchors, so their
sources round-trip and never collide by folder name.
- A per-source scan sends each index with its public path; a stale or
shifted index is 409 sources-changed and starts nothing.
- A source still watched after monitoring was switched off is
"stopping".
- Roots are compared as folders, not spellings, so an unchanged legacy
resend takes no source-change hold.
- Tests: no realpath/stat/lstat of an outside path (native and Windows
forms with ntpath fakes), the alike answers with /, ~ and a link to ~,
an event instead of a timer wait, and a teardown that drains jobs
before the temporary database goes.
* fix(system): say what Sources saved, refused and still watches
- A per-source scan sends the source's public path; when the sources
changed since they were read the toast says the server's reason and
the list and saved values are read again.
- "watching until restart" for a source the running monitor still
watches after monitoring was saved off, and the status adds "Restart
ComfyUI to apply: Watch for new outputs." from restart_required_fields.
- A save refused for one unusable source marks that row and the status
is the server's message naming it.
- A valid configured source the effective list holds under another
spelling reads "same folder as another source".
- The busy toast names the job the 409 named (ApiError keeps a
validated running job and refused source), falling back to the list.
- The Add source dialog's listeners live on the page scope and Enter
adds; its markup is compared with the concept (D127).
- TESTING.md: D-NEW-S4-1 is D127; D-NEW-S4-2 records the new copy; the
Sources contract describes the lexical containment, invalid-source and
sources-changed.
* fix(security): guard the Windows form probes on a token of their own
The no-filesystem guard matched "C:", which on a Windows runner is also
the temporary ComfyUI tree the save may resolve for its trusted first
entry. The probe paths now carry a token no trusted directory holds.
* docs(system): number the Sources states deviation D128
199 lines
10 KiB
JavaScript
199 lines
10 KiB
JavaScript
/**
|
||
* Native System (#249 System S2 Task 5): the concept's `renderSystem` shell (app.js 698–706) at `#/system/<section>`.
|
||
* Under the page heading it renders the section nav (`.section-nav`, one button per section, the shown one
|
||
* `aria-pressed`; a press navigates to its hash), then `.settings-layout` with the section's body and the Save row
|
||
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
|
||
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
|
||
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs),
|
||
* Appearance (system-appearance.mjs) and Sources & scans (system-sources.mjs) are native.
|
||
*
|
||
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
|
||
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
|
||
*/
|
||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||
import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||
import { createSystemDrafts, DRAFT_DOMAINS } from './system-state.mjs';
|
||
import { sectionNav } from './system-controls.mjs';
|
||
import { healthNotice, mountOverview } from './system-overview.mjs';
|
||
import { mountAppearance } from './system-appearance.mjs';
|
||
import { mountSources } from './system-sources.mjs';
|
||
import { busyToast } from './activity-scan.mjs';
|
||
import { showWorkspaceToast } from './toast.mjs';
|
||
import { displayPreferences, readPreferences, reconcilePreferences, subscribePreferences, writePreferences } from './preferences.mjs';
|
||
|
||
const SETTINGS_URL = '/prompt_manager/settings';
|
||
const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' });
|
||
const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open existing image gallery' });
|
||
/**
|
||
* D94: where each section's capabilities live until its PR (the plan's legacy capability coverage): the admin page's
|
||
* dashboard, Settings, Maintenance, Backup, Restore, Scan, Logs, Diagnostics and Auto-tag tools, and the gallery's
|
||
* settings, thumbnails, duplicates and cache tools.
|
||
*/
|
||
export const SECTION_HANDOFFS = Object.freeze({
|
||
integrations: [ADMIN],
|
||
tagging: [ADMIN],
|
||
maintenance: [ADMIN, GALLERY],
|
||
backups: [ADMIN],
|
||
diagnostics: [ADMIN],
|
||
});
|
||
export const NOT_DELIVERED = 'This setting is not available here yet.';
|
||
|
||
let drafts = null;
|
||
// The Activity store of the System page shown last: a refused host save that does not name its job reads the list.
|
||
let jobs = null;
|
||
|
||
/** The running job a 409 `busy` named, else the first running one from a fresh list read (the load never throws). */
|
||
async function runningJob(error) {
|
||
if (error?.running) return error.running;
|
||
await jobs.load();
|
||
return jobs.running()[0];
|
||
}
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
/** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */
|
||
const undelivered = async () => { throw new Error(NOT_DELIVERED); };
|
||
|
||
/**
|
||
* The page session's drafts (Q10). The browser domain is this browser's display defaults (preferences.mjs): a write
|
||
* says whether storage kept it, so the status never claims "Saved in this browser." for a change only this tab holds.
|
||
* The host domain reads and saves the server settings with their revision (#220).
|
||
*/
|
||
export function systemDrafts(api) {
|
||
drafts ??= createSystemDrafts({
|
||
readBrowser: () => readPreferences().values,
|
||
readRuntime: () => ({}),
|
||
writeBrowser: changes => {
|
||
// Another page changed a drafted value since this tab read it, and no storage event told it yet: refuse,
|
||
// so the store keeps the draft, flags it and reads the values again; the next Save is an explicit choice.
|
||
const baseline = drafts.saved('browser');
|
||
const { conflicts } = reconcilePreferences({ baseline, drafts: changes, next: readPreferences().values });
|
||
if (conflicts.length) throw Object.assign(new Error('Settings changed elsewhere'), { code: 'settings-changed' });
|
||
return { persisted: writePreferences(changes, undefined, { baseline }).persisted };
|
||
},
|
||
writeRuntime: undelivered,
|
||
async readHost() {
|
||
const result = await api.get(SETTINGS_URL, { cache: 'no-store' });
|
||
// The store reads a missing value as none and a missing revision as unreadable (no host save).
|
||
return { values: result.settings, revision: result.revision };
|
||
},
|
||
async writeHost(changes, { expected_revision }) {
|
||
try {
|
||
return await api.post(SETTINGS_URL, { ...changes, expected_revision });
|
||
} catch (error) {
|
||
// A sources change waits for a running record-changing job (S4): name it, as a refused start does (D57).
|
||
if (error?.code === 'busy') showWorkspaceToast(busyToast(await runningJob(error)));
|
||
throw error;
|
||
}
|
||
},
|
||
});
|
||
return drafts;
|
||
}
|
||
|
||
/** D94: the shared hand-off panel, linking each legacy tool that still owns this section. */
|
||
function handoff(section) {
|
||
const panel = el('section', undefined, 'surface handoff');
|
||
panel.append(el('h2', 'Your current tools are ready'),
|
||
el('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'));
|
||
for (const tool of SECTION_HANDOFFS[section]) {
|
||
const link = el('a', tool.label, 'workspace-link');
|
||
link.href = window.ApiClient.resolveUrl(tool.href);
|
||
panel.append(link);
|
||
}
|
||
return [panel];
|
||
}
|
||
|
||
/** D92: highlight the controls of the settings the last save could not save. */
|
||
function markFailed(layout, store) {
|
||
const failed = new Set(DRAFT_DOMAINS.flatMap(domain => [...store.failed(domain)]));
|
||
for (const control of layout.querySelectorAll('[data-setting]')) {
|
||
// A row's control names its stored key when that differs from the row id (Appearance's `data-key`).
|
||
if (failed.has(control.dataset.key ?? control.dataset.setting)) control.setAttribute('aria-invalid', 'true');
|
||
else control.removeAttribute('aria-invalid');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11), and Maintenance's notice with "Run health checks" above the
|
||
* hand-off to the maintenance tools that are not native yet (D123).
|
||
*/
|
||
export const DELIVERED_SECTIONS = Object.freeze({
|
||
overview: mountOverview,
|
||
appearance: mountAppearance,
|
||
sources: mountSources,
|
||
maintenance: (container, { api, activity, scope }) => [healthNotice({ api, activity, scope }), ...handoff('maintenance')],
|
||
});
|
||
|
||
/**
|
||
* Mount System under the heading `renderHeading` wrote in `main`. Returns `{show(section)}`, which shows another
|
||
* section in place. `scope` is the page's view scope; `sections` maps a section id to a delivered section's mount
|
||
* (`(container, {api, activity, scope, drafts}) => nodes`), and every other section hands off. `activity` is the page's
|
||
* Activity store, which follows the jobs a section starts.
|
||
*
|
||
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
|
||
* until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on
|
||
* mount and again whenever another page changes them (a `storage` event). Either way a value another page changed
|
||
* under one of this tab's drafts flags that draft (D92's conflict status), including a change made while System was
|
||
* not mounted (the drafts outlive it, Q10).
|
||
*/
|
||
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
|
||
const store = systemDrafts(api);
|
||
jobs = activity;
|
||
store.reloadHost();
|
||
// Another tab or the legacy gallery changed the display defaults: clean values follow it; a draft it overtook stays
|
||
// and is flagged (D92's conflict status). `next` is null when the values cannot be read (no gallery-settings.js).
|
||
const reconcile = next => {
|
||
const baseline = store.saved('browser');
|
||
// The first mount has read nothing yet, so nothing can have been overtaken.
|
||
const conflicts = next && Object.keys(baseline).length
|
||
? reconcilePreferences({ baseline, drafts: store.changes('browser'), next }).conflicts : [];
|
||
store.load('browser').then(() => store.conflict('browser', conflicts), () => {});
|
||
};
|
||
reconcile(displayPreferences());
|
||
scope.cleanup(subscribePreferences(window, next => reconcile(next.values)));
|
||
const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope,
|
||
onSelect: id => navigate(`#/system/${id}`) });
|
||
const layout = el('div', undefined, 'settings-layout');
|
||
const saveRow = el('div', undefined, 'save-row');
|
||
const status = el('span', undefined, 'small');
|
||
status.id = 'settings-status';
|
||
const save = createButton('Save preferences', 'ca-primary');
|
||
saveRow.append(status, save);
|
||
layout.append(saveRow);
|
||
main.append(nav, layout);
|
||
|
||
const paintStatus = () => {
|
||
status.textContent = store.statusText();
|
||
if (store.dirty() && !store.saving()) save.removeAttribute('aria-disabled');
|
||
else save.setAttribute('aria-disabled', 'true');
|
||
markFailed(layout, store);
|
||
};
|
||
scope.cleanup(store.subscribe(paintStatus));
|
||
scope.listen(save, 'click', () => {
|
||
if (save.getAttribute('aria-disabled') === 'true') return;
|
||
const saving = store.save();
|
||
paintStatus();
|
||
saving.then(() => { if (!scope.signal.aborted) paintStatus(); });
|
||
});
|
||
|
||
let sectionScope = null;
|
||
scope.cleanup(() => sectionScope?.dispose());
|
||
function show(next) {
|
||
sectionScope?.dispose();
|
||
sectionScope = createScope();
|
||
for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next));
|
||
while (layout.firstChild !== saveRow) layout.firstChild.remove();
|
||
const mount = sections[next];
|
||
const nodes = mount ? mount(layout, { api, activity, scope: sectionScope, drafts: store }) : handoff(next);
|
||
layout.prepend(...nodes);
|
||
paintStatus();
|
||
}
|
||
show(section);
|
||
return { show };
|
||
}
|