From f43a222ffc04407060ccf75781b2f2cb9d1096db Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 23 Oct 2026 01:52:07 -0700 Subject: [PATCH] feat(system): native Overview with health report and statistics System > Overview renders the concept's metrics and its Library health and Workspace preferences surfaces from GET /system/overview: real counts, "Not checked" until a health check ran, no invented review count, backup or thumbnail status (D93). Explore opens "Usage statistics" with the real runs and ratings per category and the Library totals (D96); the statistics route now also returns rated_images. Maintenance shows the concept's notice with Run health checks above its hand-off: the read-only check runs as a registry read job and opens the "Health report" with a chip per real result, missing files per source (D95). A load failure shows Retry and no zeros; late answers never repaint a newer section. Adds the System concept comparison (shell, Overview, both dialogs, the notice, mutation checks), the System workflow spec and its fixture hooks, and the TESTING.md "Native System" section. --- TESTING.md | 108 ++++++ py/api/system.py | 13 +- tests/browser/server.py | 2 + tests/browser/system-concept.spec.mjs | 365 +++++++++++++++++++ tests/browser/workspace-system.spec.mjs | 225 ++++++++++++ tests/browser/workspace.spec.mjs | 12 +- tests/browser/workspace_system_fixture.py | 116 ++++++ tests/js/workspace-system-overview.test.js | 188 ++++++++++ tests/test_system_api.py | 12 + web/js/workspace/system-overview.mjs | 389 +++++++++++++++++++++ web/js/workspace/system.mjs | 14 +- web/js/workspace/workspace.css | 2 + 12 files changed, 1439 insertions(+), 7 deletions(-) create mode 100644 tests/browser/system-concept.spec.mjs create mode 100644 tests/browser/workspace-system.spec.mjs create mode 100644 tests/browser/workspace_system_fixture.py create mode 100644 tests/js/workspace-system-overview.test.js create mode 100644 web/js/workspace/system-overview.mjs diff --git a/TESTING.md b/TESTING.md index 603f53e7..c556335f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1722,6 +1722,114 @@ npm run --prefix tools/vendor test:workflows -- inspect-concept.spec.mjs workspa npm run --prefix tools/vendor test:comfyui -- inspect-sender.spec.mjs ``` +### Native System (#249 System) + +`#/system/
` is a native workspace route rendered by `web/js/workspace/system.mjs` +on the concept's `renderSystem` markup (app.js a698–706; `concept.css` 709–895). `#/system` +is Overview under the sidebar's own hash; `#/system/overview|appearance|sources| +integrations|tagging|maintenance|backups|diagnostics` select a section, and an unknown +child (the concept's `#/system/general`) is replaced by `#/system/overview`. The +production hub `#/overview` is unchanged (D28); its System card opens +`#/system/overview` (Q12). Switching sections keeps the shell, the drafts and focus on +the pressed section button and disposes the previous section's scope, so its reads, +listeners and dialogs never outlive it. + +Drafts (`system-state.mjs`) live for the page session (Q10). Save writes the dirty +browser fields, then the host fields (`POST /prompt_manager/settings` with +`expected_revision`, #220), then the runtime fields. A failed domain keeps its drafts and +highlights their controls; `settings-changed` is a conflict and keeps the draft. No +delivered section edits a setting yet, so Save stays disabled until Appearance (S3). + +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 +shows "The overview could not be loaded. Your saved data has not changed." and Retry, +never zeros. The health check (`POST /prompt_manager/system/health`) is a `read` +registry job of kind `health`: one at a time, beside a scan, not cancellable and with +no file outcomes. A press while one runs (another tab) waits for that one. The last +report lives for this server session (`GET /prompt_manager/system/health`). Statistics +read `GET /prompt_manager/system/statistics` (`by_category`, the legacy `/stats` totals +and `rated_images`). + +Recorded deviations (plan D60–D64 renumbered D92–D96; D121 and D122 assigned): + +- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No + unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server + configuration." / "Applied until the server restarts.", "Some changes were not saved. + Review the highlighted settings.", "Saved for this tab only; browser storage is + unavailable." and "Settings changed elsewhere. Reload this section to see them." + "Save preferences" is `aria-disabled` (opacity .55, `not-allowed`) while clean or + saving; a setting the last save could not save gets a red outline. CSS under D92. +- **D93** (Overview): real values. The metrics are the Library's eligible positives + and active saved negatives and the indexed `generated_images` rows (Q2). "Database + integrity" is "Not checked" until a check ran this session, then "N issues" (checks + that found issues, even beside a check that could not run), "Passed" or "Incomplete". + "Ambiguous image links" reads "Not available yet." with its Review metadata action + `aria-disabled` (CSS under D93) until the Needs review queue reports a count (Q6). + "Backup status" reads "No retained backup record." until verified backups exist (S8). + The thumbnail row is "No missing thumbnails", "N missing thumbnails" (the pending count + of `/images/thumbnails/status`) or "Thumbnail status unavailable"; Review thumbnails + opens Sources until the thumbnail dialog (S5). "One display configuration" says "Grid + and density settings are shared across views." (no paging). The sources row is + "N output sources" with "Monitoring and enrichment share one source list.", prefixed + by "M of N configured sources available." or "None configured is available; ComfyUI's + output folder is used." when that is the case. +- **D94** (all sections): a section not delivered yet shows the hand-off panel "Your + current tools are ready" with a link to each legacy tool that still owns it (the + plan's legacy coverage table); retired section by section, gone after S12. +- **D95** (health report): the concept's `health-check` dialog titled "Health report", + with no sample note. It lists every check in the server's order: Integrity check, + Foreign keys & pair references, Canonical hashes, Pair uniqueness (added), Missing + files, Ambiguous metadata, Consistency (added). Missing files has one row per image + source when there are several ("Missing files · output"). Each chip is the real + result: "Passed", a count with what was found ("2 broken references", "1 link needs + review", the concept's "N links need review" and "N images need review"), "Not + available" (an unavailable source), "Not available yet" (Ambiguous metadata, Q6) or + "Could not check". A `p.small` after the list says when it was checked and whether + every check passed, some could not run, or issues need review. Close / Review missing + links (to Maintenance, its hand-off until S7). Nothing is changed by a check. +- **D96** (Statistics): "Usage statistics" without the sample note: the concept's + "Runs by category" table (Category / Runs / Avg. rating) with the real runs and + average rating of eligible parents per category ("Uncategorized", "Not rated"), + then a second table "Library totals" (Total / Count: Prompts, Tags, Images from the + legacy statistics, and Rated images: indexed images whose linked prompt is rated). + The Overview row says "Runs and ratings by category, with library totals." because + prompt, tag and date usage is not recorded. A failed read toasts "Statistics could not + be loaded. Your saved data has not changed." +- **D121** (production hub): the Overview hub's System card reads the concept's System + heading and "Open System"; the concept has no hub card. +- **D122** (shell): the section nav's `aria-label` is "System sections"; the concept's + literal label is its prototype action name "system-tab sections". +- **D-NEW-S2-5** (Maintenance, pending a number): the concept's Maintenance notice + ("Check first. Review changes. Then apply." + Run health checks) is delivered ahead of + the rest of Maintenance, above its D94 hand-off, as the native place that runs the + health check. A press shows "Checking…" (`aria-disabled`) and opens the report; a + failed check toasts "The health check could not finish. Nothing was changed." + +`tests/browser/system-concept.spec.mjs` compares the shell and Overview with the concept +(dark and light, 1600×1100 and 390×844), mapping only the deviations above; unmapped, +the difference is exactly the D92, D93, D96 and D122 paths. `statistics and health +dialogs match the concept` maps D95 and D96 and compares the Maintenance notice +unchanged. `system comparison detects mutations` removes a row, renames a metric label, +swaps the `--line` token, recolours a chip, removes the totals table and renames a +caption. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`, +`{concept,production}-{statistics,health}-{dark,light}.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, the review row (Q6), the section links, the Statistics dialog against the +route, a load failure with Retry and late answers after leaving. 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 +concept-shaped fixture, with delays through the shared request holds. + +```sh +python3 -m unittest tests.test_database_health tests.test_system_api tests.test_library -v +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 +npm run --prefix tools/vendor test:workflows -- system-concept.spec.mjs workspace-system.spec.mjs \ + --project chromium --project touch --project firefox --project webkit +``` + ## Workspace data fields for the concept pages (#249) These fields are additive. The legacy `/prompt_manager/images/output` listing (no diff --git a/py/api/system.py b/py/api/system.py index d0afe24f..876abebe 100644 --- a/py/api/system.py +++ b/py/api/system.py @@ -9,7 +9,8 @@ one, no health result until a check ran, and no Needs-review count until the review queue exists. - ``GET /prompt_manager/system/statistics`` lists runs and ratings per - category, with the legacy ``/stats`` totals. + category, with the legacy ``/stats`` totals and the Overview's count of + rated images (images whose linked prompt is rated). New routes answer errors with ``{success:false, code, error}``. """ @@ -167,14 +168,20 @@ class SystemRoutesMixin: async def get_system_statistics(self, request): try: - by_category, totals = await asyncio.gather( + by_category, totals, counts = await asyncio.gather( self._run_in_executor(self.db.usage_by_category), self._run_in_executor(self.db.get_statistics), + self._run_in_executor(self.db.library_counts), ) except Exception: self.logger.exception("System statistics failed") return _failure("Failed to read statistics") return web.json_response( - {"success": True, "by_category": by_category, "totals": totals}, + { + "success": True, + "by_category": by_category, + "totals": totals, + "rated_images": counts["rated_images"], + }, headers={"Cache-Control": "no-store"}, ) diff --git a/tests/browser/server.py b/tests/browser/server.py index 9764aa74..9f9f74ec 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -20,6 +20,7 @@ from PIL.PngImagePlugin import PngInfo from tests.browser.request_hold import register_request_holds from tests.browser.workspace_activity_fixture import register_activity_commands +from tests.browser.workspace_system_fixture import register_system_commands from tests.browser.workspace_concept_fixture import ( MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES, MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES, @@ -196,6 +197,7 @@ def create_app(root: Path) -> web.Application: register_tagging_commands(routes, api, root, model) reveal_stub = register_reveal_stub(routes, api, root) register_activity_commands(app, routes, api) + register_system_commands(app, routes, api, root) request_holds = register_request_holds(app, routes) async def canonical_asset(request: web.Request) -> web.Response: diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs new file mode 100644 index 00000000..aa4904fd --- /dev/null +++ b/tests/browser/system-concept.spec.mjs @@ -0,0 +1,365 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; +import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs'; + +// #249 System S2 Task 6: System's shell and its Overview section (app.js renderSystem 658–706), the Statistics dialog +// ("Usage statistics", a1600–1604) and the health report (a1439–1456) against the immutable concept, on the +// concept-shaped real fixture. Each concept measurement is mapped only by the recorded deviations (D92–D96, D121, +// D122, D-NEW-S2-5), each commented where it applies; after that the difference must be empty, and a mutation of a +// row, a label or a token must be noticed. +test.use({ locale: 'en-US', timezoneId: 'UTC' }); + +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.conceptErrors = []; + page.on('pageerror', error => page.conceptErrors.push(error.message)); +}); + +test.afterEach(async ({ page, request, browserName }) => { + await saveCoverage(page, browserName); + await request.post('/__test__/system/reset'); + expect(page.conceptErrors).toEqual([]); +}); + +/** Runs in the page; self-contained. System's heading, section nav, Overview body and Save row under `mainSelector`. */ +function measureSystem(mainSelector) { + const main = document.querySelector(mainSelector); + 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 box = (node, origin = main) => { + const rect = node.getBoundingClientRect(), base = origin.getBoundingClientRect(); + return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) }; + }; + const text = node => node?.textContent.trim() ?? null; + const button = node => ({ text: text(node), className: node.className, + ...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopWidth', 'borderTopColor', + 'borderTopLeftRadius', 'opacity', 'cursor') }); + const heading = main.querySelector(':scope > .heading'); + const nav = main.querySelector(':scope > .section-nav'); + const layout = main.querySelector(':scope > .settings-layout'); + const metrics = layout.querySelector(':scope > .summary-metrics'); + const columns = layout.querySelector(':scope > .two-col'); + const save = layout.querySelector(':scope > .save-row'); + const firstMetric = metrics.querySelector('.metric'); + const firstSurface = columns.querySelector('.surface'); + const firstOperation = firstSurface.querySelector('.operation'); + return { + tree: [...main.children].map(name), + heading: { + title: text(heading.querySelector('h1')), description: text(heading.querySelector('p.muted')), + actions: [...(heading.querySelector('.heading-actions')?.children ?? [])].map(name), + }, + nav: { + role: nav.getAttribute('role'), label: nav.getAttribute('aria-label'), + ...css(nav, 'display', 'flexWrap', 'columnGap', 'rowGap', 'paddingBottom', 'marginBottom', 'borderBottomWidth', 'borderBottomColor'), + box: box(nav), + buttons: [...nav.children].map(item => ({ pressed: item.getAttribute('aria-pressed'), ...button(item) })), + }, + layout: { tree: [...layout.children].map(name), ...css(layout, 'maxWidth'), box: box(layout) }, + metrics: { + ...css(metrics, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap', 'marginBottom'), box: box(metrics), + items: [...metrics.children].map(item => ({ tree: [...item.children].map(name), value: text(item.querySelector('strong')), + label: text(item.querySelector('span')), box: box(item) })), + metric: css(firstMetric, 'paddingTop', 'paddingLeft', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius', 'backgroundColor'), + strong: css(firstMetric.querySelector('strong'), 'display', 'fontSize', 'fontWeight', 'color'), + span: css(firstMetric.querySelector('span'), 'fontSize', 'color', 'marginTop'), + }, + columns: { + ...css(columns, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'), box: box(columns), + surfaces: [...columns.children].map(surface => ({ tree: [...surface.children].map(name), box: box(surface), + heading: text(surface.querySelector(':scope > h2')), + rows: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name), + title: text(row.querySelector('h3')), description: text(row.querySelector('p')), + action: button(row.querySelector('.row-actions > button')) })) })), + surface: css(firstSurface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'), + h2: css(firstSurface.querySelector('h2'), 'fontSize', 'fontWeight', 'color', 'marginBottom'), + operation: css(firstOperation, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', + 'borderTopWidth', 'borderTopColor'), + h3: css(firstOperation.querySelector('h3'), 'fontSize', 'fontWeight', 'color'), + p: css(firstOperation.querySelector('p'), 'fontSize', 'color', 'marginTop'), + actions: css(firstOperation.querySelector('.row-actions'), 'display', 'columnGap', 'flexShrink'), + }, + save: { + tree: [...save.children].map(name), ...css(save, 'display', 'alignItems', 'justifyContent', 'columnGap', 'marginTop', 'paddingTop'), + status: { text: text(save.querySelector('#settings-status')), ...css(save.querySelector('#settings-status'), 'fontSize', 'color') }, + button: button(save.querySelector('button')), + }, + pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth, + }; +} + +/** Runs in the page: the open dialog's title, body sequence, tables, operation rows and actions. */ +function measureDialog() { + const dialog = document.querySelector('dialog#modal[open]'); + 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 body = dialog.querySelector('#modal-body'); + const firstCell = selector => body.querySelector(selector); + const list = body.querySelector(':scope > .operation-list'); + const actions = body.querySelector(':scope > .dialog-actions'); + return { + title: dialog.querySelector('#modal-title').textContent, + body: [...body.children].map(name), + tables: [...body.querySelectorAll(':scope > table')].map(table => ({ + caption: table.caption?.textContent ?? null, + head: [...table.tHead.rows[0].cells].map(cell => [cell.localName, cell.scope, cell.textContent]), + rows: [...table.tBodies[0].rows].map(row => [...row.cells].map(cell => cell.textContent)), + })), + tableStyle: firstCell(':scope > table') && { + table: css(firstCell(':scope > table'), 'width', 'borderCollapse', 'fontSize', 'marginTop'), + caption: css(firstCell(':scope > table caption'), 'textAlign', 'fontSize', 'fontWeight', 'color', 'paddingBottom'), + th: css(firstCell(':scope > table thead th'), 'textAlign', 'fontSize', 'fontWeight', 'color', 'paddingTop', 'paddingLeft', + 'borderBottomWidth', 'borderBottomColor'), + td: css(firstCell(':scope > table tbody td'), 'textAlign', 'fontSize', 'color', 'paddingTop', 'paddingLeft', + 'borderBottomWidth', 'borderBottomColor'), + }, + operations: list && { + ...css(list, 'display', 'rowGap', 'marginTop'), + rows: [...list.children].map(row => ({ tree: [...row.children].map(name), title: row.querySelector('h3').textContent, + chip: row.querySelector('.chip').textContent })), + operation: css(list.querySelector('.operation'), 'display', 'alignItems', 'justifyContent', 'paddingTop', 'borderTopWidth'), + h3: css(list.querySelector('.operation h3'), 'fontSize', 'fontWeight', 'color'), + chip: css(list.querySelector('.operation .chip'), 'fontSize', 'color', 'backgroundColor', 'borderTopLeftRadius', 'paddingLeft'), + }, + actions: actions && { + ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'), + buttons: [...actions.children].map(item => ({ text: item.textContent, className: item.className, + ...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft') })), + }, + }; +} + +/** Runs in the page: a `.notice` (Maintenance's "Check first…") with its copy. */ +function measureNotice(mainSelector) { + const notice = document.querySelector(`${mainSelector} .settings-layout > .notice`); + const css = (node, ...keys) => Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])); + const name = node => [node.localName, ...[...node.classList].sort()].join('.'); + const run = notice.querySelector(':scope > button'); + return { + tree: [...notice.children].map(name), text: [...notice.querySelectorAll(':scope > div > *')].map(node => [node.localName, node.textContent]), + frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopColor', 'backgroundColor', + 'borderTopLeftRadius', 'paddingTop', 'paddingLeft', 'marginBottom'), + h2: css(notice.querySelector('h2'), 'fontSize', 'fontWeight', 'color'), + p: css(notice.querySelector('p'), 'fontSize', 'color', 'marginTop'), + run: { text: run.textContent, className: run.className, ...css(run, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft') }, + }; +} + +async function serverJson(request, url) { + const response = await request.get(url); + await expect(response).toBeOK(); + return response.json(); +} + +const number = value => value.toLocaleString('en-US'); +const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`; + +/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */ +function mapOverviewConcept(reference, overview) { + const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts), + 'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) }; + const rows = { + // D93 (Q6): no review queue yet, so no count and the action is unavailable (dimmed, as the Save row's D92). + '3 ambiguous image links': row => ({ ...row, title: 'Ambiguous image links', description: 'Not available yet.', + action: { ...row.action, opacity: '0.55', cursor: 'not-allowed' } }), + // D93: no backup has been recorded (verified backups arrive with S8). + 'Backup status': row => ({ ...row, description: 'No retained backup record.' }), + // D93: there is no paging, so the shared settings are grid and density. + 'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }), + // D93: the real sources, not samples. + '2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }), + // D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage. + Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }), + }; + return { + ...reference, + // D92: no "Sample configuration" chip. + heading: { ...reference.heading, actions: [] }, + // D122: the concept's literal label is its prototype action name ("system-tab sections"). + nav: { ...reference.nav, label: 'System sections' }, + metrics: { ...reference.metrics, items: reference.metrics.items.map(item => ({ ...item, value: relabel[item.label] })) }, + columns: { ...reference.columns, surfaces: reference.columns.surfaces.map(surface => ({ ...surface, + rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) })) }, + save: { ...reference.save, + // D92: truthful status copy, and "Save preferences" is aria-disabled (dimmed) while nothing is unsaved. + status: { ...reference.save.status, text: 'No unsaved changes.' }, + button: { ...reference.save.button, opacity: '0.55', cursor: 'not-allowed' } }, + }; +} + +async function conceptOverview(page, theme) { + await openConcept(page, 'system', theme); + await expect(page.locator('#main .summary-metrics')).toBeVisible(); + await page.mouse.move(0, 0); + return page.evaluate(measureSystem, '#main'); +} + +async function productionOverview(page, theme) { + await openProduction(page, 'system/overview', theme); + await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible(); + await page.mouse.move(0, 0); + return page.evaluate(measureSystem, '#workspace-main'); +} + +for (const theme of THEMES) for (const [width, height] of SIZES) { + test(`system shell and overview match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width, height }); + const overview = await serverJson(request, '/prompt_manager/system/overview'); + // The fixture's thumbnails all exist, so the concept's "No missing thumbnails" is also the real title. + expect(overview.thumbnails.missing).toBe(0); + const concept = await conceptOverview(page, theme); + expect(concept.heading.actions).toEqual(['span.chip']); + // Paired captures are artifacts only; there are no PNG baselines. + await page.screenshot({ path: testInfo.outputPath(`concept-system-${theme}-${width}.png`), fullPage: true }); + const production = await productionOverview(page, theme); + await page.screenshot({ path: testInfo.outputPath(`production-system-${theme}-${width}.png`), fullPage: true }); + expect(diffInventory(mapOverviewConcept(concept, overview), production)).toEqual([]); + expect(production.pageScroll).toBe(false); + }); +} + +// The recorded differences, by path: D93 the review row (title, Not available yet, dimmed action), the backup row, the +// preferences row and the sources row, and metric 4; D96 the statistics row; D92 the chip, the status and the dimmed +// Save; D122 the nav label. (The fixture's first three metrics are the concept's own counts.) +const UNMAPPED_PATHS = [ + 'columns.surfaces.0.rows.0.action.cursor', 'columns.surfaces.0.rows.0.action.opacity', + 'columns.surfaces.0.rows.0.description', 'columns.surfaces.0.rows.0.title', 'columns.surfaces.0.rows.1.description', + 'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description', + 'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label', + 'save.button.cursor', 'save.button.opacity', 'save.status.text', +]; + +test('system comparison detects mutations', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const overview = await serverJson(request, '/prompt_manager/system/overview'); + const concept = await conceptOverview(page, 'dark'); + const expected = mapOverviewConcept(concept, overview); + const production = await productionOverview(page, 'dark'); + expect(diffInventory(expected, production)).toEqual([]); + // Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else. + expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual(UNMAPPED_PATHS); + const measure = () => page.evaluate(measureSystem, '#workspace-main'); + const paths = async () => diffInventory(expected, await measure()).map(item => item.path); + + // A removed row. + const undoRow = await mutate(page, { remove: ['#workspace-main .two-col > .surface:first-child > .operation:nth-of-type(2)'] }); + expect(await paths()).toEqual(expect.arrayContaining(['columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.length'])); + await undoRow(); + // A changed label. + await page.evaluate(() => { document.querySelector('#workspace-main .metric span').textContent = 'Positive prompt'; }); + expect(await paths()).toEqual(['metrics.items.0.label']); + await page.evaluate(() => { document.querySelector('#workspace-main .metric span').textContent = 'Positive prompts'; }); + // A swapped token. + const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' }); + expect(await paths()).toEqual(expect.arrayContaining(['metrics.metric.borderTopColor', 'columns.surface.borderTopColor'])); + await undoToken(); + expect(diffInventory(expected, await measure())).toEqual([]); +}); + +/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */ +function mapStatisticsConcept(reference, statistics) { + const [runs] = reference.tables; + return { + ...reference, + // No sample note (prototype copy); a second table follows the first. + body: ['table', 'table'], + tables: [ + { ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) }, + { caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']], + rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)], + ['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] }, + ], + }; +} + +/** D95: the concept's health report titled "Health report", every real check, the checked line, the same actions. */ +function mapHealthConcept(reference, report) { + const chips = { passed: 'Passed', unavailable: 'Not available yet' }; + const rows = report.checks.flatMap(check => check.id === 'missing_files' && check.sources.length > 1 + ? check.sources.map(source => [`${check.label} · ${source.name}`, source.count === 0 ? 'Passed' : 'Not available']) + : [[check.label, chips[check.status]]]); + return { + ...reference, + title: 'Health report', + // No sample note (prototype copy); D95 adds the checked line after the list. + body: ['div.operation-list', 'p.small', 'div.dialog-actions'], + operations: { ...reference.operations, rows: rows.map(([title, chip]) => ({ tree: ['h3', 'span.chip'], title, chip })) }, + }; +} + +async function conceptDialogs(page, theme, testInfo) { + await openConcept(page, 'system', theme); + await page.locator('#main').getByRole('button', { name: 'Explore', exact: true }).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics'); + await page.mouse.move(0, 0); + const statistics = await page.evaluate(measureDialog); + await page.screenshot({ path: testInfo.outputPath(`concept-statistics-${theme}.png`) }); + await page.keyboard.press('Escape'); + await page.locator('#main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click(); + const notice = await page.evaluate(measureNotice, '#main'); + await page.locator('#main .notice').getByRole('button', { name: 'Run health checks', exact: true }).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Sample health report'); + await page.mouse.move(0, 0); + await page.screenshot({ path: testInfo.outputPath(`concept-health-${theme}.png`) }); + return { statistics, notice, health: await page.evaluate(measureDialog) }; +} + +async function productionDialogs(page, theme, testInfo) { + await openProduction(page, 'system/overview', theme); + await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics'); + await page.mouse.move(0, 0); + const statistics = await page.evaluate(measureDialog); + await page.screenshot({ path: testInfo.outputPath(`production-statistics-${theme}.png`) }); + await page.keyboard.press('Escape'); + await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click(); + await expect(page.locator('#workspace-main .notice')).toBeVisible(); + const notice = await page.evaluate(measureNotice, '#workspace-main'); + await page.locator('#workspace-main .notice').getByRole('button', { name: 'Run health checks', exact: true }).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Health report'); + await page.mouse.move(0, 0); + await page.screenshot({ path: testInfo.outputPath(`production-health-${theme}.png`) }); + return { statistics, notice, health: await page.evaluate(measureDialog) }; +} + +for (const theme of THEMES) test(`statistics and health dialogs match the concept ${theme}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const concept = await conceptDialogs(page, theme, testInfo); + expect(concept.statistics.body).toEqual(['p.demo-note', 'table']); + expect(concept.health.body).toEqual(['p.demo-note', 'div.operation-list', 'div.dialog-actions']); + const production = await productionDialogs(page, theme, testInfo); + const statistics = await serverJson(request, '/prompt_manager/system/statistics'); + const { report } = await serverJson(request, '/prompt_manager/system/health'); + expect(diffInventory(mapStatisticsConcept(concept.statistics, statistics), production.statistics)).toEqual([]); + expect(diffInventory(mapHealthConcept(concept.health, report), production.health)).toEqual([]); + // D-NEW-S2-5: Maintenance's notice is the concept's own, delivered ahead of the rest of the section. + expect(diffInventory(concept.notice, production.notice)).toEqual([]); + + // Unmapped, the dialogs differ only where D95 and D96 are recorded. + expect(diffInventory(concept.health, production.health).map(item => item.path.split('.')[0]) + .filter((part, index, all) => all.indexOf(part) === index).sort()).toEqual(['body', 'operations', 'title']); + expect(diffInventory(concept.statistics, production.statistics).map(item => item.path.split('.')[0]) + .filter((part, index, all) => all.indexOf(part) === index).sort()).toEqual(['body', 'tables']); + + // Mutation check: a changed chip token in the health report is noticed, then undone to an exact match again. + const undo = await mutate(page, { css: '#modal .operation .chip { background: rgb(255, 0, 0); }' }); + expect(diffInventory(mapHealthConcept(concept.health, report), await page.evaluate(measureDialog)).map(item => item.path)) + .toEqual(['operations.chip.backgroundColor']); + await undo(); + expect(diffInventory(mapHealthConcept(concept.health, report), await page.evaluate(measureDialog))).toEqual([]); + // And a removed "Library totals" table and a changed caption in the statistics dialog. + await page.keyboard.press('Escape'); + await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click(); + await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics'); + const expectedStatistics = mapStatisticsConcept(concept.statistics, statistics); + const undoTable = await mutate(page, { remove: ['#modal-body > table:nth-of-type(2)'] }); + expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path)) + .toEqual(expect.arrayContaining(['body.length', 'tables.length'])); + await undoTable(); + await page.evaluate(() => { document.querySelector('#modal-body caption').textContent = 'Runs per category'; }); + expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path)).toEqual(['tables.0.caption']); +}); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs new file mode 100644 index 00000000..f1b4b156 --- /dev/null +++ b/tests/browser/workspace-system.spec.mjs @@ -0,0 +1,225 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; +import { seedConcept } from './concept-inventory.mjs'; + +// System › Overview (#249 System S2 Task 6) on the concept-shaped real fixture: the metrics, rows, Statistics dialog and +// Health report read the server, never sample values (D93, D95, D96). The hooks are workspace_system_fixture.py's. +const workspace = '/prompt_manager/workspace'; +const OVERVIEW_URL = '/prompt_manager/system/overview'; +const number = value => value.toLocaleString('en-US'); + +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__/system/reset'); + expect(page.systemErrors).toEqual([]); +}); + +async function serverJson(request, url) { + const response = await request.get(url); + await expect(response).toBeOK(); + return response.json(); +} + +const layoutOf = page => page.locator('#workspace-main .settings-layout'); + +async function openOverview(page) { + await page.goto(`${workspace}#/system/overview`); + await expect(layoutOf(page).locator(':scope > .summary-metrics')).toBeVisible(); + return layoutOf(page); +} + +/** The rows of one Overview surface: `[h3, p, button]`. */ +function rowsOf(layout, heading) { + return layout.locator('.two-col > section.surface').filter({ has: layout.page().getByRole('heading', { name: heading, exact: true }) }) + .locator('.operation').evaluateAll(rows => rows.map(row => [row.querySelector('h3').textContent, + row.querySelector('p').textContent, row.querySelector('.row-actions button').textContent])); +} + +test('overview shows real counts and Not checked', async ({ page, request }) => { + const writes = []; + page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); }); + const overview = await serverJson(request, OVERVIEW_URL); + // The concept fixture: six positives, their saved negatives and 24 linked images, all with thumbnails. + expect([overview.library.positive_prompts, overview.images.indexed, overview.thumbnails.missing]).toEqual([6, 24, 0]); + const layout = await openOverview(page); + expect(await layout.locator('.summary-metrics > .metric').evaluateAll(items => items.map(item => + [item.querySelector('strong').textContent, item.querySelector('span').textContent]))).toEqual([ + [number(overview.library.positive_prompts), 'Positive prompts'], + [number(overview.library.negative_variants), 'Negative variants'], + [number(overview.images.indexed), 'Images'], + ['Not checked', 'Database integrity'], + ]); + expect(await rowsOf(layout, 'Library health')).toEqual([ + ['Ambiguous image links', 'Not available yet.', 'Review metadata'], + ['Backup status', 'No retained backup record.', 'View backups'], + ['No missing thumbnails', 'Generate only missing previews when necessary.', 'Review thumbnails'], + ]); + expect(await rowsOf(layout, 'Workspace preferences')).toEqual([ + ['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'], + ['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'], + ['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'], + ]); + expect(writes).toEqual([]); +}); + +test('Run health checks opens Health report and updates metric 4', async ({ page, request }) => { + await page.goto(`${workspace}#/system/maintenance`); + const layout = layoutOf(page); + await expect(layout.locator(':scope > .notice h2')).toHaveText('Check first. Review changes. Then apply.'); + // The rest of Maintenance is still the existing tools' (D94). + await expect(layout.locator(':scope > section.handoff h2')).toHaveText('Your current tools are ready'); + const run = layout.getByRole('button', { name: 'Run health checks', exact: true }); + await run.click(); + const dialog = page.locator('dialog#modal[open]'); + await expect(dialog.locator('#modal-title')).toHaveText('Health report'); + const { report } = await serverJson(request, '/prompt_manager/system/health'); + expect(report.status).toBe('incomplete'); + expect(await dialog.locator('.operation-list > .operation').evaluateAll(rows => rows.map(row => + [row.querySelector('h3').textContent, row.querySelector('span.chip').textContent]))).toEqual([ + ['Integrity check', 'Passed'], ['Foreign keys & pair references', 'Passed'], ['Canonical hashes', 'Passed'], + ['Pair uniqueness', 'Passed'], ['Missing files · output', 'Passed'], ['Missing files · input', 'Passed'], + ['Ambiguous metadata', 'Not available yet'], ['Consistency', 'Passed'], + ]); + await expect(dialog.locator('#modal-body > p.small')).toHaveText(/^Checked .+\. Some checks could not run, so this report is incomplete\.$/); + await expect(dialog.locator('.dialog-actions button')).toHaveText(['Close', 'Review missing links']); + await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(page.locator('dialog#modal[open]')).toHaveCount(0); + await expect(run).toBeFocused(); + await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click(); + await expect(layout.locator('.metric').nth(3).locator('strong')).toHaveText('Incomplete'); + + // A missing file and an unmounted source: the report counts the readable sources and names the other one. + await expect(await request.post('/__test__/system/missing-file')).toBeOK(); + await expect(await request.post('/__test__/system/missing-source')).toBeOK(); + await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click(); + await layout.getByRole('button', { name: 'Run health checks', exact: true }).click(); + await expect(dialog.locator('.operation').filter({ hasText: 'Missing files' }).locator('.chip')) + .toHaveText(['1 link needs review', 'Passed', 'Not available']); + await expect(dialog.locator('.operation h3').filter({ hasText: 'Missing files' })) + .toHaveText(['Missing files · output', 'Missing files · input', 'Missing files · unmounted-drive']); + // Review missing links goes to Maintenance, where the cleanup's existing tool is linked. + await dialog.getByRole('button', { name: 'Review missing links', exact: true }).click(); + await expect(page.locator('dialog#modal[open]')).toHaveCount(0); + await expect(page).toHaveURL(`${workspace}#/system/maintenance`); + await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click(); + await expect(layout.locator('.metric').nth(3).locator('strong')).toHaveText('1 issue'); +}); + +test('a health check that fails says so and keeps the last result', async ({ page, request }) => { + await expect(await request.post('/__test__/system/fail', { data: { path: '/prompt_manager/system/health' } })).toBeOK(); + await page.goto(`${workspace}#/system/maintenance`); + const run = layoutOf(page).getByRole('button', { name: 'Run health checks', exact: true }); + await run.click(); + await expect(page.locator('#workspace-toast')).toHaveText('The health check could not finish. Nothing was changed.'); + await expect(page.locator('dialog#modal[open]')).toHaveCount(0); + await expect(run).not.toHaveAttribute('aria-disabled', 'true'); + await openOverview(page); + await expect(layoutOf(page).locator('.metric').nth(3).locator('strong')).toHaveText('Not checked'); +}); + +test('thumbnail row reflects the status endpoint', async ({ page, request }) => { + await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 3 } })).toBeOK(); + const status = await serverJson(request, '/prompt_manager/images/thumbnails/status'); + expect(status.pending).toBe(3); + const layout = await openOverview(page); + const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) }); + await expect(row.locator('h3')).toHaveText('3 missing thumbnails'); + // Until the thumbnail review dialog (S5), Review thumbnails opens Sources. + await row.getByRole('button', { name: 'Review thumbnails', exact: true }).click(); + await expect(page).toHaveURL(`${workspace}#/system/sources`); +}); + +test('ambiguous row reads Not available yet (Q6)', async ({ page }) => { + const layout = await openOverview(page); + const row = layout.locator('.operation').filter({ hasText: 'Ambiguous image links' }); + await expect(row.locator('p')).toHaveText('Not available yet.'); + const review = row.getByRole('button', { name: 'Review metadata', exact: true }); + await expect(review).toHaveAttribute('aria-disabled', 'true'); + await review.click({ force: true }); + await expect(page).toHaveURL(`${workspace}#/system/overview`); +}); + +test('View backups / Appearance / Manage sources switch sections', async ({ page }) => { + for (const [label, section, pressed] of [['View backups', 'backups', 'Backup & export'], ['Appearance', 'appearance', 'Appearance'], + ['Manage sources', 'sources', 'Sources & scans']]) { + const layout = await openOverview(page); + await layout.getByRole('button', { name: label, exact: true }).click(); + await expect(page).toHaveURL(`${workspace}#/system/${section}`); + await expect(page.locator('#workspace-main .section-nav [aria-pressed="true"]')).toHaveText(pressed); + await expect(layout.locator(':scope > .summary-metrics')).toHaveCount(0); + } +}); + +test('statistics dialog lists runs by category and library totals', async ({ page, request }) => { + const statistics = await serverJson(request, '/prompt_manager/system/statistics'); + expect(statistics.by_category.length).toBeGreaterThan(1); + const layout = await openOverview(page); + const explore = layout.getByRole('button', { name: 'Explore', exact: true }); + await explore.click(); + const dialog = page.locator('dialog#modal[open]'); + await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics'); + const tables = dialog.locator('#modal-body > table'); + await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']); + await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']); + const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent))); + expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', + number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`])); + expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)], + ['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)], + ['Rated images', number(statistics.rated_images)]]); + await page.keyboard.press('Escape'); + await expect(explore).toBeFocused(); + // A statistics read that fails says so; the Overview stays. + await expect(await request.post('/__test__/system/fail', { data: { path: '/prompt_manager/system/statistics' } })).toBeOK(); + await explore.click(); + await expect(page.locator('#workspace-toast')).toHaveText('Statistics could not be loaded. Your saved data has not changed.'); + await expect(page.locator('dialog#modal[open]')).toHaveCount(0); +}); + +test('load failure shows Retry and no zeros', async ({ page, request }) => { + await expect(await request.post('/__test__/system/fail', { data: { path: OVERVIEW_URL } })).toBeOK(); + await page.goto(`${workspace}#/system/overview`); + const layout = layoutOf(page); + await expect(layout.getByRole('alert')).toHaveText('The overview could not be loaded. Your saved data has not changed.'); + await expect(layout.locator('.metric')).toHaveCount(0); + await expect(layout).not.toContainText('0'); + await layout.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(layout.locator('.summary-metrics > .metric')).toHaveCount(4); + await expect(layout.getByRole('alert')).toHaveCount(0); +}); + +test('out-of-order overview responses never repaint a newer section', async ({ page, request }) => { + const held = async () => (await serverJson(request, '/__test__/hold')).held[OVERVIEW_URL] ?? 0; + await expect(await request.post('/__test__/hold', { data: { path: OVERVIEW_URL } })).toBeOK(); + await page.goto(`${workspace}#/system/overview`); + const layout = layoutOf(page); + await expect(layout.getByRole('status')).toHaveText('Loading the overview…'); + await expect.poll(held).toBe(1); + // Leave for Appearance, come back (a second read), then leave again before either answers. + const nav = page.locator('#workspace-main .section-nav'); + 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(); + 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. + 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); + expect(await layout.evaluate(node => [...node.children].map(child => child.className))).toEqual(['surface handoff', 'save-row']); + // Back on Overview, one fresh read paints one set of metrics. + await nav.getByRole('button', { name: 'Overview', exact: true }).click(); + await expect(layout.locator('.summary-metrics')).toHaveCount(1); + await expect(layout.locator('.two-col')).toHaveCount(1); +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 1c8eaa0f..292e1f0a 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -410,6 +410,8 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await sidebarLink(page, 'System').click(); + // System's Overview is native (S2 Task 6); a section not delivered yet still hands off (D94). + await main.locator('.section-nav').getByRole('button', { name: 'Integrations', exact: true }).click(); const handoff = main.locator('section.handoff'); await expect(handoff).toHaveClass(/\bsurface\b/); expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE); @@ -426,7 +428,7 @@ const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tag const SYSTEM_HANDOFFS = { appearance: ['/prompt_manager/gallery', '/prompt_manager/admin'], sources: ['/prompt_manager/admin', '/prompt_manager/gallery'], maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] }; -test('System is native: the concept shell, section navigation, hand-offs and the Save row', async ({ page }) => { +test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => { const mutations = []; page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); }); await page.goto(`${workspace}#/system`); @@ -460,8 +462,14 @@ test('System is native: the concept shell, section navigation, hand-offs and the 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); Maintenance shows its health-check notice above + // the hand-off (D-NEW-S2-5); every other section hands off. + if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible(); expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`))) - .toEqual(['section.surface handoff', 'div.save-row']); + .toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], + maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash] + ?? ['section.surface handoff', 'div.save-row']); + if (hash === 'overview') 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 new file mode 100644 index 00000000..6027c64d --- /dev/null +++ b/tests/browser/workspace_system_fixture.py @@ -0,0 +1,116 @@ +"""System test hooks for the browser tests (#249 System S2 Task 6). + +The data is the concept-shaped real fixture (``POST /__test__/concept/reset``, +``workspace_concept_fixture.py``): its parents, saved pairs, images, ratings +and runs are what the Overview, the Statistics dialog and the health check +read. These per-server hooks only change what a System test needs on top: + +- ``POST /__test__/system/reset`` restores the configured sources, forgets the + session's health report and ends every injected failure; +- ``POST /__test__/system/fail`` ``{path, times}`` answers the next ``times`` + requests for ``path`` (a ``/prompt_manager/system/`` route) with the routes' + 500 error envelope; +- ``POST /__test__/system/missing-source`` configures one more source that + does not exist (an unmounted drive); +- ``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. + +Delays use the shared request holds (``request_hold.py``). +""" + +import os +from pathlib import Path + +from aiohttp import web +from PIL import Image + +FAILABLE_PREFIX = "/prompt_manager/system/" +MAX_UNTHUMBNAILED = 50 + + +def register_system_commands(app, routes, api, root: Path): + from py.config import GalleryConfig + + configured = list(GalleryConfig.MONITORING_DIRECTORIES) + failures: dict[str, int] = {} + + @web.middleware + async def inject_failures(request, handler): + left = failures.get(request.path, 0) + if left <= 0: + return await handler(request) + failures[request.path] = left - 1 + return web.json_response( + {"success": False, "code": None, "error": "Injected failure"}, status=500 + ) + + app.middlewares.append(inject_failures) + + def reset(): + failures.clear() + GalleryConfig.MONITORING_DIRECTORIES = list(configured) + api._health_report = None + + @routes.post("/__test__/system/reset") + async def system_reset(request): + reset() + return web.json_response({"success": True}) + + @routes.post("/__test__/system/fail") + async def system_fail(request): + data = await request.json() + path, times = data.get("path"), data.get("times", 1) + if ( + not isinstance(path, str) + or not path.startswith(FAILABLE_PREFIX) + or isinstance(times, bool) + or not isinstance(times, int) + or not 1 <= times <= 10 + ): + raise web.HTTPBadRequest() + failures[path] = times + return web.json_response({"success": True}) + + @routes.post("/__test__/system/missing-source") + async def missing_source(request): + absent = str(root / "unmounted-drive") + if os.path.exists(absent): + raise web.HTTPConflict() + GalleryConfig.MONITORING_DIRECTORIES = [*configured, absent] + return web.json_response( + {"success": True, "configured": len(GalleryConfig.MONITORING_DIRECTORIES)} + ) + + @routes.post("/__test__/system/missing-file") + async def missing_file(request): + with api.db.model.get_connection() as conn: + row = conn.execute( + "SELECT file_path FROM generated_images ORDER BY id DESC LIMIT 1" + ).fetchone() + if row is None: + raise web.HTTPNotFound() + Path(row[0]).unlink() + api.invalidate_gallery_cache() + return web.json_response({"success": True, "filename": Path(row[0]).name}) + + @routes.post("/__test__/system/unthumbnailed") + async def unthumbnailed(request): + data = await request.json() + count = data.get("count") + if ( + isinstance(count, bool) + or not isinstance(count, int) + or not 1 <= count <= MAX_UNTHUMBNAILED + ): + raise web.HTTPBadRequest() + folder = root / "output" / "unthumbnailed" + folder.mkdir(exist_ok=True) + for number in range(count): + with Image.new("RGB", (64, 64), "#2b3442") as image: + image.save(folder / f"plain_{number + 1:03}.png") + api.invalidate_gallery_cache() + return web.json_response({"success": True}) + + return reset diff --git a/tests/js/workspace-system-overview.test.js b/tests/js/workspace-system-overview.test.js new file mode 100644 index 00000000..bccea458 --- /dev/null +++ b/tests/js/workspace-system-overview.test.js @@ -0,0 +1,188 @@ +// System › Overview (#249 System S2 Task 6): the concept's Overview copy on real values (D93), the Health report rows +// (D95), the Statistics tables (D96) and the health check run that waits for its read job. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const overviewModule = import('../../web/js/workspace/system-overview.mjs'); + +const OVERVIEW = Object.freeze({ + library: { positive_prompts: 1234, negative_variants: 7 }, + images: { indexed: 24, rated: 20 }, + health: null, + backup: null, + needs_review: { pending: null }, + sources: { configured: 2, effective: 2, auto_detected: false }, + thumbnails: { missing: 0 }, +}); + +test('metrics are the server counts and Not checked before any health check', async () => { + const { metricValues } = await overviewModule; + assert.deepEqual(metricValues(OVERVIEW), [ + [(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'], + ['Not checked', 'Database integrity'], + ]); +}); + +test('metric 4 is the last health result', async () => { + const { integrityText } = await overviewModule; + assert.equal(integrityText(null), 'Not checked'); + assert.equal(integrityText({ status: 'passed', issues: 0 }), 'Passed'); + assert.equal(integrityText({ status: 'issues', issues: 1 }), '1 issue'); + assert.equal(integrityText({ status: 'issues', issues: 3 }), '3 issues'); + assert.equal(integrityText({ status: 'incomplete', issues: 0 }), 'Incomplete'); + // Issues found beside a check that could not run (Needs review, Q6) are still named. + assert.equal(integrityText({ status: 'incomplete', issues: 2 }), '2 issues'); +}); + +test('library health rows never invent a review count, a backup or thumbnails', async () => { + const { ambiguousTitle, backupText, thumbnailTitle } = await overviewModule; + assert.equal(ambiguousTitle({ pending: null }), 'Ambiguous image links'); + assert.equal(ambiguousTitle(undefined), 'Ambiguous image links'); + assert.equal(ambiguousTitle({ pending: 1 }), '1 ambiguous image link'); + assert.equal(ambiguousTitle({ pending: 3 }), '3 ambiguous image links'); + assert.equal(backupText(null), 'No retained backup record.'); + assert.equal(backupText({ verified_at: 'not a time' }), 'No retained backup record.'); + assert.match(backupText({ verified_at: '2026-10-22T09:30:00Z' }), /^Last verified backup .+\.$/); + assert.equal(thumbnailTitle({ missing: 0 }), 'No missing thumbnails'); + assert.equal(thumbnailTitle({ missing: 1 }), '1 missing thumbnail'); + assert.equal(thumbnailTitle({ missing: 12 }), '12 missing thumbnails'); + assert.equal(thumbnailTitle({ missing: null }), 'Thumbnail status unavailable'); +}); + +test('the sources row tells configured, available and detected sources apart', async () => { + const { sourcesCopy } = await overviewModule; + const shared = 'Monitoring and enrichment share one source list.'; + assert.deepEqual(sourcesCopy({ configured: 2, effective: 2, auto_detected: false }), ['2 output sources', shared]); + assert.deepEqual(sourcesCopy({ configured: 2, effective: 1, auto_detected: false }), + ['1 output source', `1 of 2 configured sources available. ${shared}`]); + assert.deepEqual(sourcesCopy({ configured: 0, effective: 1, auto_detected: true }), + ['1 output source', `None configured is available; ComfyUI's output folder is used. ${shared}`]); +}); + +const REPORT = Object.freeze({ + checked_at: '2026-10-23T08:00:00Z', + status: 'issues', + checks: [ + { id: 'integrity', label: 'Integrity check', status: 'passed', count: 0 }, + { id: 'foreign_keys', label: 'Foreign keys & pair references', status: 'issues', count: 2 }, + { id: 'canonical_hashes', label: 'Canonical hashes', status: 'issues', count: 1 }, + { id: 'pair_uniqueness', label: 'Pair uniqueness', status: 'failed', count: null }, + { id: 'missing_files', label: 'Missing files', status: 'issues', count: 2, + sources: [{ name: 'output', status: 'checked', count: 2 }] }, + { id: 'ambiguous_metadata', label: 'Ambiguous metadata', status: 'unavailable', count: null }, + { id: 'consistency', label: 'Consistency', status: 'issues', count: null }, + ], +}); + +test('health rows give each check a chip from its real status, in the server order', async () => { + const { healthRows } = await overviewModule; + assert.deepEqual(healthRows(REPORT), [ + ['Integrity check', 'Passed'], + ['Foreign keys & pair references', '2 broken references'], + ['Canonical hashes', '1 hash does not match its text'], + ['Pair uniqueness', 'Could not check'], + ['Missing files', '2 links need review'], + ['Ambiguous metadata', 'Not available yet'], + ['Consistency', 'Issues found'], + ]); +}); + +test('missing files is reported per image source when there are several', async () => { + const { healthRows } = await overviewModule; + const report = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 1, partial: true, + sources: [{ name: 'output', status: 'checked', count: 1 }, { name: 'input', status: 'checked', count: 0 }, + { name: 'archive', status: 'unavailable', count: null }] }] }; + assert.deepEqual(healthRows(report), [ + ['Missing files · output', '1 link needs review'], + ['Missing files · input', 'Passed'], + ['Missing files · archive', 'Not available'], + ]); + const unavailable = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'unavailable', count: null, sources: [] }] }; + assert.deepEqual(healthRows(unavailable), [['Missing files', 'Not available']]); +}); + +test('the report summary says when it was checked and whether it is complete', async () => { + const { healthSummary } = await overviewModule; + const when = new Date(REPORT.checked_at).toLocaleString(); + assert.equal(healthSummary({ ...REPORT, status: 'passed' }), `Checked ${when}. Every check passed.`); + assert.equal(healthSummary({ ...REPORT, status: 'incomplete' }), `Checked ${when}. Some checks could not run, so this report is incomplete.`); + assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.'); +}); + +test('statistics rows are the real runs and ratings, then the library totals', async () => { + const { categoryRows, totalsRows } = await overviewModule; + const statistics = { + by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 }, + { category: null, runs: 3, average_rating: null }], + totals: { total_prompts: 12, total_tags: 15, total_images: 24 }, + rated_images: 20, + }; + assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]); + assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]); +}); + +/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */ +function fakeApi(answers) { + const calls = []; + const answer = (method, url) => { + calls.push(`${method} ${url}`); + const queue = answers[`${method} ${url}`]; + if (!queue?.length) throw new Error(`unexpected ${method} ${url}`); + const next = queue.shift(); + return next instanceof Error ? Promise.reject(next) : Promise.resolve(next); + }; + return { calls, get: url => answer('GET', url), post: url => answer('POST', url) }; +} + +const immediate = { setTimeout: callback => { queueMicrotask(callback); return 0; }, clearTimeout() {} }; +const JOB = '0123456789abcdef0123456789abcdef'; + +test('a health check waits for its read job, then reads the report', async () => { + const { runHealthCheck } = await overviewModule; + const api = fakeApi({ + 'POST /prompt_manager/system/health': [{ job: { id: JOB } }], + [`GET /prompt_manager/jobs/${JOB}`]: [{ job: { state: 'running' } }, { job: { state: 'finished' } }], + 'GET /prompt_manager/system/health': [{ report: REPORT }], + }); + assert.equal(await runHealthCheck(api, { timers: immediate }), REPORT); + assert.deepEqual(api.calls, ['POST /prompt_manager/system/health', `GET /prompt_manager/jobs/${JOB}`, + `GET /prompt_manager/jobs/${JOB}`, 'GET /prompt_manager/system/health']); +}); + +test('a check already running elsewhere is waited for, not refused', async () => { + const { runHealthCheck } = await overviewModule; + const busy = Object.assign(new Error('busy'), { status: 409, code: 'busy' }); + const api = fakeApi({ + 'POST /prompt_manager/system/health': [busy], + 'GET /prompt_manager/jobs': [{ jobs: [{ id: 'f'.repeat(32), kind: 'scan', state: 'running' }, { id: JOB, kind: 'health', state: 'running' }] }], + [`GET /prompt_manager/jobs/${JOB}`]: [{ job: { state: 'finished' } }], + 'GET /prompt_manager/system/health': [{ report: REPORT }], + }); + assert.equal(await runHealthCheck(api, { timers: immediate }), REPORT); + assert.ok(api.calls.includes(`GET /prompt_manager/jobs/${JOB}`)); +}); + +test('a failed, lost or reportless check rejects; other errors pass through', async () => { + const { runHealthCheck, waitForJob, HEALTH_FAILED_TEXT } = await overviewModule; + const failed = fakeApi({ 'POST /prompt_manager/system/health': [{ job: { id: JOB } }], + [`GET /prompt_manager/jobs/${JOB}`]: [{ job: { state: 'failed' } }] }); + await assert.rejects(runHealthCheck(failed, { timers: immediate }), { message: HEALTH_FAILED_TEXT }); + const lost = fakeApi({ [`GET /prompt_manager/jobs/${JOB}`]: [Object.assign(new Error('gone'), { status: 404 })] }); + assert.equal(await waitForJob(lost, JOB, { timers: immediate }), 'lost'); + const empty = fakeApi({ 'POST /prompt_manager/system/health': [{ job: { id: JOB } }], + [`GET /prompt_manager/jobs/${JOB}`]: [{ job: { state: 'finished' } }], 'GET /prompt_manager/system/health': [{ report: null }] }); + await assert.rejects(runHealthCheck(empty, { timers: immediate }), { message: HEALTH_FAILED_TEXT }); + const broken = fakeApi({ 'POST /prompt_manager/system/health': [Object.assign(new Error('server'), { status: 500 })] }); + await assert.rejects(runHealthCheck(broken, { timers: immediate }), { message: 'server' }); +}); + +test('waiting stops when its signal aborts', async () => { + const { waitForJob } = await overviewModule; + const controller = new AbortController(); + const api = fakeApi({ [`GET /prompt_manager/jobs/${JOB}`]: [{ job: { state: 'running' } }] }); + const never = { setTimeout: () => 1, clearTimeout() {} }; + const waiting = waitForJob(api, JOB, { signal: controller.signal, timers: never }); + await new Promise(resolve => setImmediate(resolve)); + controller.abort(new Error('left')); + await assert.rejects(waiting, { message: 'left' }); +}); diff --git a/tests/test_system_api.py b/tests/test_system_api.py index 2e0206b4..a05d9614 100644 --- a/tests/test_system_api.py +++ b/tests/test_system_api.py @@ -299,6 +299,18 @@ class TestStatisticsRoute(SystemAPITestCase): self.assertEqual(body["totals"], self.api.db.get_statistics()) self.assertTrue(body["success"]) + async def test_statistics_lists_rated_images_for_library_totals(self): + # D96: the Statistics dialog's "Library totals" reads the rated count + # with the runs (one read), from the same images the Overview counts. + rated = self.save("Rated", rating=4) + self.image(rated, "a.png") + self.image(self.save("Unrated"), "b.png") + body = await self.json("GET", "/prompt_manager/system/statistics") + self.assertEqual(body["rated_images"], 1) + self.assertEqual( + set(body), {"success", "by_category", "totals", "rated_images"} + ) + async def test_statistics_failure_is_an_error_envelope(self): with ( patch.object( diff --git a/web/js/workspace/system-overview.mjs b/web/js/workspace/system-overview.mjs new file mode 100644 index 00000000..b58810a9 --- /dev/null +++ b/web/js/workspace/system-overview.mjs @@ -0,0 +1,389 @@ +/** + * System › Overview (#249 System S2 Task 6): the concept's `renderSystem` "general" body (app.js 658–675) on real + * values, its Statistics dialog ("Usage statistics", a1600–1604) and its health report (`health-check`, a1439–1456). + * + * - The four `.summary-metrics` and the two `.two-col` surfaces ("Library health", "Workspace preferences") come from + * one `GET /prompt_manager/system/overview`. D93: metric 4 is the last health result (Not checked / Passed / + * N issues / Incomplete); the review, backup, thumbnail and sources rows say what the server reports, and nothing + * reads 0 before the read answers: a load shows a status line, a failure an alert and Retry. + * - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals". + * - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real + * result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D-NEW-S2-5) as a + * read job of the registry; the report opens when it ends. + * + * Reads run through `latestOnly()` on the section's scope, so a late answer never repaints a newer section. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { navigateTo } from './state.mjs'; +import { closeWorkspaceDialog, openContentDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { noun } from './plural.mjs'; +import { operationRow } from './system-controls.mjs'; + +export const OVERVIEW_URL = '/prompt_manager/system/overview'; +export const STATISTICS_URL = '/prompt_manager/system/statistics'; +export const HEALTH_URL = '/prompt_manager/system/health'; +const JOBS_URL = '/prompt_manager/jobs'; +export const HEALTH_KIND = 'health'; +export const JOB_POLL_MS = 150; + +export const LOADING_TEXT = 'Loading the overview…'; +export const LOAD_FAILED_TEXT = 'The overview could not be loaded. Your saved data has not changed.'; +export const STATISTICS_FAILED_TEXT = 'Statistics could not be loaded. Your saved data has not changed.'; +export const HEALTH_FAILED_TEXT = 'The health check could not finish. Nothing was changed.'; +export const NOT_AVAILABLE = 'Not available yet.'; + +const number = value => Number(value).toLocaleString(); + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * D93: metric 4, the last health result of this server session: the checks that found issues, even when another + * check could not run; otherwise Passed, or Incomplete while a check was unavailable or failed. + */ +export function integrityText(health) { + if (!health) return 'Not checked'; + if (health.issues > 0) return `${number(health.issues)} ${noun(health.issues, 'issue')}`; + return health.status === 'passed' ? 'Passed' : 'Incomplete'; +} + +/** The concept's four metrics `[value, label]`, in its order. */ +export function metricValues(overview) { + return [ + [number(overview.library.positive_prompts), 'Positive prompts'], + [number(overview.library.negative_variants), 'Negative variants'], + [number(overview.images.indexed), 'Images'], + [integrityText(overview.health), 'Database integrity'], + ]; +} + +/** D93 (Q6): the review row's title; a count only once the Needs review queue reports one. */ +export function ambiguousTitle(needsReview) { + const pending = needsReview?.pending; + if (!Number.isSafeInteger(pending)) return 'Ambiguous image links'; + return `${number(pending)} ambiguous image ${noun(pending, 'link')}`; +} + +/** D93: the backup row; only a recorded, verified backup is ever named. */ +export function backupText(backup) { + if (!backup) return 'No retained backup record.'; + const when = new Date(backup.verified_at ?? backup.created_at); + return Number.isNaN(when.getTime()) ? 'No retained backup record.' : `Last verified backup ${when.toLocaleString()}.`; +} + +/** D93: the thumbnail row's title, the `/images/thumbnails/status` plan's pending count. */ +export function thumbnailTitle(thumbnails) { + const missing = thumbnails?.missing; + if (!Number.isSafeInteger(missing)) return 'Thumbnail status unavailable'; + if (missing === 0) return 'No missing thumbnails'; + return `${number(missing)} missing ${noun(missing, 'thumbnail')}`; +} + +const SOURCES_SHARED = 'Monitoring and enrichment share one source list.'; + +/** D93: the sources row, `[title, description]`: the sources in use and how they were chosen. */ +export function sourcesCopy(sources) { + const { configured, effective, auto_detected: auto } = sources; + const title = `${number(effective)} output ${noun(effective, 'source')}`; + if (auto) return [title, `None configured is available; ComfyUI's output folder is used. ${SOURCES_SHARED}`]; + if (configured !== effective) return [title, `${number(effective)} of ${number(configured)} configured ${noun(configured, 'source')} available. ${SOURCES_SHARED}`]; + return [title, SOURCES_SHARED]; +} + +// D95: an issue chip names what was found; the concept's own wording for missing files and ambiguous metadata. +const ISSUE_NOUNS = Object.freeze({ + integrity: ['problem found', 'problems found'], + foreign_keys: ['broken reference', 'broken references'], + canonical_hashes: ['hash does not match its text', 'hashes do not match their text'], + pair_uniqueness: ['duplicate pair row', 'duplicate pair rows'], + missing_files: ['link needs review', 'links need review'], + ambiguous_metadata: ['image needs review', 'images need review'], + consistency: ['inconsistency', 'inconsistencies'], +}); + +/** D95: a check's chip from its real status. */ +export function checkChip(check) { + if (check.status === 'passed') return 'Passed'; + if (check.status === 'failed') return 'Could not check'; + if (check.status === 'unavailable') return check.id === 'ambiguous_metadata' ? NOT_AVAILABLE.slice(0, -1) : 'Not available'; + const [one, many] = ISSUE_NOUNS[check.id] ?? ['issue', 'issues']; + return Number.isSafeInteger(check.count) ? `${number(check.count)} ${check.count === 1 ? one : many}` : 'Issues found'; +} + +function sourceChip(source) { + if (source.status !== 'checked') return 'Not available'; + return source.count === 0 ? 'Passed' : checkChip({ id: 'missing_files', status: 'issues', count: source.count }); +} + +/** D95: the report's rows `[label, chip]` in the server's order; Missing files once per image source when several. */ +export function healthRows(report) { + return report.checks.flatMap(check => { + const sources = check.id === 'missing_files' && Array.isArray(check.sources) ? check.sources : []; + if (sources.length < 2) return [[check.label, checkChip(check)]]; + return sources.map(source => [`${check.label} · ${source.name}`, sourceChip(source)]); + }); +} + +/** D95: when the report was taken, and whether it is complete. */ +export function healthSummary(report) { + const when = new Date(report.checked_at); + const time = Number.isNaN(when.getTime()) ? '' : `Checked ${when.toLocaleString()}. `; + if (report.status === 'passed') return `${time}Every check passed.`; + if (report.status === 'incomplete') return `${time}Some checks could not run, so this report is incomplete.`; + return `${time}Nothing was changed; review the checks with issues.`; +} + +/** D96: the concept's "Runs by category" rows `[category, runs, average rating]`. */ +export function categoryRows(statistics) { + return statistics.by_category.map(row => [ + row.category ?? 'Uncategorized', + number(row.runs), + row.average_rating === null || row.average_rating === undefined ? 'Not rated' + : `${Number(row.average_rating).toLocaleString(undefined, { maximumFractionDigits: 2 })} / 5`, + ]); +} + +/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics and the rated images). */ +export function totalsRows(statistics) { + const { totals } = statistics; + return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images], + ['Rated images', statistics.rated_images]].map(([label, value]) => [label, number(value)]); +} + +function table(caption, head, rows, empty) { + const node = el('table'); + const header = el('tr'); + for (const text of head) { + const cell = el('th', text); + cell.scope = 'col'; + header.append(cell); + } + const thead = el('thead'); + thead.append(header); + const tbody = el('tbody'); + for (const values of rows) { + const row = el('tr'); + row.append(...values.map(text => el('td', text))); + tbody.append(row); + } + if (rows.length === 0) { + const row = el('tr'); + const cell = el('td', empty); + cell.colSpan = head.length; + row.append(cell); + tbody.append(row); + } + node.append(el('caption', caption), thead, tbody); + return node; +} + +/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */ +export function statisticsNodes(statistics) { + return [ + table('Runs by category', ['Category', 'Runs', 'Avg. rating'], categoryRows(statistics), 'No prompts in the library yet.'), + table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''), + ]; +} + +/** The concept's health report body: `.operation-list` of `h3` + chip, the D95 summary, then Close / Review missing links. */ +export function healthNodes(report, { scope, onReviewMissing }) { + const list = el('div', undefined, 'operation-list'); + for (const [label, chip] of healthRows(report)) { + const row = el('div', undefined, 'operation'); + row.append(el('h3', label), el('span', chip, 'chip')); + list.append(row); + } + const close = createButton('Close'); + const review = createButton('Review missing links', 'ca-primary'); + scope.listen(close, 'click', () => closeWorkspaceDialog()); + scope.listen(review, 'click', () => onReviewMissing()); + const actions = el('div', undefined, 'dialog-actions'); + actions.append(close, review); + return [list, el('p', healthSummary(report), 'small'), actions]; +} + +/** Wait until the job `id` has ended; resolves its final state ('finished', 'failed', 'cancelled' or 'lost'). */ +export async function waitForJob(api, id, { signal, interval = JOB_POLL_MS, timers = globalThis } = {}) { + for (;;) { + let job; + try { + ({ job } = await api.get(`${JOBS_URL}/${encodeURIComponent(id)}`, { signal, cache: 'no-store' })); + } catch (error) { + if (error?.status === 404) return 'lost'; + throw error; + } + if (job?.state !== 'running') return job?.state ?? 'lost'; + await new Promise((resolve, reject) => { + const timer = timers.setTimeout(resolve, interval); + signal?.addEventListener('abort', () => { timers.clearTimeout(timer); reject(signal.reason); }, { once: true }); + }); + } +} + +/** + * Run the health check and resolve its report. A check already running (409 busy, another tab) is waited for instead + * of refused. Rejects when the check fails or ends without a report. + */ +export async function runHealthCheck(api, { signal, interval, timers } = {}) { + let id; + try { + ({ job: { id } } = await api.post(HEALTH_URL, {}, { signal })); + } catch (error) { + if (error?.status !== 409 || error?.code !== 'busy') throw error; + const { jobs } = await api.get(JOBS_URL, { signal, cache: 'no-store' }); + id = jobs.find(job => job.kind === HEALTH_KIND && job.state === 'running')?.id; + // The other check ended between the refusal and this read: its report is the latest. + } + const state = id ? await waitForJob(api, id, { signal, interval, timers }) : 'finished'; + if (state !== 'finished') throw new Error(HEALTH_FAILED_TEXT); + const { report } = await api.get(HEALTH_URL, { signal, cache: 'no-store' }); + if (!report) throw new Error(HEALTH_FAILED_TEXT); + return report; +} + +/** Open the Health report for `report` (the Maintenance notice's and, from S7, its Check action's dialog). */ +export function openHealthReport(report, { scope, trigger = null, navigate = navigateTo }) { + openContentDialog({ scope, title: 'Health report', trigger, nodes: healthNodes(report, { scope, + // Missing-link cleanup lives in Maintenance (its D94 hand-off until S7). + onReviewMissing: () => { closeWorkspaceDialog(); navigate('#/system/maintenance'); } }) }); +} + +/** + * The concept's Maintenance notice ("Check first. Review changes. Then apply." + "Run health checks", a689), which + * S2 delivers ahead of the rest of Maintenance (D-NEW-S2-5): the press runs the read-only check, then opens the report. + */ +export function healthNotice({ api, scope, navigate = navigateTo, notify = showWorkspaceToast }) { + const notice = el('div', undefined, 'notice'); + const text = el('div'); + text.append(el('h2', 'Check first. Review changes. Then apply.'), + el('p', 'One home for database and media maintenance. Scanning is separate from changing or removing records.')); + const run = createButton('Run health checks', 'ca-primary'); + notice.append(text, run); + scope.listen(run, 'click', async () => { + if (run.getAttribute('aria-disabled') === 'true') return; + run.setAttribute('aria-disabled', 'true'); + run.textContent = 'Checking…'; + try { + const report = await runHealthCheck(api, { signal: scope.signal }); + if (scope.signal.aborted) return; + openHealthReport(report, { scope, trigger: run, navigate }); + } catch (error) { + if (!scope.signal.aborted) notify(HEALTH_FAILED_TEXT); + } finally { + if (!scope.signal.aborted) { + run.removeAttribute('aria-disabled'); + run.textContent = 'Run health checks'; + } + } + }); + return notice; +} + +function metricsNode(overview) { + const metrics = el('div', undefined, 'summary-metrics'); + for (const [value, label] of metricValues(overview)) { + const metric = el('div', undefined, 'metric'); + metric.append(el('strong', value), el('span', label)); + metrics.append(metric); + } + return metrics; +} + +function surface(heading, rows) { + const section = el('section', undefined, 'surface'); + section.append(el('h2', heading), ...rows); + return section; +} + +function overviewNodes(overview, { scope, navigate, openStatistics }) { + const go = section => () => navigate(`#/system/${section}`); + const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review), + description: Number.isSafeInteger(overview.needs_review?.pending) + ? 'Review metadata before assigning a positive / negative pair.' : NOT_AVAILABLE, + label: 'Review metadata', onClick: () => {} }); + // D93 (Q6): the review queue (Needs review) is not part of the workspace yet. + review.querySelector('button').setAttribute('aria-disabled', 'true'); + const [sourcesTitle, sourcesDescription] = sourcesCopy(overview.sources); + const health = surface('Library health', [ + review, + operationRow({ scope, title: 'Backup status', description: backupText(overview.backup), label: 'View backups', + onClick: go('backups') }), + // Until the thumbnail review dialog (S5), Review thumbnails opens Sources, where thumbnail generation lives. + operationRow({ scope, title: thumbnailTitle(overview.thumbnails), description: 'Generate only missing previews when necessary.', + label: 'Review thumbnails', onClick: go('sources') }), + ]); + const preferences = surface('Workspace preferences', [ + operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.', + label: 'Appearance', onClick: go('appearance') }), + operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }), + operationRow({ scope, title: 'Statistics', description: 'Runs and ratings by category, with library totals.', label: 'Explore', + onClick: openStatistics }), + ]); + const columns = el('div', undefined, 'two-col'); + columns.append(health, preferences); + return [metricsNode(overview), columns]; +} + +/** + * The Overview section's mount (`mountSystem`'s `sections.overview`): returns its loading line, which the server's + * answer replaces in place (the concept's metrics and columns sit directly in `.settings-layout`). A read that a newer + * one superseded, or one answering after the section left, paints nothing. + */ +export function mountOverview(container, { api, scope, navigate = navigateTo, notify = showWorkspaceToast, + latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) { + const overviewRead = latestOnly({ lifetime: scope.signal }); + const statisticsRead = latestOnly({ lifetime: scope.signal }); + let shown = []; + + function swap(nodes) { + const anchor = shown.find(node => node.isConnected); + if (anchor) anchor.before(...nodes); + for (const node of shown) node.remove(); + shown = nodes; + return nodes; + } + + async function openStatistics(button) { + try { + const statistics = await statisticsRead(signal => api.get(STATISTICS_URL, { signal, cache: 'no-store' })); + if (statistics === STALE || scope.signal.aborted) return; + openContentDialog({ scope, title: 'Usage statistics', trigger: button, nodes: statisticsNodes(statistics) }); + } catch (error) { + if (!scope.signal.aborted) notify(STATISTICS_FAILED_TEXT); + } + } + + function loading() { + const status = el('p', LOADING_TEXT, 'small'); + status.setAttribute('role', 'status'); + return [status]; + } + + async function load() { + try { + const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' })); + if (overview === STALE || scope.signal.aborted) return; + swap(overviewNodes(overview, { scope, navigate, openStatistics })); + } catch (error) { + if (scope.signal.aborted) return; + const alert = el('p', LOAD_FAILED_TEXT); + alert.setAttribute('role', 'alert'); + const retry = createButton('Retry'); + scope.listen(retry, 'click', () => { + swap(loading()); + load(); + }); + const panel = el('section', undefined, 'surface'); + panel.append(alert, retry); + swap([panel]); + } + } + const first = swap(loading()); + load(); + return first; +} diff --git a/web/js/workspace/system.mjs b/web/js/workspace/system.mjs index 60f1b81a..8459f8b9 100644 --- a/web/js/workspace/system.mjs +++ b/web/js/workspace/system.mjs @@ -4,7 +4,7 @@ * `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. + * per-section hand-off to its legacy tool (D94), never mock placeholders. Overview is native (system-overview.mjs). * * 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. @@ -13,6 +13,7 @@ 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'; const SETTINGS_URL = '/prompt_manager/settings'; const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' }); @@ -84,12 +85,21 @@ function markFailed(layout, store) { } } +/** + * The sections delivered so far: Overview (S2 Task 6), and Maintenance's notice with "Run health checks" above the + * hand-off to the maintenance tools that are not native yet (D-NEW-S2-5). + */ +export const DELIVERED_SECTIONS = Object.freeze({ + overview: mountOverview, + maintenance: (container, { api, scope }) => [healthNotice({ api, 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, scope, drafts}) => nodes`), and every other section hands off. */ -export function mountSystem(main, api, scope, { section, sections = {}, navigate = navigateTo }) { +export function mountSystem(main, api, scope, { section, sections = DELIVERED_SECTIONS, navigate = navigateTo }) { const store = systemDrafts(api); const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope, onSelect: id => navigate(`#/system/${id}`) }); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index a05ab12d..6cf7f6da 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -89,6 +89,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } not save is highlighted, as its "Review the highlighted settings" status says. */ .save-row button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D92 */ } .setting-row [aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D92 */ } +/* D93: an Overview row whose action is not available yet (the review queue, Q6) keeps its button, disabled. */ +.operation .row-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D93 */ } @media (max-width: 700px) { .stat-grid { gap: 8px; } .stat { padding: 16px; }