diff --git a/TESTING.md b/TESTING.md index c345ef7a..2191fb78 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1407,6 +1407,8 @@ is shorter than the row it reserves. Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source, so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts. +System › Overview's Images metric reads the same shell count (#555), so it, the Overview card and the Images badge show +one number; its Statistics dialog labels the legacy row counts as "Database records". Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`. ### Images page controls and recorded deviations (#249 PR C) @@ -4006,7 +4008,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati "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 + and active saved negatives and the Images count (#555, amending Q2's indexed + `generated_images` rows): the shell's Images sidebar count, the Images page's own + total, which also lists files no row links, as the concept's `images.length` is + both. A visit re-reads it unless a read is in flight; "–" until read or after a + failed read. `images.indexed` stays in the API and is not shown. "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". The review row is "N ambiguous image links" (the Needs review queue's pending count, @@ -4044,10 +4050,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati - **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the concept's "Runs by category" table without its Avg. rating column (Category / Runs) with the real runs of eligible parents per category ("Uncategorized"), then a second - table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy - statistics; no Rated images). The API still reports `average_rating` and + table "Database records" (Total / Count: Prompt records, Tags on prompts, Image + records: the raw row counts of the legacy statistics, labelled as rows so they never + read as the Library or Images page counts, #555; no Rated images). The API still reports `average_rating` and `rated_images`; the workspace does not show them. - The Overview row says "Runs by category, with library totals." because + The Overview row says "Runs by category, with database record counts." because that is what the dialog shows (the concept's own dialog shows only "Runs by category"); per-prompt run counts, prompt tags and dated runs are recorded, but no table of them exists yet. A failed read toasts "Statistics could not @@ -5413,7 +5420,7 @@ list stay unscoped: any row's control proves them. | Worker threads | `admin.html:437–445` | S4 Sources | covered | `sources` · slider · "Worker threads" | worker range max is cpu_count (workspace-system-sources.spec.mjs) | | LoraManager badge, enable, path, trigger words, CivitAI key, clear key | `admin.html:448–490`; `admin.js:1052–1156` | S10 Integrations | covered | `integrations` · switch · "LoraManager integration" | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) | | LoRA data import with progress | `admin.html:492–498,517–541`; `admin.js:1157` | S10 Integrations | covered | `integrations` · button · "Review import" | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) | -| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and library totals (workspace-system.spec.mjs) | +| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and database record counts (workspace-system.spec.mjs) | | Export components JSON `/export` | `admin.html:163`; `admin.js:1607`; `py/api/prompts.py:238` | S9 Backup & export | covered | `backups` · button · "Export all prompts JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) | | Saved pairs export | `py/api/pairs.py:84` | S9 Backup & export | covered | `backups` · button · "Export saved pairs JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) | | Library export | `py/api/library.py:223` | S9 Backup & export | covered | `backups` · button · "Export JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) | diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index 37b66f78..6018d5ce 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -162,13 +162,15 @@ async function serverJson(request, url) { } const number = value => value.toLocaleString('en-US'); +// The sidebar's Images count (state.mjs), which System's Images metric shows (#555). +const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1'; /** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */ -function mapOverviewConcept(reference, overview) { +function mapOverviewConcept(reference, overview, images) { // D165: the discreet legacy-pages line follows the settings layout. reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] }; 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) }; + 'Negative variants': number(overview.library.negative_variants), Images: number(images) }; const rows = { // D93: the real pending count; Review metadata opens Needs review (the concept's go-review). '3 ambiguous image links': row => ({ ...row, @@ -179,8 +181,9 @@ function mapOverviewConcept(reference, overview) { '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 by category and library totals, not prompt, tag or date usage (D153: no ratings). - Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }), + // D96: the dialog shows runs by category and database record counts, not prompt, tag or date usage (D153: no + // ratings; #555: the counts are labelled as records, not as the Library or Images page counts). + Statistics: row => ({ ...row, description: 'Runs by category, with database record counts.' }), }; const surfaces = reference.columns.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) })); @@ -218,6 +221,8 @@ async function conceptOverview(page, theme) { async function productionOverview(page, theme) { await openProduction(page, 'system/overview', theme); await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible(); + // #555: the Images metric is the shell's Images count: compare once it was read. + await expect(page.locator('#workspace-main .summary-metrics > .metric:nth-child(3) strong')).toHaveText(/^[\d,]+$/); // The thumbnail row is its own read (I2): compare once it answered. await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails'); // So is the backup row (S9). @@ -231,6 +236,7 @@ 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'); + const images = (await serverJson(request, IMAGES_COUNT_URL)).total; // The fixture's thumbnails all exist, so the concept's "No missing thumbnails" is also the real title. expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).pending).toBe(0); const concept = await conceptOverview(page, theme); @@ -239,7 +245,7 @@ for (const theme of THEMES) for (const [width, height] of SIZES) { 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(diffInventory(mapOverviewConcept(concept, overview, images), production)).toEqual([]); expect(production.pageScroll).toBe(false); }); } @@ -265,8 +271,9 @@ const UNMAPPED_PATHS = [ 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 images = (await serverJson(request, IMAGES_COUNT_URL)).total; const concept = await conceptOverview(page, 'dark'); - const expected = mapOverviewConcept(concept, overview); + const expected = mapOverviewConcept(concept, overview, images); const production = await productionOverview(page, 'dark'); expect(diffInventory(expected, production)).toEqual([]); // Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else. @@ -290,7 +297,7 @@ test('system comparison detects mutations', async ({ page, request }) => { }); /** - * D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the + * D96: the concept's table, its rows the real ones; then "Database records" (#555) in the same table markup. D153 (#447): the * concept's "Avg. rating" column and a "Rated images" total are not drawn. */ function mapStatisticsConcept(reference, statistics) { @@ -302,9 +309,10 @@ function mapStatisticsConcept(reference, statistics) { tables: [ { ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'), rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) }, - { 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)]] }, + { caption: 'Database records', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']], + rows: [['Prompt records', number(statistics.totals.total_prompts)], + ['Tags on prompts', number(statistics.totals.total_tags)], + ['Image records', number(statistics.totals.total_images)]] }, ], }; } @@ -420,7 +428,7 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path)) .toEqual(['operations.rows.0.title']); await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity check'; }); - // And a removed "Library totals" table and a changed caption in the statistics dialog. + // And a removed "Database records" 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(); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index 98ca5e6a..c4bf90cc 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -8,6 +8,8 @@ import { readCoverageTable, parseControl, CONTROL_EXEMPTIONS } from '../../tools const workspace = '/prompt_manager/workspace'; const OVERVIEW_URL = '/prompt_manager/system/overview'; const THUMBNAILS_URL = '/prompt_manager/images/thumbnails/status'; +// The sidebar's Images count (state.mjs NAV_COUNT_SOURCES): the Images page's own total. +const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1'; const number = value => value.toLocaleString('en-US'); test.beforeEach(async ({ page, request, browserName }) => { @@ -55,6 +57,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) => page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); }); const overview = await serverJson(request, OVERVIEW_URL); const thumbnails = await serverJson(request, THUMBNAILS_URL); + const images = (await serverJson(request, IMAGES_COUNT_URL)).total; // The concept fixture: six positives, their saved negatives and 24 linked images, all with thumbnails. expect([overview.library.positive_prompts, overview.images.indexed, thumbnails.pending]).toEqual([6, 24, 0]); // I2: the Overview never walks the sources for thumbnails; the row reads the status route itself. @@ -66,7 +69,8 @@ test('overview shows real counts and Not checked', async ({ page, request }) => [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'], + // #555: the Images page's own count, the same number as its sidebar badge. + [number(images), 'Images'], ['Not checked', 'Database integrity'], ]); expect(await rowsOf(layout, 'Library health')).toEqual([ @@ -79,11 +83,31 @@ test('overview shows real counts and Not checked', async ({ page, request }) => 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 by category, with library totals.', 'Explore'], + ['Statistics', 'Runs by category, with database record counts.', 'Explore'], ]); expect(writes).toEqual([]); }); +test('#555: the Images metric is the sidebar Images count, not the indexed image rows', async ({ page, request }) => { + const overview = await serverJson(request, OVERVIEW_URL); + // The Images page lists files no row links (the demo library: 122 files, 110 rows); the listing says so. + const listed = overview.images.indexed + 12; + await page.route(`**${IMAGES_COUNT_URL}`, async route => { + const response = await route.fetch(); + await route.fulfill({ response, json: { ...(await response.json()), total: listed } }); + }); + const layout = await openOverview(page); + const imagesMetric = layout.locator('.summary-metrics > .metric').filter({ has: page.locator('span', { hasText: /^Images$/ }) }); + await expect(imagesMetric.locator('strong')).toHaveText(number(listed)); + await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText(number(listed)); + // A failed count reads as a dash, never the row count. + await page.unroute(`**${IMAGES_COUNT_URL}`); + await page.route(`**${IMAGES_COUNT_URL}`, route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } })); + await page.goto(`${workspace}#/system/appearance`); + await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click(); + await expect(imagesMetric.locator('strong')).toHaveText('–'); +}); + // D129 (#249 S6b): where the library lives. The hook makes the fixture's database read as a node-folder library; its // Move library is a stand-in that touches no file (workspace_system_fixture.py). const banner = page => page.locator('#library-banner'); @@ -364,7 +388,7 @@ test('View backups / Appearance / Manage sources switch sections', async ({ page } }); -test('statistics dialog lists runs by category and library totals', async ({ page, request }) => { +test('statistics dialog lists runs by category and database record counts', async ({ page, request }) => { const statistics = await serverJson(request, '/prompt_manager/system/statistics'); expect(statistics.by_category.length).toBeGreaterThan(1); const layout = await openOverview(page); @@ -373,13 +397,14 @@ test('statistics dialog lists runs by category and library totals', async ({ pag 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.locator('caption')).toHaveText(['Runs by category', 'Database records']); // D153 (#447): no rating column and no rated-images total. await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']); 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)])); - 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)]]); + // #555: raw rows of the legacy /stats, labelled as rows so they never read as the Library or Images page counts. + expect(await cells(tables.nth(1))).toEqual([['Prompt records', number(statistics.totals.total_prompts)], + ['Tags on prompts', number(statistics.totals.total_tags)], ['Image records', number(statistics.totals.total_images)]]); await page.keyboard.press('Escape'); await expect(explore).toBeFocused(); // A statistics read that fails says so; the Overview stays. diff --git a/tests/js/workspace-system-overview.test.js b/tests/js/workspace-system-overview.test.js index e6b07802..39a40abd 100644 --- a/tests/js/workspace-system-overview.test.js +++ b/tests/js/workspace-system-overview.test.js @@ -16,12 +16,22 @@ const OVERVIEW = Object.freeze({ test('metrics are the server counts and Not checked before any health check', async () => { const { metricValues } = await overviewModule; - assert.deepEqual(metricValues(OVERVIEW), [ + assert.deepEqual(metricValues(OVERVIEW, 24), [ [(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'], ['Not checked', 'Database integrity'], ]); }); +test('#555: the Images metric is the Images page count (the sidebar badge), not the indexed rows', async () => { + const { metricValues } = await overviewModule; + // The demo library: 110 indexed rows, 122 files on the Images page (unlinked ones included). + const overview = { ...OVERVIEW, images: { indexed: 110, rated: 0 } }; + assert.deepEqual(metricValues(overview, 122)[2], [(122).toLocaleString(), 'Images']); + // Not read yet (undefined) or failed (null): a dash, as on the workspace Overview, never the row count. + assert.deepEqual(metricValues(overview, undefined)[2], ['–', 'Images']); + assert.deepEqual(metricValues(overview, null)[2], ['–', 'Images']); +}); + test('metric 4 is the last health result', async () => { const { integrityText } = await overviewModule; assert.equal(integrityText(null), 'Not checked'); @@ -150,7 +160,7 @@ test('statistics rows are the real runs, then the library totals, without rating rated_images: 20, }; assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]); - assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]); + assert.deepEqual(totalsRows(statistics), [['Prompt records', '12'], ['Tags on prompts', '15'], ['Image records', '24']]); }); /** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */ diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 3022285d..1b257bbd 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -176,6 +176,12 @@ function mountWorkspace() { function notifyCounts() { for (const listener of countListeners) listener(); } /** Re-read the given counts unless a read is already in flight. */ function freshenCounts(ids) { refreshCounts(ids.filter(id => !countRequests.has(id))); } + /** #555: the counts as System › Overview reads them, so its Images metric and the Images badge cannot differ. */ + const sharedCounts = Object.freeze({ + get: id => knownCounts.get(id), + subscribe(listener) { countListeners.add(listener); return () => countListeners.delete(listener); }, + freshen: freshenCounts, + }); /** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */ function setCount(id, count) { countRequests.get(id)?.abort(); @@ -488,7 +494,7 @@ function mountWorkspace() { if (route.id === 'system') { // The concept's System (app.js renderSystem): its heading description, section nav and Save row. description.textContent = route.description; - system = mountSystem(main, api, view, { section: route.section, activity }); + system = mountSystem(main, api, view, { section: route.section, activity, counts: sharedCounts }); return; } if (route.id === 'inspect') { diff --git a/web/js/workspace/system-overview.mjs b/web/js/workspace/system-overview.mjs index 033ea2d1..7356213d 100644 --- a/web/js/workspace/system-overview.mjs +++ b/web/js/workspace/system-overview.mjs @@ -11,8 +11,10 @@ * D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs). * #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the * configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131). - * - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or - * rated-images total). + * - #555: metric 3, "Images", is the shell's Images count (the Images page's total, its sidebar badge), not the + * overview's indexed rows, which leave out files no row links: a dash until it is read or when its read failed. + * - D96: the Statistics dialog lists the real runs per category, then "Database records" (D153: no rating column or + * rated-images total; #555: the legacy row counts, labelled as records). * - 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", D123) as a * read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends. @@ -61,12 +63,20 @@ export function integrityText(health) { return health.status === 'passed' ? 'Passed' : 'Incomplete'; } -/** The concept's four metrics `[value, label]`, in its order. */ -export function metricValues(overview) { +/** #555: the Images metric's value from the shell's Images count; a dash while unread (undefined) or failed (null). */ +export function imagesText(images) { + return Number.isSafeInteger(images) && images >= 0 ? number(images) : '–'; +} + +/** + * The concept's four metrics `[value, label]`, in its order. `images` is the shell's Images count (#555): the concept's + * `images.length`, the same number as the Images sidebar badge. + */ +export function metricValues(overview, images) { return [ [number(overview.library.positive_prompts), 'Positive prompts'], [number(overview.library.negative_variants), 'Negative variants'], - [number(overview.images.indexed), 'Images'], + [imagesText(images), 'Images'], [integrityText(overview.health), 'Database integrity'], ]; } @@ -174,10 +184,15 @@ export function categoryRows(statistics) { return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]); } -/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */ +/** + * D96: the added "Database records" rows `[label, count]` (the legacy statistics; D153 drops the rated images). #555: + * they count raw rows (every prompt row, tags on a prompt, every image row), so they say so and never read as the + * Library or Images page counts. + */ export function totalsRows(statistics) { const { totals } = statistics; - return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]] + return [['Prompt records', totals.total_prompts], ['Tags on prompts', totals.total_tags], + ['Image records', totals.total_images]] .map(([label, value]) => [label, number(value)]); } @@ -208,11 +223,11 @@ function table(caption, head, rows, empty) { return node; } -/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */ +/** The Statistics dialog: "Runs by category" (the concept's table), then "Database records" (D96, #555). */ export function statisticsNodes(statistics) { return [ table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'), - table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''), + table('Database records', ['Total', 'Count'], totalsRows(statistics), ''), ]; } @@ -332,9 +347,9 @@ export function healthNotice({ api, activity, scope, navigate = navigateTo, noti return notice; } -function metricsNode(overview) { +function metricsNode(overview, images) { const metrics = el('div', undefined, 'summary-metrics'); - for (const [value, label] of metricValues(overview)) { + for (const [value, label] of metricValues(overview, images)) { const metric = el('div', undefined, 'metric'); metric.append(el('strong', value), el('span', label)); metrics.append(metric); @@ -349,7 +364,7 @@ function surface(heading, rows) { } /** The Overview's nodes, and its thumbnail row (painted by its own read). */ -function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails }) { +function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails, images }) { const go = section => () => navigate(`#/system/${section}`); const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review), description: Number.isSafeInteger(overview.needs_review?.pending) @@ -374,26 +389,36 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS 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 by category, with library totals.', label: 'Explore', + operationRow({ scope, title: 'Statistics', description: 'Runs by category, with database record counts.', label: 'Explore', onClick: openStatistics }), ]); const columns = el('div', undefined, 'two-col'); columns.append(health, preferences); - return { nodes: [metricsNode(overview), columns], thumbnails, backup: backupRow }; + const metrics = metricsNode(overview, images); + return { nodes: [metrics, columns], thumbnails, backup: backupRow, metrics }; } /** * 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. + * + * `counts` is the shell's sidebar counts (`{get(id), subscribe(listener), freshen(ids)}`, index.mjs): the Images metric + * shows its `images` count and follows it, and a visit re-reads it as the workspace Overview does (#555). */ -export function mountOverview(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast, - latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) { +export function mountOverview(container, { api, activity, scope, counts = null, navigate = navigateTo, + notify = showWorkspaceToast, latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) { const overviewRead = latestOnly({ lifetime: scope.signal }); const statisticsRead = latestOnly({ lifetime: scope.signal }); const thumbnailsRead = latestOnly({ lifetime: scope.signal }); const backupRead = latestOnly({ lifetime: scope.signal }); let shown = []; + // #555: the Images metric's value node, repainted whenever the shell's Images count changes. + let imagesValue = null; + const imagesCount = () => counts?.get('images'); + const paintImages = () => { + if (imagesValue) imagesValue.textContent = imagesText(imagesCount()); + }; // The backup status (undefined while read, null when it failed), painted into its row once both are there (S9). let backupStatus; let backupRow = null; @@ -439,8 +464,10 @@ export function mountOverview(container, { api, activity, scope, navigate = navi try { const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' })); if (overview === STALE || scope.signal.aborted) return; - const { nodes, thumbnails, backup } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, + const { nodes, thumbnails, backup, metrics } = overviewNodes(overview, { api, activity, notify, scope, navigate, + openStatistics, images: imagesCount(), openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) }); + imagesValue = metrics.children[2].querySelector('strong'); thumbnailRow = thumbnails; backupRow = backup; sources = overview.sources; @@ -488,6 +515,10 @@ export function mountOverview(container, { api, activity, scope, navigate = navi const first = swap(loading()); // D129: a finished Move library changes where the library lives. scope.cleanup(onMoveEnded(activity, load)); + if (counts) { + scope.cleanup(counts.subscribe(paintImages)); + counts.freshen(['images']); + } load(); loadThumbnails(); loadBackup(); diff --git a/web/js/workspace/system.mjs b/web/js/workspace/system.mjs index 8f144a65..d769f809 100644 --- a/web/js/workspace/system.mjs +++ b/web/js/workspace/system.mjs @@ -135,7 +135,7 @@ export const DELIVERED_SECTIONS = Object.freeze({ /** * Mount System under the heading `renderHeading` wrote in `main`. Returns `{show(section)}`, which shows another * section in place. `scope` is the page's view scope; `sections` maps a section id to a delivered section's mount - * (`(container, {api, activity, scope, drafts}) => nodes`) for every section. `activity` is the page's Activity store, which follows the jobs a section starts. + * (`(container, {api, activity, scope, drafts, counts}) => nodes`; `counts` is the shell's sidebar counts, #555) for every section. `activity` is the page's Activity store, which follows the jobs a section starts. * * The host values are read on mount (and by the store again after a conflict), so a save carries the current revision; * until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on @@ -159,7 +159,8 @@ function legacyPagesLine() { return line; } -export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) { +export function mountSystem(main, api, scope, { section, activity, counts = null, sections = DELIVERED_SECTIONS, + navigate = navigateTo }) { const store = systemDrafts(api); jobs = activity; store.reloadHost(); @@ -208,7 +209,7 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next)); while (layout.firstChild !== saveRow) layout.firstChild.remove(); // Every section is delivered (S12), and the router only names known sections (state.mjs). - const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store }); + const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store, counts }); layout.prepend(...nodes); if (next === 'overview') main.append(legacyPages); else legacyPages.remove();