From 16330cad55307adcf6a14a85ecaf7a56f547dbdf Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 23 Oct 2026 09:21:40 -0700 Subject: [PATCH] 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. --- TESTING.md | 85 +++- tests/browser/system-concept.spec.mjs | 153 ++++++++ .../browser/workspace-system-sources.spec.mjs | 337 ++++++++++++++++ tests/browser/workspace-system.spec.mjs | 5 +- tests/browser/workspace.spec.mjs | 10 +- tests/browser/workspace_system_fixture.py | 51 ++- tests/js/workspace-system-sources.test.js | 123 ++++++ tools/vendor/frontend-coverage-baseline.json | 10 +- web/js/workspace/activity-scan.mjs | 16 +- web/js/workspace/system-sources.mjs | 369 ++++++++++++++++++ web/js/workspace/system.mjs | 30 +- 11 files changed, 1160 insertions(+), 29 deletions(-) create mode 100644 tests/browser/workspace-system-sources.spec.mjs create mode 100644 tests/js/workspace-system-sources.test.js create mode 100644 web/js/workspace/system-sources.mjs diff --git a/TESTING.md b/TESTING.md index 16cf4cc5..ea1185a2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1865,6 +1865,38 @@ cache off selecting the shown file again re-reads it (on, the one current record reused); paths off leaves out only the Path row of File & provenance; the viewer reads the video defaults. +Sources & scans (`system-sources.mjs`, S4) is the concept's "sources" body on the +server's sources. `GET /prompt_manager/settings` reports them (public paths only): +`sources.configured` (each `gallery_root_paths` entry with `valid`, `code` and the +server's `reason`), `sources.effective` (what is scanned and served, with its +`root_index` and `watch`: `watching` only when the image monitor's observer is alive +and scheduled that folder, `manual` when monitoring is off or no monitor runs, else +`restart_pending`), `sources.auto_detected`, `monitor` and `settings.monitoring_enabled`. +`POST /prompt_manager/settings/validate-source {path}` checks one folder and writes +nothing; a path outside the allowed ComfyUI folders answers `outside-allowed` with one +reason whether it exists or not (no existence oracle). A save that changes the sources +holds the registry as `records` ("Source change"), so it is refused with 409 `busy` +while a scan runs. `POST /prompt_manager/jobs/scan {scope, roots}` scans only the given +effective `root_index`es (strict ints, at most 64; an unknown one is 400 +`unknown-source` and starts nothing); the legacy `/scan` is unchanged. The section +lists one row per source (" · watching | manual scan | restart pending" with +"Scan changes" for a watched source, else "Scan folder"), which opens the shared +"Review metadata scan" for that one source ("1 output folder") and starts the session +job over it; the job opens in Activity, and the toast only says it started. Enrich +existing metadata and the notice's Review scan review every source. The notice shows +until this browser dismissed the 3.2.0 update notice (`pm_update_notice_dismissed`, +the legacy admin page's key); Later dismisses it without scanning, and Review scan +dismisses it as the legacy Scan does. "+ Add source" asks validate-source, then adds +the folder to the host draft of `gallery_root_paths`; Remove takes one out; "Watch for +new outputs" (`monitoring_enabled`) and Worker threads (`worker_threads`) are host +drafts too, and Save writes them with the revision. A save the server refuses because a +scan runs toasts "Another job is running: ." with a link to that job (D57) and +keeps the drafts. After a save the source list is read again; a monitoring change waits +for a restart (the running observer keeps watching until then). Thumbnail generation's +Review options opens the legacy gallery in a new tab (D94) until its review dialog (S5). +A list that cannot be read shows "Sources could not be loaded. Your saved data has not +changed." and Retry, never an empty list. + Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per visit through `latestOnly()` on the section scope; a late answer after the section left paints nothing. Until it answers, the section shows "Loading the overview…"; a failure @@ -1892,7 +1924,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged `-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no reader can keep it byte-identical (`test_health_check_never_writes`). -Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122, D123 and D124 assigned): +Recorded deviations (plan D60–D66 and D81 renumbered D92–D98 and D113; D121, D122, D123 and D124 assigned; D-NEW-S4-1 awaits its number): - **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server @@ -1959,6 +1991,32 @@ Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122, D123 and D defaults" ("Restore this browser’s display defaults, then save. Other settings are kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only. The node search timeout shows the server's real value, not the sample 30. CSS under D97. +- **D98** (Sources): each configured source's row has a plain Remove after its scan + button; nothing changes until Save. A source added this session reads "<path> · not + saved yet" with Remove only until Save; a configured source the server cannot use + reads "<path> · <server reason>" with Remove only. A line under the list says when the + list is not the whole story: "No source is configured. After saving, ComfyUI’s output + folder is used." (the last one removed), "No source is configured; ComfyUI’s output + folder is used." and "No configured source is available; ComfyUI’s output folder is + used." (auto-detected rows, listed without Remove). "restart pending" is the third + watch state. The operation "Refresh files" ("Read every source again to find files + added or removed outside ComfyUI.", button "Refresh") closes the surface: it calls the + legacy gallery's `/gallery/rescan` and toasts "Files refreshed. N files in M output + folders." or "Files could not be refreshed. Try again.". The concept's "sample path" + note is prototype copy and is dropped. +- **D113** (Sources): the Worker threads range ends at the server's `cpu_count` (the + concept's fixed `max=16`) and shows the server's real `worker_threads` (the concept's + sample 4). +- **D-NEW-S4-1** (Sources): the concept's `add-source` `simpleInput` with production + copy: the field is "Folder path" (the concept's "(sample only)" dropped), it starts + empty (not the demo "/images/new-collection"), and the action is "Add" (not "Apply in + prototype"). A folder the server refuses keeps the dialog open with the field + `aria-invalid` and the server's reason in a `p.small[role=alert]` above the actions; + a folder the server names as one already listed says "This folder is already a + source.", and a check that cannot finish says "The folder could not be checked. Try + again."; neither adds anything. + The concept dialog does no check, so `invalid source shows the server reason` + (workspace-system-sources.spec) pins it. - **D124** (inspector): with "Load metadata on selection" off, the Images panel and the viewer's details show "Metadata loads when you ask for it." and a "Load metadata" button instead of reading the file. The concept has no such state, so no concept @@ -1995,9 +2053,16 @@ renames a metric label, swaps the `--line` token, recolours a chip, renames a ch removes the totals table and renames a caption. `appearance matches the concept` maps only D97 and the real timeout (unmapped, exactly those paths differ), and `appearance comparison detects mutations` removes a row, renames a label and an option and swaps -the `--line` token. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`, +the `--line` token. `sources matches the concept with|without the notice` (both +themes and sizes; the fixture's monitor watches the first source, as the concept's +first sample is watched, and the concept's Later removes its notice) maps the real +sources for the two samples, D98's Remove and Refresh files, and D113; unmapped, +exactly those paths differ. `sources comparison detects mutations` removes the notice +and a source row, renames a label and the Add source button and swaps the `--line` +token. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`, `{concept,production}-{statistics,health}-{dark,light}.png`, -`{concept,production}-appearance-{dark,light}-{1600,390}.png`) are test artifacts; there +`{concept,production}-appearance-{dark,light}-{1600,390}.png`, +`{concept,production}-sources-{with,without}-notice-{dark,light}-{1600,390}.png`) are test artifacts; there are no PNG baselines. `tests/browser/workspace-system.spec.mjs` covers the real counts, the health check and metric 4, a failed check, the thumbnail row against the status endpoint (painted after the rest when its read is held, unavailable when it fails), the review row (Q6), the section links, the Statistics dialog against the @@ -2006,9 +2071,14 @@ visits versus the current view and a saved view, the embedded gallery keeping Gr metadata off and on request, the cache (counted up to a request barrier), hidden paths, the video defaults, the host fields with their revision, a stale one and a failed read, reload and a second tab, a second tab's save while System was closed, storage refused, -the D97 rows and reset. Its hooks are +the D97 rows and reset. `tests/browser/workspace-system-sources.spec.mjs` covers the +notice's Later and Review scan, a scoped scan from one source into Activity, Add source +with its server check and Save, a refused folder, Remove down to auto-detect, an absent +configured source, the three watch states, the monitoring and worker rows, Refresh +files, the thumbnail hand-off, a save refused by a running scan and a failed read. Its hooks are `tests/browser/workspace_system_fixture.py` (`/__test__/system/*`: reset, injected -failures, an absent source, a deleted linked file, unthumbnailed files) on the +failures, an absent source, a deleted linked file, unthumbnailed files, a fake image +monitor) on the concept-shaped fixture, with delays through the shared request holds; reset also restores the server's host settings and its `config.json`, which must be the one inside the server's temp root (`tests/test_workspace_system_fixture.py`). @@ -2019,8 +2089,11 @@ python3 -m unittest tests.test_database_health tests.test_system_api tests.test_ node --test tests/js/workspace-system-state.test.js tests/js/workspace-system-controls.test.js \ tests/js/workspace-system-overview.test.js tests/js/workspace-state.test.js \ tests/js/workspace-system-appearance.test.js tests/js/workspace-preferences.test.js \ - tests/js/workspace-inspection-state.test.js tests/js/workspace-images-state.test.js + tests/js/workspace-inspection-state.test.js tests/js/workspace-images-state.test.js \ + tests/js/workspace-system-sources.test.js +python3 -m unittest tests.test_settings_sources tests.test_scan_scope -v npm run --prefix tools/vendor test:workflows -- system-concept.spec.mjs workspace-system.spec.mjs \ + workspace-system-sources.spec.mjs \ --project chromium --project touch --project firefox --project webkit ``` diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index 761134b9..1b21809c 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -533,3 +533,156 @@ test('appearance comparison detects mutations', async ({ page, request }) => { await undoToken(); expect(diffInventory(expected, await measure())).toEqual([]); }); + +/** + * Runs in the page: Sources & scans (app.js 679–681): the optional notice, then the "Image sources" surface with its + * panel heading, the source list, the two setting rows and the operations, with the concept's geometry and tokens. + */ +function measureSources(mainSelector) { + const layout = document.querySelector(`${mainSelector} .settings-layout`); + const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null; + const name = node => [node.localName, ...[...node.classList].sort()].join('.'); + const text = node => node?.textContent.trim() ?? null; + const button = node => ({ text: text(node), className: node.className, + ...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') }); + const operation = row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')), + description: text(row.querySelector('p')), actions: [...row.querySelectorAll('.row-actions > button')].map(button) }); + const notice = layout.querySelector(':scope > .notice'); + const surface = layout.querySelector(':scope > section.surface'); + const heading = surface.querySelector(':scope > .panel-heading'); + const list = surface.querySelector(':scope > #source-list'); + const control = row => { + const toggle = row.querySelector(':scope > * input[type="checkbox"]'); + if (toggle) return { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked, + ...css(toggle.parentElement, 'width', 'height') }; + const input = row.querySelector(':scope > input'); + return { kind: `input.${input.type}`, id: input.id, min: input.min, max: input.max, value: input.value, + ...css(input, 'width', 'accentColor') }; + }; + const rows = [...surface.querySelectorAll(':scope > .setting-row')]; + return { + layout: [...layout.children].map(name), + notice: notice && { tree: [...notice.children].map(name), + text: [...notice.querySelectorAll(':scope > div > *')].map(node => [node.localName, text(node)]), + actions: [...notice.querySelectorAll(':scope > .row-actions > button')].map(button), + ...css(notice, 'display', 'alignItems', 'columnGap', 'borderTopColor', 'backgroundColor', 'paddingTop', 'marginBottom') }, + surface: { tree: [...surface.children].map(name), intro: text(surface.querySelector(':scope > p')), + ...css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius') }, + heading: { tree: [...heading.children].map(name), title: text(heading.querySelector('h2')), + action: button(heading.querySelector('button')), ...css(heading, 'display', 'alignItems', 'justifyContent', 'marginBottom'), + h2: css(heading.querySelector('h2'), 'fontSize', 'fontWeight', 'color') }, + sources: [...list.children].map(operation), + rows: rows.map(row => ({ tree: [...row.children].map(name), text: [...row.firstElementChild.children].map(node => [node.localName, text(node), + node.localName === 'label' ? node.htmlFor : null]), control: control(row) })), + operations: [...surface.querySelectorAll(':scope > .operation')].map(operation), + operation: css(list.querySelector('.operation'), 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', + 'paddingBottom', 'borderTopWidth', 'borderTopColor'), + h3: css(list.querySelector('.operation h3'), 'fontSize', 'fontWeight', 'color'), + p: css(list.querySelector('.operation p'), 'fontSize', 'color', 'marginTop'), + row: css(rows[0], 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopColor'), + pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth, + }; +} + +const WATCH_TEXT = { watching: 'watching', manual: 'manual scan', restart_pending: 'restart pending' }; +// D98: the operation Sources adds after the concept's own, as production renders it. +const REFRESH_FILES = { title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.', + label: 'Refresh' }; + +/** The concept's Sources with the recorded deviations applied, given the server's real settings and sources. */ +function mapSourcesConcept(reference, { settings, sources }) { + const [sample] = reference.sources; + const scan = sample.actions[0]; + return { + ...reference, + // The real sources replace the two samples (without the "sample path" note). D98: each configured one has Remove, + // a plain button after its scan. + sources: sources.effective.map(source => ({ ...sample, title: source.label, + description: `${source.path} · ${WATCH_TEXT[source.watch]}`, + actions: [{ ...scan, text: source.watch === 'watching' ? 'Scan changes' : 'Scan folder' }, { ...scan, text: 'Remove' }] })), + rows: reference.rows.map(row => { + if (row.control.id === 'monitoring') return { ...row, control: { ...row.control, checked: settings.monitoring_enabled } }; + // D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count. + return { ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }; + }), + // D98: "Refresh files" closes the surface. + surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.operation'] }, + operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title, + description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }], + }; +} + +async function conceptSources(page, theme, { notice }) { + await openConcept(page, 'system', theme); + await page.locator('#main .section-nav').getByRole('button', { name: 'Sources & scans', exact: true }).click(); + await expect(page.locator('#main .panel-heading > h2')).toHaveText('Image sources'); + if (!notice) await page.locator('#main .notice').getByRole('button', { name: 'Later', exact: true }).click(); + await expect(page.locator('#main .settings-layout > .notice')).toHaveCount(notice ? 1 : 0); + await page.mouse.move(0, 0); + return page.evaluate(measureSources, '#main'); +} + +async function productionSources(page, theme, { notice }) { + // about:blank has no storage; the workspace page reads the dismissal. + if (!notice) await page.addInitScript(() => { if (location.protocol.startsWith('http')) localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); }); + await openProduction(page, 'system/sources', theme); + await expect(page.locator('#workspace-main #source-list .operation')).toHaveCount(2); + await expect(page.locator('#workspace-main #workers')).toBeEnabled(); + await expect(page.locator('#workspace-main .settings-layout > .notice')).toHaveCount(notice ? 1 : 0); + await page.mouse.move(0, 0); + return page.evaluate(measureSources, '#workspace-main'); +} + +/** The fixture's sources as the concept shows its samples: the first one watched, the second not. */ +async function watchedSources(request) { + await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output'] } })).toBeOK(); + return serverJson(request, '/prompt_manager/settings'); +} + +for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) { + const variant = notice ? 'with' : 'without'; + test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width, height }); + const server = await watchedSources(request); + const concept = await conceptSources(page, theme, { notice }); + await page.screenshot({ path: testInfo.outputPath(`concept-sources-${variant}-notice-${theme}-${width}.png`), fullPage: true }); + const production = await productionSources(page, theme, { notice }); + await page.screenshot({ path: testInfo.outputPath(`production-sources-${variant}-notice-${theme}-${width}.png`), fullPage: true }); + expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]); + expect(production.pageScroll).toBe(false); + }); +} + +test('sources comparison detects mutations', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const server = await watchedSources(request); + const concept = await conceptSources(page, 'dark', { notice: true }); + const expected = mapSourcesConcept(concept, server); + const production = await productionSources(page, 'dark', { notice: true }); + expect(diffInventory(expected, production)).toEqual([]); + // Unmapped, the concept differs only in the real sources, the D98 operation and the D113 range. + expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.')) + .filter((path, index, all) => all.indexOf(path) === index).sort()) + .toEqual(['operations.2', 'operations.length', 'rows.1', 'sources.0', 'sources.1', 'surface.tree'].sort()); + const measure = () => page.evaluate(measureSources, '#workspace-main'); + const paths = async () => diffInventory(expected, await measure()).map(item => item.path); + // A removed notice and a removed source row. + const undoNotice = await mutate(page, { remove: ['#workspace-main .settings-layout > .notice'] }); + expect(await paths()).toEqual(expect.arrayContaining(['layout.length', 'notice.tree.length'])); + await undoNotice(); + const undoRow = await mutate(page, { remove: ['#workspace-main #source-list > .operation:nth-child(2)'] }); + expect(await paths()).toEqual(expect.arrayContaining(['sources.length'])); + await undoRow(); + // A changed label and a changed button. + await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for outputs'; }); + expect(await paths()).toEqual(['rows.0.text.0.1']); + await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for new outputs'; }); + await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = 'Add source'; }); + expect(await paths()).toEqual(['heading.action.text']); + await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = '+ Add source'; }); + // A swapped token. + const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' }); + expect(await paths()).toEqual(expect.arrayContaining(['operation.borderTopColor'])); + await undoToken(); + expect(diffInventory(expected, await measure())).toEqual([]); +}); diff --git a/tests/browser/workspace-system-sources.spec.mjs b/tests/browser/workspace-system-sources.spec.mjs new file mode 100644 index 00000000..d7b3a9fb --- /dev/null +++ b/tests/browser/workspace-system-sources.spec.mjs @@ -0,0 +1,337 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; +import { seedConcept } from './concept-inventory.mjs'; + +// System › Sources & scans (#249 System S4 Task 11) on the concept-shaped real fixture, whose two sources are this +// server's `output` and `input` folders: the source list, monitoring and worker rows read GET /settings (S4 Task 9), +// Add/Remove edit the host draft behind the one Save row, and every scan starts the session job through the shared +// "Review metadata scan" (D55), scoped to one source from its row (S4 Task 10). The hooks are workspace_system_fixture.py's. +const workspace = '/prompt_manager/workspace'; +const SETTINGS_URL = '/prompt_manager/settings'; +const NOTICE_KEY = 'pm_update_notice_dismissed'; +const OUTSIDE_REASON = 'Gallery root must be inside a ComfyUI directory (output, input, temp, user) or one listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS'; + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + await seedConcept(request); + await expect(await request.post('/__test__/system/reset')).toBeOK(); + await expect(await request.post('/__test__/jobs/restart')).toBeOK(); + page.systemErrors = []; + page.on('pageerror', error => page.systemErrors.push(error.message)); +}); + +test.afterEach(async ({ page, request, browserName }) => { + await saveCoverage(page, browserName); + await request.post('/__test__/release'); + await request.post('/__test__/jobs/restart'); + await request.post('/__test__/system/reset'); + expect(page.systemErrors).toEqual([]); +}); + +const toast = page => page.locator('#workspace-toast'); +const layoutOf = page => page.locator('#workspace-main .settings-layout'); +const sourceList = page => layoutOf(page).locator('#source-list'); +const statusOf = page => layoutOf(page).locator('#settings-status'); +const saveButton = page => layoutOf(page).getByRole('button', { name: 'Save preferences', exact: true }); +const dialog = page => page.locator('dialog#modal[open]'); + +async function serverJson(request, url) { + const response = await request.get(url); + await expect(response).toBeOK(); + return response.json(); +} + +/** Open Sources with the update notice dismissed (or shown, `notice: true`) once the source list has loaded. */ +async function openSources(page, { notice = false } = {}) { + if (!notice) await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY); + await page.goto(`${workspace}#/system/sources`); + await expect(sourceList(page).locator('.operation').first()).toBeVisible(); + await expect(layoutOf(page).locator('#workers')).toBeEnabled(); + return layoutOf(page); +} + +/** The source list's rows: `[h3, p, [buttons]]`, then its note line when there is one. */ +function rowsOf(page) { + return sourceList(page).evaluate(list => [...list.children].map(row => row.matches('.operation') + ? [row.querySelector('h3').textContent, row.querySelector('p').textContent, + [...row.querySelectorAll('.row-actions > button')].map(button => button.textContent)] + : [row.localName, row.className, row.textContent])); +} + +const sourceRow = (page, title) => sourceList(page).locator('.operation').filter({ has: page.getByRole('heading', { name: title, exact: true }) }); + +test('notice Later persists the 3.2.0 dismissal (pm_update_notice_dismissed) without scanning', async ({ page, request }) => { + const writes = []; + page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); }); + const layout = await openSources(page, { notice: true }); + const notice = layout.locator(':scope > .notice'); + await expect(notice.locator('h2')).toHaveText('Bring older images into your prompt history'); + await expect(notice.locator('p')).toHaveText('The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.'); + await expect(notice.locator('.row-actions > button')).toHaveText(['Later', 'Review scan']); + await notice.getByRole('button', { name: 'Later', exact: true }).click(); + await expect(layout.locator(':scope > .notice')).toHaveCount(0); + expect(await page.evaluate(key => localStorage.getItem(key), NOTICE_KEY)).toBe('3.2.0'); + await expect(dialog(page)).toHaveCount(0); + // The dismissal is the legacy admin page's own, so neither page shows the notice again. + await page.reload(); + await expect(sourceList(page).locator('.operation').first()).toBeVisible(); + await expect(layout.locator(':scope > .notice')).toHaveCount(0); + expect(writes).toEqual([]); + expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]); +}); + +test('Review scan opens the shared Review metadata scan with all sources', async ({ page, request }) => { + const layout = await openSources(page, { notice: true }); + // The notice's Review scan dismisses it, as the legacy page's does, and reviews every source. + await layout.locator(':scope > .notice').getByRole('button', { name: 'Review scan', exact: true }).click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan'); + await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']); + expect(await page.evaluate(key => localStorage.getItem(key), NOTICE_KEY)).toBe('3.2.0'); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(layout.locator(':scope > .notice')).toHaveCount(0); + // Enrich existing metadata is the same review. + const enrich = layout.locator('.operation').filter({ hasText: 'Enrich existing metadata' }); + await expect(enrich.locator('p')).toHaveText('Resolve both prompts, deduplicate recipes, preserve uncertain links.'); + await enrich.getByRole('button', { name: 'Review scan', exact: true }).click(); + await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]); +}); + +test('Scan changes opens it with that one source ("1 output folder") and starts a scoped job that opens in Activity', async ({ page, request }) => { + await expect(await request.post('/__test__/system/monitor', { data: { watching: ['input'] } })).toBeOK(); + const bodies = []; + page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/jobs/scan')) bodies.push(item.postDataJSON()); }); + await openSources(page); + const input = sourceRow(page, 'ComfyUI input'); + await expect(input.locator('p')).toHaveText('input · watching'); + await input.getByRole('button', { name: 'Scan changes', exact: true }).click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan'); + await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['1 output folder', 'New and unresolved files']); + await dialog(page).getByRole('button', { name: 'Start scan', exact: true }).click(); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + // The toast says it started (or, for a scan this quick, that it already finished); Activity shows its progress. + await expect(toast(page)).toHaveText(/^Scan (started\.|finished · )/); + expect(bodies).toEqual([{ scope: 'new', roots: [1] }]); + const id = page.url().split('/').pop(); + // The job walks only that source (the count is recorded once the walk has listed it). + await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.scope) + .toEqual({ files: 'new', roots: [1], output_folders: 1 }); + await expect(page.locator('#workspace-main h1')).toHaveText('Scan for metadata'); +}); + +test('Add source validates on the server, then marks the host draft dirty; Save persists', async ({ page, request }) => { + // A folder inside ComfyUI's output folder that is not a source yet. + await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 1 } })).toBeOK(); + const layout = await openSources(page); + expect(await rowsOf(page)).toEqual([ + ['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']], + ['ComfyUI input', 'input · manual scan', ['Scan folder', 'Remove']], + ]); + await expect(layout.locator('.panel-heading > h2')).toHaveText('Image sources'); + const add = layout.locator('.panel-heading').getByRole('button', { name: '+ Add source', exact: true }); + await add.click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Add image source'); + await expect(dialog(page).locator('label[for="action-input"]')).toHaveText('Folder path'); + await expect(dialog(page).locator('#action-input')).toHaveValue(''); + await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Cancel', 'Add']); + await dialog(page).locator('#action-input').fill('output/unthumbnailed'); + const validated = page.waitForResponse(response => response.url().endsWith('/prompt_manager/settings/validate-source')); + await dialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + expect(await (await validated).json()).toEqual({ success: true, valid: true, label: 'unthumbnailed', path: 'output/unthumbnailed', code: null, reason: null }); + await expect(dialog(page)).toHaveCount(0); + await expect(add).toBeFocused(); + // Validation wrote nothing: the draft is dirty and the server still has two sources. + expect(await rowsOf(page)).toEqual([ + ['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']], + ['ComfyUI input', 'input · manual scan', ['Scan folder', 'Remove']], + ['unthumbnailed', 'output/unthumbnailed · not saved yet', ['Remove']], + ]); + await expect(statusOf(page)).toHaveText('Unsaved changes.'); + expect((await serverJson(request, SETTINGS_URL)).sources.effective).toHaveLength(2); + const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL)); + await saveButton(page).click(); + const body = (await saved).postDataJSON(); + expect(body.gallery_root_paths).toEqual(['output', 'input', 'output/unthumbnailed']); + expect(typeof body.expected_revision).toBe('string'); + await expect(statusOf(page)).toHaveText('Saved to the server configuration.'); + // The list is the server's again: three effective sources, each scannable. + await expect(sourceRow(page, 'unthumbnailed').locator('p')).toHaveText('output/unthumbnailed · manual scan'); + await expect(sourceRow(page, 'unthumbnailed').getByRole('button', { name: 'Scan folder', exact: true })).toBeVisible(); + expect((await serverJson(request, SETTINGS_URL)).sources.effective.map(source => source.path)).toEqual(['output', 'input', 'output/unthumbnailed']); +}); + +test('invalid source shows the server reason', async ({ page, request }) => { + const layout = await openSources(page); + await layout.getByRole('button', { name: '+ Add source', exact: true }).click(); + const field = dialog(page).locator('#action-input'); + for (const [path, reason] of [ + ['output/not-there', 'Gallery root does not exist'], + ['/etc', OUTSIDE_REASON], + ]) { + await field.fill(path); + await dialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText(reason); + await expect(field).toHaveAttribute('aria-invalid', 'true'); + await expect(field).toHaveValue(path); + } + // A folder already listed (the server's answer names it) is not added twice. + await field.fill('input'); + await dialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText('This folder is already a source.'); + // A check that cannot finish says so and adds nothing. + await page.route('**/prompt_manager/settings/validate-source', route => route.fulfill({ status: 500, + json: { success: false, code: null, error: 'Failed to check the source' } })); + await field.fill('output/not-checked'); + await dialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText('The folder could not be checked. Try again.'); + await page.unroute('**/prompt_manager/settings/validate-source'); + // Typing again clears the reason. + await field.fill('output/unthumbnailed'); + await expect(dialog(page).locator('p.small[role="alert"]')).toHaveCount(0); + await expect(field).not.toHaveAttribute('aria-invalid', 'true'); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(statusOf(page)).toHaveText('No unsaved changes.'); + expect((await serverJson(request, SETTINGS_URL)).sources.configured).toHaveLength(2); +}); + +test('Remove marks the draft; removing the last entry states auto-detect', async ({ page, request }) => { + await openSources(page); + await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click(); + expect(await rowsOf(page)).toEqual([['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']]]); + await expect(statusOf(page)).toHaveText('Unsaved changes.'); + await sourceRow(page, 'ComfyUI output').getByRole('button', { name: 'Remove', exact: true }).click(); + expect(await rowsOf(page)).toEqual([['p', 'small', 'No source is configured. After saving, ComfyUI’s output folder is used.']]); + await saveButton(page).click(); + await expect(statusOf(page)).toHaveText('Saved to the server configuration.'); + // Saved: ComfyUI's output folder is detected and listed without Remove. + await expect.poll(rowsOf.bind(null, page)).toEqual([ + ['ComfyUI output', 'output · manual scan', ['Scan folder']], + ['p', 'small', 'No source is configured; ComfyUI’s output folder is used.'], + ]); + const { sources } = await serverJson(request, SETTINGS_URL); + expect([sources.configured, sources.auto_detected]).toEqual([[], true]); +}); + +test('a configured source that does not exist says why and can be removed', async ({ page, request }) => { + await expect(await request.post('/__test__/system/missing-source')).toBeOK(); + await openSources(page); + // The fixture's unmounted drive is not inside a ComfyUI folder, so the server says that (it never probes outside). + expect((await rowsOf(page)).at(-1)).toEqual(['unmounted-drive', `unmounted-drive · ${OUTSIDE_REASON}`, ['Remove']]); + await sourceRow(page, 'unmounted-drive').getByRole('button', { name: 'Remove', exact: true }).click(); + await expect(sourceList(page).locator('.operation')).toHaveCount(2); + await saveButton(page).click(); + await expect(statusOf(page)).toHaveText('Saved to the server configuration.'); + expect((await serverJson(request, SETTINGS_URL)).sources.configured.map(source => source.path)).toEqual(['output', 'input']); +}); + +test('watching only with a live observer; restart pending otherwise', async ({ page, request }) => { + // No image monitor in this process: every source is scanned by hand. + await openSources(page); + await expect(sourceList(page).locator('.operation p')).toHaveText(['output · manual scan', 'input · manual scan']); + // A live observer that scheduled output: output is watched; input was added since it started. + await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output'] } })).toBeOK(); + await page.reload(); + await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · restart pending']); + await expect(sourceList(page).locator('.operation .row-actions > button:first-child')).toHaveText(['Scan changes', 'Scan folder']); + // An observer that died watches nothing. + await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'], alive: false } })).toBeOK(); + await page.reload(); + await expect(sourceList(page).locator('.operation p')).toHaveText(['output · restart pending', 'input · restart pending']); +}); + +test('Watch for new outputs saves monitoring_enabled; a restart applies it', async ({ page, request }) => { + await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'] } })).toBeOK(); + const layout = await openSources(page); + const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true }); + await expect(monitoring).toBeChecked(); + await expect(layout.locator('.setting-row').filter({ has: page.locator('#monitoring') }).locator('p')) + .toHaveText('New files enter the same library. Existing scans do not count as new runs.'); + await monitoring.uncheck(); + await expect(statusOf(page)).toHaveText('Unsaved changes.'); + const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL)); + await saveButton(page).click(); + const answer = await (await saved).json(); + expect(answer.restart_required_fields).toEqual(['monitoring_enabled']); + await expect(statusOf(page)).toHaveText('Saved to the server configuration.'); + // Saved for the next start: the running observer keeps watching until ComfyUI restarts, and the list says so. + const { settings, monitor } = await serverJson(request, SETTINGS_URL); + expect([settings.monitoring_enabled, monitor.observer_alive]).toEqual([false, true]); + await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · watching']); +}); + +test('worker range max is cpu_count', async ({ page, request }) => { + const { settings } = await serverJson(request, SETTINGS_URL); + const layout = await openSources(page); + const workers = layout.locator('#workers'); + await expect(layout.locator('label[for="workers"]')).toHaveText('Worker threads'); + await expect(workers).toHaveAttribute('type', 'range'); + await expect(workers).toHaveAttribute('min', '1'); + await expect(workers).toHaveAttribute('max', String(settings.cpu_count)); + await expect(workers).toHaveValue(String(settings.worker_threads)); + const next = settings.worker_threads === 1 ? settings.cpu_count : 1; + await workers.fill(String(next)); + await expect(statusOf(page)).toHaveText(next === settings.worker_threads ? 'No unsaved changes.' : 'Unsaved changes.'); + test.skip(next === settings.worker_threads, 'One core: the range has a single value'); + const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL)); + await saveButton(page).click(); + expect((await saved).postDataJSON().worker_threads).toBe(next); + await expect(statusOf(page)).toHaveText('Saved to the server configuration.'); + expect((await serverJson(request, SETTINGS_URL)).settings.worker_threads).toBe(next); +}); + +test('Refresh files calls /gallery/rescan and toasts its counts', async ({ page }) => { + const layout = await openSources(page); + const refresh = layout.locator('.operation').filter({ has: page.getByRole('heading', { name: 'Refresh files', exact: true }) }); + await expect(refresh.locator('p')).toHaveText('Read every source again to find files added or removed outside ComfyUI.'); + const answered = page.waitForResponse(response => response.url().endsWith('/prompt_manager/gallery/rescan')); + await refresh.getByRole('button', { name: 'Refresh', exact: true }).click(); + const { total, roots } = await (await answered).json(); + expect(roots).toBe(2); + await expect(toast(page)).toHaveText(`Files refreshed. ${total.toLocaleString('en-US')} files in 2 output folders.`); + // A failed refresh says so. + await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({ status: 500, json: { success: false, error: 'busy disk' } })); + await refresh.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(toast(page)).toHaveText('Files could not be refreshed. Try again.'); +}); + +test('Thumbnail generation hands off to the existing gallery until its review dialog', async ({ page }) => { + const layout = await openSources(page); + const thumbnails = layout.locator('.operation').filter({ has: page.getByRole('heading', { name: 'Thumbnail generation', exact: true }) }); + await expect(thumbnails.locator('p')).toHaveText('Generate missing previews; rebuilding all is a separate explicit option.'); + const popup = page.waitForEvent('popup'); + await thumbnails.getByRole('button', { name: 'Review options', exact: true }).click(); + const gallery = await popup; + expect(new URL(gallery.url()).pathname).toBe('/prompt_manager/gallery'); + await gallery.close(); + await expect(statusOf(page)).toHaveText('No unsaved changes.'); +}); + +test('a save refused by a running scan shows the busy link toast', async ({ page, request }) => { + const running = await (await request.post('/__test__/jobs/start', { data: { title: 'Scan for metadata' } })).json(); + await openSources(page); + await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click(); + await saveButton(page).click(); + await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.'); + await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${running.job.id}`); + await expect(statusOf(page)).toHaveText('Some changes were not saved. Review the highlighted settings.'); + // The draft is kept for the next Save, and the server still has both sources. + await expect(sourceList(page).locator('.operation')).toHaveCount(1); + expect((await serverJson(request, SETTINGS_URL)).sources.configured).toHaveLength(2); + await request.post(`/__test__/jobs/${running.job.id}/finish`, { data: { state: 'finished' } }); +}); + +test('a source list that cannot be read says so and retries', async ({ page }) => { + await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY); + let failing = true; + await page.route(`**${SETTINGS_URL}`, route => (failing && route.request().method() === 'GET' + ? route.fulfill({ status: 500, json: { success: false, error: 'Injected failure' } }) : route.continue())); + await page.goto(`${workspace}#/system/sources`); + await expect(sourceList(page).getByRole('alert')).toHaveText('Sources could not be loaded. Your saved data has not changed.'); + await expect(sourceList(page).locator('.operation')).toHaveCount(0); + // The server settings rows say so too, and stay disabled. + await expect(layoutOf(page).locator('#workers')).toBeDisabled(); + failing = false; + await sourceList(page).getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(sourceList(page).locator('.operation')).toHaveCount(2); +}); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index ca2da6e7..a11cfdfa 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -237,10 +237,11 @@ test('out-of-order overview responses never repaint a newer section', async ({ p await nav.getByRole('button', { name: 'Appearance', exact: true }).click(); await nav.getByRole('button', { name: 'Overview', exact: true }).click(); await expect.poll(held).toBe(2); - await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click(); + // Integrations still hands off (S10), so its body is one known panel. + await nav.getByRole('button', { name: 'Integrations', exact: true }).click(); await expect(await request.post('/__test__/release')).toBeOK(); await expect.poll(held).toBe(0); - // Both late answers reached a section that had left: Sources keeps its own body. + // Both late answers reached a section that had left: Integrations keeps its own body. await expect(layout.locator(':scope > section.handoff')).toBeVisible(); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await expect(layout.locator('.summary-metrics')).toHaveCount(0); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 8d847a33..79313a3f 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -425,8 +425,7 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance', 'Backup & export', 'Diagnostics & logs']; const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics']; -const SYSTEM_HANDOFFS = { sources: ['/prompt_manager/admin', '/prompt_manager/gallery'], - maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] }; +const SYSTEM_HANDOFFS = { maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] }; test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => { const mutations = []; @@ -462,14 +461,17 @@ test('System is native: the concept shell, section navigation, sections, hand-of await expect(nav.locator('[aria-pressed="true"]')).toHaveCount(1); // The section changes in place: focus stays on the pressed section, not the page title. await expect(button).toBeFocused(); - // S2 Task 6: Overview is native (workspace-system.spec.mjs), and so is Appearance (S3 Task 8); Maintenance shows + // S2 Task 6: Overview is native (workspace-system.spec.mjs), and so are Appearance (S3 Task 8) and Sources & scans + // (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser); Maintenance shows // its health-check notice above the hand-off (D123); every other section hands off. if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible(); + if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2); expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`))) .toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'div.save-row'], + sources: ['div.notice', 'section.surface', 'div.save-row'], maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash] ?? ['section.surface handoff', 'div.save-row']); - if (hash === 'overview' || hash === 'appearance') continue; + if (['overview', 'appearance', 'sources'].includes(hash)) continue; const handoff = layout.locator('section.handoff'); await expect(handoff.locator('h2')).toHaveText('Your current tools are ready'); expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href')))) diff --git a/tests/browser/workspace_system_fixture.py b/tests/browser/workspace_system_fixture.py index 2413c1f0..fae432d4 100644 --- a/tests/browser/workspace_system_fixture.py +++ b/tests/browser/workspace_system_fixture.py @@ -15,7 +15,11 @@ read. These per-server hooks only change what a System test needs on top: - ``POST /__test__/system/missing-file`` deletes the newest linked image's file from disk, leaving its database row (a missing-file link); - ``POST /__test__/system/unthumbnailed`` ``{count}`` writes ``count`` plain - PNGs with no thumbnail into the output folder. + PNGs with no thumbnail into the output folder; +- ``POST /__test__/system/monitor`` ``{watching, alive}`` stands in a fake + image monitor whose observer is ``alive`` (default true) and schedules the + named folders of this server's root (``["output"]``), so Sources reads + watching, manual scan and restart pending states (S4). Delays use the shared request holds (``request_hold.py``). """ @@ -45,13 +49,17 @@ def register_system_commands(app, routes, api, root: Path): f"PROMPT_MANAGER_CONFIG_PATH must be {config_path}, not {configured_path!r}" ) from py.config import GalleryConfig, PromptManagerConfig + from utils import image_monitor configured = list(GalleryConfig.MONITORING_DIRECTORIES) config_bytes = config_path.read_bytes() if config_path.exists() else None host_values = ( PromptManagerConfig.RESULT_TIMEOUT, PromptManagerConfig.WEBUI_DISPLAY_MODE, + PromptManagerConfig.WORKER_THREADS, + GalleryConfig.MONITORING_ENABLED, ) + monitor = getattr(image_monitor, "_monitor_instance", None) failures: dict[str, int] = {} @web.middleware @@ -70,9 +78,13 @@ def register_system_commands(app, routes, api, root: Path): failures.clear() GalleryConfig.MONITORING_DIRECTORIES = list(configured) api._health_report = None - PromptManagerConfig.RESULT_TIMEOUT, PromptManagerConfig.WEBUI_DISPLAY_MODE = ( - host_values - ) + ( + PromptManagerConfig.RESULT_TIMEOUT, + PromptManagerConfig.WEBUI_DISPLAY_MODE, + PromptManagerConfig.WORKER_THREADS, + GalleryConfig.MONITORING_ENABLED, + ) = host_values + image_monitor._monitor_instance = monitor if config_bytes is None: config_path.unlink(missing_ok=True) else: @@ -141,4 +153,35 @@ def register_system_commands(app, routes, api, root: Path): api.invalidate_gallery_cache() return web.json_response({"success": True}) + @routes.post("/__test__/system/monitor") + async def fake_monitor(request): + data = await request.json() + watching, alive = data.get("watching", []), data.get("alive", True) + if ( + not isinstance(watching, list) + or not all(name in ("output", "input") for name in watching) + or not isinstance(alive, bool) + ): + raise web.HTTPBadRequest() + image_monitor._monitor_instance = _FakeMonitor( + [str(root / name) for name in watching], alive + ) + return web.json_response({"success": True}) + return reset + + +class _FakeMonitor: + """The image monitor's status surface (``get_status``) with no observer thread.""" + + def __init__(self, directories, alive): + self.monitored_directories = directories + self._alive = alive + + def get_status(self): + return { + "running": True, + "monitored_directories": list(self.monitored_directories), + "handler_active": True, + "observer_alive": self._alive, + } diff --git a/tests/js/workspace-system-sources.test.js b/tests/js/workspace-system-sources.test.js new file mode 100644 index 00000000..2485d60f --- /dev/null +++ b/tests/js/workspace-system-sources.test.js @@ -0,0 +1,123 @@ +// System › Sources & scans (#249 System S4 Task 11): the concept's "Image sources" list (app.js 679–681) built from the +// server's configured, effective and watched sources (GET /settings), the host draft of `gallery_root_paths`, the +// 3.2.0 update notice's dismissal and the Refresh files toast (D98). +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const sourcesModule = import('../../web/js/workspace/system-sources.mjs'); + +const STATUS = Object.freeze({ + configured: [ + { label: 'ComfyUI output', path: 'output', valid: true, code: null, reason: null }, + { label: 'ComfyUI input', path: 'input', valid: true, code: null, reason: null }, + ], + effective: [ + { label: 'ComfyUI output', path: 'output', root_index: 0, watch: 'watching' }, + { label: 'ComfyUI input', path: 'input', root_index: 1, watch: 'manual' }, + ], + auto_detected: false, +}); + +test('each effective source is the concept row: label, "<path> · watching|manual scan", Scan changes or Scan folder', async () => { + const { sourceRows } = await sourcesModule; + const saved = ['output', 'input']; + const { rows, note } = sourceRows({ sources: STATUS, saved, current: saved }); + assert.equal(note, null); + assert.deepEqual(rows, [ + { key: 'output', title: 'ComfyUI output', description: 'output · watching', scan: { label: 'Scan changes', root: 0 }, remove: 'output' }, + { key: 'input', title: 'ComfyUI input', description: 'input · manual scan', scan: { label: 'Scan folder', root: 1 }, remove: 'input' }, + ]); +}); + +test('watching only with a live observer; restart pending otherwise (D98)', async () => { + const { sourceRows, WATCH_COPY } = await sourcesModule; + assert.deepEqual(WATCH_COPY, { watching: 'watching', manual: 'manual scan', restart_pending: 'restart pending' }); + const sources = { ...STATUS, effective: STATUS.effective.map(source => ({ ...source, watch: 'restart_pending' })) }; + const { rows } = sourceRows({ sources, saved: ['output', 'input'], current: ['output', 'input'] }); + assert.deepEqual(rows.map(row => [row.description, row.scan.label]), + [['output · restart pending', 'Scan folder'], ['input · restart pending', 'Scan folder']]); +}); + +test('a configured source that cannot be used shows the server reason and only Remove', async () => { + const { sourceRows } = await sourcesModule; + const sources = { ...STATUS, configured: [...STATUS.configured, + { label: 'unmounted-drive', path: 'unmounted-drive', valid: false, code: 'not-found', reason: 'Gallery root does not exist' }] }; + const saved = ['output', 'input', 'unmounted-drive']; + const { rows } = sourceRows({ sources, saved, current: saved }); + assert.deepEqual(rows.at(-1), { key: 'unmounted-drive', title: 'unmounted-drive', + description: 'unmounted-drive · Gallery root does not exist', scan: null, remove: 'unmounted-drive' }); +}); + +test('an added source is listed as not saved yet with its validated label; a removed one leaves the list', async () => { + const { sourceRows } = await sourcesModule; + const labels = new Map([['output/archive', 'archive']]); + const { rows, note } = sourceRows({ sources: STATUS, saved: ['output', 'input'], current: ['input', 'output/archive'], labels }); + assert.equal(note, null); + assert.deepEqual(rows.map(row => [row.title, row.description, row.scan?.label ?? null, row.remove]), [ + ['ComfyUI input', 'input · manual scan', 'Scan folder', 'input'], + ['archive', 'output/archive · not saved yet', null, 'output/archive'], + ]); + // Without a recorded label (a reload of System), the folder name stands in. + assert.equal(sourceRows({ sources: STATUS, saved: [], current: ['output/new-collection'] }).rows[0].title, 'new-collection'); +}); + +test('removing the last entry states auto-detect (D98)', async () => { + const { sourceRows, NOTES } = await sourcesModule; + const { rows, note } = sourceRows({ sources: STATUS, saved: ['output', 'input'], current: [] }); + assert.deepEqual(rows, []); + assert.equal(note, NOTES.removedAll); + assert.equal(NOTES.removedAll, 'No source is configured. After saving, ComfyUI’s output folder is used.'); +}); + +test('auto-detected sources are listed without Remove, with the line that says how they were chosen', async () => { + const { sourceRows, NOTES } = await sourcesModule; + const auto = { configured: [], effective: [{ label: 'ComfyUI output', path: 'output', root_index: 0, watch: 'manual' }], auto_detected: true }; + assert.deepEqual(sourceRows({ sources: auto, saved: [], current: [] }), { rows: [ + { key: 'auto:0', title: 'ComfyUI output', description: 'output · manual scan', scan: { label: 'Scan folder', root: 0 }, remove: null }, + ], note: NOTES.autoDetected }); + // Every configured source unusable: ComfyUI's output folder is used instead. + const fallback = { ...auto, configured: [{ label: 'gone', path: 'gone', valid: false, code: 'not-found', reason: 'Gallery root does not exist' }] }; + const result = sourceRows({ sources: fallback, saved: ['gone'], current: ['gone'] }); + assert.deepEqual(result.rows.map(row => [row.title, row.remove]), [['gone', 'gone'], ['ComfyUI output', null]]); + assert.equal(result.note, NOTES.fallback); + assert.equal(sourceRows({ sources: { configured: [], effective: [], auto_detected: false }, saved: [], current: [] }).note, NOTES.none); +}); + +test('a path listed twice in the draft is one row', async () => { + const { sourceRows } = await sourcesModule; + assert.equal(sourceRows({ sources: STATUS, saved: ['output', 'input'], current: ['output', 'output'] }).rows.length, 1); +}); + +test('roots equal to the saved list are the saved list itself, so the draft is clean again', async () => { + const { draftRoots } = await sourcesModule; + const saved = ['output', 'input']; + assert.equal(draftRoots(['output', 'input'], saved), saved); + assert.deepEqual(draftRoots(['input'], saved), ['input']); + assert.notEqual(draftRoots(['input', 'output'], saved), saved); + assert.deepEqual(draftRoots([], undefined), []); +}); + +test('notice Later persists the 3.2.0 dismissal (pm_update_notice_dismissed); storage that throws keeps it for this view', async () => { + const { noticeDismissed, dismissNotice, NOTICE_KEY, NOTICE_VERSION } = await sourcesModule; + assert.deepEqual([NOTICE_KEY, NOTICE_VERSION], ['pm_update_notice_dismissed', '3.2.0']); + const values = new Map(); + const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)) }; + assert.equal(noticeDismissed(() => storage), false); + values.set(NOTICE_KEY, '3.1.0'); + assert.equal(noticeDismissed(() => storage), false); + assert.equal(dismissNotice(() => storage), true); + assert.equal(values.get(NOTICE_KEY), '3.2.0'); + assert.equal(noticeDismissed(() => storage), true); + const denied = () => { throw new Error('SecurityError'); }; + assert.equal(noticeDismissed(denied), false); + assert.equal(dismissNotice(denied), false); + assert.equal(dismissNotice(() => ({ setItem() { throw new Error('QuotaExceededError'); } })), false); +}); + +test('Refresh files toasts the rescan counts, or says it finished when they are missing', async () => { + const { refreshedText } = await sourcesModule; + assert.equal(refreshedText({ total: 1234, roots: 2 }), 'Files refreshed. 1,234 files in 2 output folders.'); + assert.equal(refreshedText({ total: 1, roots: 1 }), 'Files refreshed. 1 file in 1 output folder.'); + assert.equal(refreshedText({ total: -1, roots: 1 }), 'Files refreshed.'); + assert.equal(refreshedText(null), 'Files refreshed.'); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 177df6b1..75061d57 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -163,7 +163,7 @@ }, "workspace/activity-scan.mjs": { "lines": 96, - "branches": 73, + "branches": 78, "functions": 98, "statements": 96 }, @@ -467,6 +467,12 @@ "functions": 98, "statements": 96 }, + "workspace/system-sources.mjs": { + "lines": 98, + "branches": 88, + "functions": 94, + "statements": 98 + }, "workspace/system-state.mjs": { "lines": 98, "branches": 94, @@ -476,7 +482,7 @@ "workspace/system.mjs": { "lines": 98, "branches": 92, - "functions": 85, + "functions": 86, "statements": 98 }, "workspace/theme.mjs": { diff --git a/web/js/workspace/activity-scan.mjs b/web/js/workspace/activity-scan.mjs index 8ec2c87d..9c86f99a 100644 --- a/web/js/workspace/activity-scan.mjs +++ b/web/js/workspace/activity-scan.mjs @@ -81,11 +81,12 @@ export function busyToast(running) { /** * Start the scan job over `files` ('new': new and unresolved files, or 'all') and open its view; a refused or failed - * start says why and stays on the page. + * start says why and stays on the page. `roots` (System › Sources, S4) limits it to those effective source indexes. */ -export async function startMetadataScan({ store, scope, files = 'new', notify = showWorkspaceToast, navigate = navigateTo }) { +export async function startMetadataScan({ store, scope, files = 'new', roots = null, notify = showWorkspaceToast, + navigate = navigateTo }) { try { - const result = await store.start('scan', { scope: files }); + const result = await store.start('scan', roots ? { scope: files, roots } : { scope: files }); if (scope.signal.aborted) return; if (result.busy !== undefined) { notify(busyToast(result.busy)); @@ -97,9 +98,12 @@ export async function startMetadataScan({ store, scope, files = 'new', notify = } } -/** Open "Review metadata scan" for `sources` output folders. */ -export function openMetadataScan({ scope, store, sources, trigger = null }) { +/** + * Open "Review metadata scan" for `sources` output folders; with `roots` (one source's index, System › Sources) the + * scan reads only those. + */ +export function openMetadataScan({ scope, store, sources, roots = null, trigger = null }) { openScanReviewDialog({ scope, sources, trigger, onBackup: () => downloadDatabaseBackup(), - onStart: ({ scope: files }) => startMetadataScan({ store, scope, files }) }); + onStart: ({ scope: files }) => startMetadataScan({ store, scope, files, roots }) }); } diff --git a/web/js/workspace/system-sources.mjs b/web/js/workspace/system-sources.mjs new file mode 100644 index 00000000..c42996d7 --- /dev/null +++ b/web/js/workspace/system-sources.mjs @@ -0,0 +1,369 @@ +/** + * System › Sources & scans (#249 System S4 Task 11): the concept's `renderSystem` "sources" body (app.js 679–681). + * + * - The optional `.notice` "Bring older images into your prompt history" shows until this browser dismissed the 3.2.0 + * update notice (`pm_update_notice_dismissed`, the legacy admin page's own key). Later dismisses it without scanning; + * Review scan dismisses it, as the legacy page's Scan does, and reviews every source. + * - "Image sources" lists the server's sources (GET /settings, S4 Task 9) in `#source-list`, one `.operation` each: + * its label, "<path> · watching | manual scan | restart pending" and "Scan changes" (watched) or "Scan folder", which + * opens the shared "Review metadata scan" (D55) for that one source ("1 output folder") and starts the session job over + * it (`roots`, S4 Task 10). The job opens in Activity; the toast only says it started. + * - "+ Add source" asks the server whether a folder can be a source (`validate-source`, which writes nothing), then adds + * it to the host draft of `gallery_root_paths`; Remove takes one out. Both wait for the one Save row. + * - "Watch for new outputs" (`monitoring_enabled`) and "Worker threads" (`worker_threads`, a range up to the server's + * `cpu_count`, D113) are host drafts too. "Enrich existing metadata" reviews a scan of every source. + * - "Thumbnail generation → Review options" opens the existing gallery's thumbnail tool until its review dialog (S5), + * the D94 hand-off for the one capability not native yet. + * + * D98: per-source Remove; an added source reads "not saved yet" until Save; the line under the list says when + * ComfyUI's output folder is used instead; "restart pending" for a source the running monitor does not watch yet; the + * "Refresh files" operation (`/gallery/rescan`) closes the surface. D-NEW-S4-1: the add dialog's field is "Folder + * path", starts empty and says the server's reason in place; its action is "Add". + */ +import { createButton } from '../shared/toolkit/core.js'; +import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs'; +import { openMetadataScan } from './activity-scan.mjs'; +import { settingRow, operationRow } from './system-controls.mjs'; +import { HOST_UNAVAILABLE } from './system-appearance.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { noun } from './plural.mjs'; + +const SETTINGS_URL = '/prompt_manager/settings'; +const VALIDATE_URL = '/prompt_manager/settings/validate-source'; +const RESCAN_URL = '/prompt_manager/gallery/rescan'; +const GALLERY_URL = '/prompt_manager/gallery'; +const ROOTS_KEY = 'gallery_root_paths'; + +export const NOTICE_KEY = 'pm_update_notice_dismissed'; +export const NOTICE_VERSION = '3.2.0'; +export const WATCH_COPY = Object.freeze({ watching: 'watching', manual: 'manual scan', restart_pending: 'restart pending' }); +const NOT_SAVED = 'not saved yet'; +/** D98: the line under the source list when it does not say everything by itself. */ +export const NOTES = Object.freeze({ + removedAll: 'No source is configured. After saving, ComfyUI’s output folder is used.', + autoDetected: 'No source is configured; ComfyUI’s output folder is used.', + fallback: 'No configured source is available; ComfyUI’s output folder is used.', + none: 'No source is available.', +}); +export const LOADING_TEXT = 'Loading sources…'; +export const LOAD_FAILED_TEXT = 'Sources could not be loaded. Your saved data has not changed.'; +export const DUPLICATE_TEXT = 'This folder is already a source.'; +export const VALIDATE_FAILED_TEXT = 'The folder could not be checked. Try again.'; +export const REFRESH_FAILED_TEXT = 'Files could not be refreshed. Try again.'; + +// Labels of the sources added in this page session (validate-source answers them), for the rows not saved yet. +const addedLabels = new Map(); + +const defaultStorage = () => window.localStorage; + +/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */ +export function noticeDismissed(getStorage = defaultStorage) { + try { + return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION; + } catch { + return false; + } +} + +/** Dismiss the notice for this browser; false when storage refused (it stays dismissed for this view). */ +export function dismissNotice(getStorage = defaultStorage) { + try { + getStorage().setItem(NOTICE_KEY, NOTICE_VERSION); + return true; + } catch { + return false; + } +} + +const sameRoots = (a, b) => Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((path, index) => path === b[index]); + +/** The roots to draft: the saved list itself when they are equal, so the draft is clean again. */ +export function draftRoots(next, saved) { + return sameRoots(next, saved) ? saved : [...next]; +} + +const folderName = path => path.split('/').filter(Boolean).at(-1) ?? path; + +function effectiveRow(source, remove) { + const watched = source.watch === 'watching'; + return { key: remove ?? `auto:${source.root_index}`, title: source.label, + description: `${source.path} · ${WATCH_COPY[source.watch] ?? WATCH_COPY.manual}`, + scan: { label: watched ? 'Scan changes' : 'Scan folder', root: source.root_index }, remove }; +} + +/** + * The source list for `sources` (GET /settings' `sources`), the saved `gallery_root_paths` and the drafted ones + * (`current`): `{rows: [{key, title, description, scan: {label, root} | null, remove: path | null}], note}`. + */ +export function sourceRows({ sources, saved, current, labels = addedLabels }) { + const configured = sources.configured ?? []; + const effective = sources.effective ?? []; + const savedRoots = saved ?? configured.map(source => source.path); + const roots = current ?? savedRoots; + const dirty = !sameRoots(roots, savedRoots); + const rows = []; + for (const path of new Set(roots)) { + const entry = configured.find(source => source.path === path); + const live = entry?.valid ? effective.find(source => source.path === path) : undefined; + if (live) rows.push(effectiveRow(live, path)); + else if (entry) { + rows.push({ key: path, title: entry.label, description: `${path} · ${entry.reason ?? WATCH_COPY.manual}`, scan: null, remove: path }); + } else rows.push({ key: path, title: labels.get(path) ?? folderName(path), description: `${path} · ${NOT_SAVED}`, scan: null, remove: path }); + } + if (dirty) return { rows, note: roots.length ? null : NOTES.removedAll }; + if (sources.auto_detected) { + rows.push(...effective.map(source => effectiveRow(source, null))); + return { rows, note: configured.length ? NOTES.fallback : NOTES.autoDetected }; + } + return { rows, note: rows.length ? null : NOTES.none }; +} + +/** The Refresh files toast for the rescan's `{total, roots}`. */ +export function refreshedText(result) { + const { total, roots } = result ?? {}; + if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Files refreshed.'; + return `Files refreshed. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`; +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** The concept's `.notice` with Later and Review scan. */ +function noticeNode({ scope, onLater, onReview }) { + const notice = el('div', undefined, 'notice'); + const text = el('div'); + text.append(el('h2', 'Bring older images into your prompt history'), + el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.')); + const actions = el('div', undefined, 'row-actions'); + const later = createButton('Later'); + const review = createButton('Review scan', 'ca-primary'); + scope.listen(later, 'click', onLater); + scope.listen(review, 'click', onReview); + actions.append(later, review); + notice.append(text, actions); + return notice; +} + +/** + * "Add image source" (the concept's `simpleInput`, D-NEW-S4-1): the server checks the folder; a refused one keeps the + * dialog open with the server's reason under the field. + */ +function openAddSource({ api, scope, trigger, listed, onAdd }) { + const input = el('input'); + const reason = el('p', undefined, 'small'); + reason.setAttribute('role', 'alert'); + const cancel = createButton('Cancel'); + const add = createButton('Add', 'ca-primary'); + let open = true; + const refuse = text => { + reason.textContent = text; + if (!reason.isConnected) actions.before(reason); + input.setAttribute('aria-invalid', 'true'); + input.focus(); + }; + input.addEventListener('input', () => { + reason.remove(); + input.removeAttribute('aria-invalid'); + }); + cancel.addEventListener('click', () => closeWorkspaceDialog()); + add.addEventListener('click', async () => { + const path = input.value.trim(); + if (!path) { input.focus(); return; } + if (add.getAttribute('aria-disabled') === 'true') return; + add.setAttribute('aria-disabled', 'true'); + try { + const result = await api.post(VALIDATE_URL, { path }, { signal: scope.signal }); + if (!open) return; + if (!result.valid) refuse(result.reason ?? VALIDATE_FAILED_TEXT); + else if (listed().includes(result.path)) refuse(DUPLICATE_TEXT); + else { + closeWorkspaceDialog(); + onAdd(result); + } + } catch { + if (open) refuse(VALIDATE_FAILED_TEXT); + } finally { + add.removeAttribute('aria-disabled'); + } + }); + const actions = el('div', undefined, 'dialog-actions'); + actions.append(cancel, add); + openContentDialog({ scope, title: 'Add image source', trigger, nodes: [field('Folder path', input, 'action-input'), actions], + onClose: () => { open = false; } }); + input.focus(); +} + +/** The row's control (`data-setting`), wherever the Toolkit switch nests it. */ +const controlOf = (row, id) => row.querySelector(`[data-setting="${id}"]`); + +/** + * Mount Sources in `container` (System's `.settings-layout`); returns the section's nodes. `activity` is the page's + * Activity store (scans start through it), `drafts` the System drafts. + */ +export function mountSources(container, { api, activity, scope, drafts, notify = showWorkspaceToast, storage = defaultStorage, + openWindow = (url) => window.open(url, '_blank', 'noopener'), latestOnly = window.ApiClient.latestOnly, + STALE = window.ApiClient.STALE }) { + const statusRead = latestOnly({ lifetime: scope.signal }); + // undefined while read, null when the read failed, else GET /settings' `sources`. + let sources; + const nodes = []; + const scanAll = trigger => openMetadataScan({ scope, store: activity, trigger, sources: sources?.effective?.length ?? 0 }); + + if (!noticeDismissed(storage)) { + const notice = noticeNode({ scope, + onLater: () => { dismissNotice(storage); notice.remove(); }, + onReview: () => { + dismissNotice(storage); + // The pressed button leaves with the notice, so focus returns to the list's Add source. + notice.remove(); + scanAll(addButton); + } }); + nodes.push(notice); + } + + const surface = el('section', undefined, 'surface'); + const heading = el('div', undefined, 'panel-heading'); + const addButton = createButton('+ Add source'); + heading.append(el('h2', 'Image sources'), addButton); + const list = el('div'); + list.id = 'source-list'; + surface.append(heading, el('p', 'The same folders power browsing, monitoring and metadata enrichment.'), list); + + const roots = () => drafts.get('host', ROOTS_KEY) ?? sources?.configured?.map(source => source.path) ?? []; + const setRoots = next => drafts.set('host', ROOTS_KEY, draftRoots(next, drafts.saved('host')[ROOTS_KEY])); + scope.listen(addButton, 'click', () => openAddSource({ api, scope, trigger: addButton, listed: roots, + onAdd: ({ path, label }) => { + addedLabels.set(path, label); + setRoots([...roots(), path]); + } })); + + const monitoring = settingRow({ id: 'monitoring', label: 'Watch for new outputs', + description: 'New files enter the same library. Existing scans do not count as new runs.', control: { type: 'toggle' }, + scope, onChange: value => drafts.set('host', 'monitoring_enabled', value) }); + const workers = settingRow({ id: 'workers', label: 'Worker threads', + description: 'Balance background scans and thumbnail work against generation.', control: { type: 'range', value: 4 }, + scope, onChange: value => drafts.set('host', 'worker_threads', value) }); + const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [workers, 'workers', 'worker_threads']] + .map(([row, id, key]) => ({ input: controlOf(row, id), key, text: row.children[0], note: null })); + for (const row of hostRows) row.input.dataset.key = row.key; + surface.append(monitoring, workers, + operationRow({ scope, title: 'Enrich existing metadata', description: 'Resolve both prompts, deduplicate recipes, preserve uncertain links.', + label: 'Review scan', onClick: scanAll }), + // D94: the gallery keeps thumbnail generation until its review dialog (S5). + operationRow({ scope, title: 'Thumbnail generation', description: 'Generate missing previews; rebuilding all is a separate explicit option.', + label: 'Review options', onClick: () => openWindow(window.ApiClient.resolveUrl(GALLERY_URL)) }), + // D98: the legacy gallery's Refresh, which rereads every source's file list. + operationRow({ scope, title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.', + label: 'Refresh', onClick: refresh })); + nodes.push(surface); + + async function refresh(button) { + if (button.getAttribute('aria-disabled') === 'true') return; + button.setAttribute('aria-disabled', 'true'); + try { + const result = await api.post(RESCAN_URL, {}, { signal: scope.signal }); + if (!scope.signal.aborted) notify(refreshedText(result)); + } catch { + if (!scope.signal.aborted) notify(REFRESH_FAILED_TEXT); + } finally { + button.removeAttribute('aria-disabled'); + } + } + + function sourceNode(row) { + const actions = []; + if (row.scan) { + const scan = createButton(row.scan.label); + scope.listen(scan, 'click', () => openMetadataScan({ scope, store: activity, trigger: scan, sources: 1, roots: [row.scan.root] })); + actions.push(scan); + } + if (row.remove !== null) { + // D98: a plain button; nothing is removed until Save. + const remove = createButton('Remove'); + scope.listen(remove, 'click', () => setRoots(roots().filter(path => path !== row.remove))); + actions.push(remove); + } + const node = el('div', undefined, 'operation'); + const text = el('div'); + text.append(el('h3', row.title), el('p', row.description)); + const group = el('div', undefined, 'row-actions'); + group.append(...actions); + node.append(text, group); + return node; + } + + function paintList() { + if (sources === undefined) { + const loading = el('p', LOADING_TEXT, 'small'); + loading.setAttribute('role', 'status'); + list.replaceChildren(loading); + return; + } + if (sources === null) { + const alert = el('p', LOAD_FAILED_TEXT); + alert.setAttribute('role', 'alert'); + const retry = createButton('Retry'); + scope.listen(retry, 'click', () => { + load(); + if (drafts.hostState?.() === 'failed') drafts.reloadHost(); + }); + list.replaceChildren(alert, retry); + return; + } + const { rows, note } = sourceRows({ sources, saved: drafts.saved('host')[ROOTS_KEY], current: drafts.get('host', ROOTS_KEY) }); + list.replaceChildren(...rows.map(sourceNode), ...(note ? [el('p', note, 'small')] : [])); + } + + function paintControls() { + const unavailable = drafts.hostState?.() === 'failed'; + for (const row of hostRows) { + const value = drafts.get('host', row.key); + row.input.disabled = value === undefined; + if (row.key === 'worker_threads') { + // D113: the range ends at the server's core count (the concept's fixed 16). + const cpus = drafts.get('host', 'cpu_count'); + if (Number.isSafeInteger(cpus) && cpus >= 1) row.input.max = String(cpus); + if (value !== undefined) row.input.value = String(value); + } else if (value !== undefined) row.input.checked = value === true; + if (unavailable && value === undefined) { + if (!row.note) { row.note = el('p', HOST_UNAVAILABLE, 'small'); row.text.append(row.note); } + } else if (row.note) { row.note.remove(); row.note = null; } + } + } + + async function load() { + sources = undefined; + paintList(); + let answer; + try { + answer = await statusRead(signal => api.get(SETTINGS_URL, { signal, cache: 'no-store' })); + } catch { + answer = null; + } + if (answer === STALE || scope.signal.aborted) return; + sources = answer?.sources ?? null; + paintList(); + } + + // A save (or a reread after a conflict) changed the saved sources or monitoring: read their state again. The first + // host read on mount is not a change. + const savedState = () => { + const saved = drafts.saved('host'); + return saved[ROOTS_KEY] === undefined ? undefined : JSON.stringify([saved[ROOTS_KEY], saved.monitoring_enabled]); + }; + let state = savedState(); + scope.cleanup(drafts.subscribe(() => { + const previous = state; + state = savedState(); + paintControls(); + if (previous !== undefined && state !== previous) load(); + else if (sources) paintList(); + })); + paintControls(); + load(); + return nodes; +} diff --git a/web/js/workspace/system.mjs b/web/js/workspace/system.mjs index 27e1a54a..18a1ae23 100644 --- a/web/js/workspace/system.mjs +++ b/web/js/workspace/system.mjs @@ -4,8 +4,8 @@ * `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) and - * Appearance (system-appearance.mjs) are native. + * 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. @@ -16,6 +16,9 @@ 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'; @@ -27,7 +30,6 @@ const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open ex * settings, thumbnails, duplicates and cache tools. */ export const SECTION_HANDOFFS = Object.freeze({ - sources: [ADMIN, GALLERY], integrations: [ADMIN], tagging: [ADMIN], maintenance: [ADMIN, GALLERY], @@ -37,6 +39,14 @@ export const SECTION_HANDOFFS = Object.freeze({ 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 names the running job from its list. +let jobs = null; + +/** The running job that holds the registry, from a fresh list read (the store's load never throws). */ +async function runningJob() { + await jobs.load(); + return jobs.running()[0]; +} function el(tag, text, className) { const node = document.createElement(tag); @@ -71,7 +81,15 @@ export function systemDrafts(api) { // The store reads a missing value as none and a missing revision as unreadable (no host save). return { values: result.settings, revision: result.revision }; }, - writeHost: (changes, { expected_revision }) => api.post(SETTINGS_URL, { ...changes, expected_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())); + throw error; + } + }, }); return drafts; } @@ -100,12 +118,13 @@ function markFailed(layout, store) { } /** - * The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), and Maintenance's notice with "Run health checks" above the + * 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')], }); @@ -123,6 +142,7 @@ export const DELIVERED_SECTIONS = Object.freeze({ */ 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).