diff --git a/TESTING.md b/TESTING.md index d0c2272a..27ec459d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1352,8 +1352,8 @@ targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) f the selection bar's tagging shows is the concept's or the plan's (D85, D90, D91). - **D89** (plan D66, Activity for the `image-tags` job): the job's Activity item and its view are the scan's (D54, D56, D57, D59) with the job kind's own counters, from one - per-kind table (`JOB_VIEWS` in `activity-store.mjs`; a kind without one keeps the - scan's tiles under "Job log" and "Cancel job"). The titles are the server's: "Add + per-kind table (`activity-kinds.mjs` since S5, D111; a kind without one gets no + tiles, "Job log" and "Cancel job"). The titles are the server's: "Add tags to images" and "Auto-tag images". - The pill's verb is "Tagging" ("Tagging · 41% · 8,240 / 19,622"). - The item line and the view's heading line read "Started HH:MM · N images · closing @@ -1386,7 +1386,7 @@ targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) f finished · N tagged · M errors", " cancelled · N tagged" or "<title> failed · N tagged · M errors". - Tests: `tests/js/workspace-activity-store.test.js` ("image-tags jobs get their own - tiles outcomes and pill verb", "unknown kinds keep the generic view"); + tiles outcomes and pill verb", "unknown kinds get the generic view without the scan tiles"); `tests/browser/workspace-activity.spec.mjs` ("an image tagging job shows Tagged Unchanged Skipped Errors and opens tagged rows", "cancel tagging stops at a file boundary", "the report download names the kind", on fake `image-tags` jobs from @@ -2031,7 +2031,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged `-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no reader can keep it byte-identical (`test_health_check_never_writes`). -Recorded deviations (plan D60–D66 and D81 renumbered D92–D98 and D113; D121–D128 assigned): +Recorded deviations (plan D60–D66, D79–D81 renumbered D92–D98 and D111–D113; D121–D128 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 @@ -2111,6 +2111,26 @@ Recorded deviations (plan D60–D66 and D81 renumbered D92–D98 and D113; D121 legacy gallery's `/gallery/rescan` and toasts "Files refreshed. N files in M output folders." or "Files could not be refreshed. Try again.". The concept's "sample path" note is prototype copy and is dropped. +- **D111** (Activity, S5): each job kind's pill verb, toasts, line, tiles, outcomes, + openable rows and labels come from one descriptor table (`activity-kinds.mjs`; + `activity-store.mjs` re-exports it). The scan's (D56) and image tagging's (D89) are + unchanged. A thumbnail job reads "Generating thumbnails · N% · a / b" in the pill, + counts Generated (green), Skipped and Errors (red), lists Generated, Errors, Skipped + (Generated and Skipped rows open the viewer), says "Thumbnails finished · N generated · + M errors" ("cancelled · N generated", "failed · …") and keeps "Job log" and "Cancel + job". Every other kind (the health check, a kind this page does not know) gets the + generic descriptor: title-based toasts and no tiles or Results, so it never shows the + scan's zero tiles (S1 review M6, S2 review M3). A running job without a known total + has an indeterminate `<progress>` (no `value`) and a pill without a percentage. Only a + running job the server can cancel shows Cancel. Tests: + `tests/js/workspace-activity-kinds.test.js`, and in `workspace-activity.spec.mjs` "a + thumbnail job shows Generated Skipped Errors, its pill verb and its toasts" and "a held + legacy job and the health check show no tiles, no Results and no made-up percentage". +- **D112** (Activity, S5): a legacy-page operation held on the registry (not + cancellable, no outcome groups) reads as its server title ("Database restore (admin + page)", "Thumbnail clear (gallery page)"), with no tiles, no Results, no Cancel, an + indeterminate bar while it runs and "<title> finished." when it ends, whatever its + kind (the gallery's clear is a `thumbnails` hold). - **D113** (Sources): the Worker threads range ends at the server's `cpu_count` (the concept's fixed `max=16`) and shows the server's real `worker_threads` (the concept's sample 4). diff --git a/tests/browser/workspace-activity.spec.mjs b/tests/browser/workspace-activity.spec.mjs index 72e95f28..7dfd2575 100644 --- a/tests/browser/workspace-activity.spec.mjs +++ b/tests/browser/workspace-activity.spec.mjs @@ -1008,3 +1008,77 @@ test('the report download names the kind', async ({ page, request }) => { expect(report.outcomes.tagged.rows.map(row => [row.path, row.detail])).toEqual([['sub/a.png', 'Added 1 tag']]); await expect(toast(page)).toHaveText('Job report downloaded.'); }); + +// D111 (System S5): each kind's own descriptor. A thumbnail job counts Generated (green), Skipped and Errors (red), +// its pill says "Generating thumbnails" and its toasts "Thumbnails finished · N generated · M errors". +const THUMB_JOB = { kind: 'thumbnails', total: 10, output_folders: 2 }; + +test('a thumbnail job shows Generated Skipped Errors, its pill verb and its toasts', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, THUMB_JOB); + await step(request, job.id, { processed: 4, records: [ + { group: 'generated', path: 'sub/a.png', root: 'output', detail: 'Preview written' }, + { group: 'skipped', path: 'b.png', root: 'input', detail: 'Preview already up to date' }, + { group: 'errors', path: 'broken.png', root: 'output', detail: 'Not a valid image file' }, + ] }); + await page.goto(`${workspace}#/images`); + await expect(pill(page)).toHaveText('Generating thumbnails · 40% · 4 / 10'); + await page.goto(`${workspace}#/activity`); + await expect(items(page).first().locator('p')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/); + await expect(items(page).first().locator('progress')).toHaveAttribute('value', '40'); + await openJob(page, job.id); + await expect(heading(page).locator('h1')).toHaveText('Thumbnail generation'); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel job']); + expect(await tileValues(page)).toEqual([['stat added', 'Generated', '1'], ['stat', 'Skipped', '1'], ['stat bad', 'Errors', '1']]); + await expect(outcomeButtons(page)).toHaveText(['Generated1', 'Errors1', 'Skipped1']); + await expect(fileRows(page).first().locator('.path')).toHaveText('sub/a.png'); + expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('button'); + await outcomeButtons(page).filter({ hasText: 'Errors' }).click(); + expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('div'); + await expect(scanLog(page).locator('summary')).toHaveText('● Job log — raw detail, click to show'); + + await finish(request, job.id); + await expect(toast(page)).toHaveText('Thumbnails finished · 1 generated · 1 error'); + await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 1 generated · 1 error$/); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']); +}); + +test('a held legacy job and the health check show no tiles, no Results and no made-up percentage', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const response = await request.post('/__test__/jobs/hold', + { data: { kind: 'thumbnails', title: 'Thumbnail clear (gallery page)', admission: 'guarded' } }); + await expect(response).toBeOK(); + const held = (await response.json()).job; + await page.goto(`${workspace}#/images`); + // D112: the pill names the held operation and shows no percentage. + await expect(pill(page)).toHaveText('Thumbnail clear (gallery page)'); + await page.goto(`${workspace}#/activity`); + const item = items(page).filter({ has: page.getByRole('link', { name: /Thumbnail clear \(gallery page\)$/ }) }); + await expect(item.locator('p')).toHaveText(/^Started \d\d:\d\d · closing this view does not cancel it$/); + await expect(item.locator('progress')).toHaveCount(1); + await expect(item.locator('progress')).not.toHaveAttribute('value'); + await expect(item.getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0); + await item.getByRole('link').click(); + await expect(heading(page).locator('h1')).toHaveText('Thumbnail clear (gallery page)'); + await expect(progressSection(page).locator('progress')).not.toHaveAttribute('value'); + await expect(progressSection(page).locator('dl.stat-row')).toHaveCount(0); + await expect(resultsSection(page)).toHaveCount(0); + await expect(scanLog(page)).toHaveCount(1); + await finish(request, held.id); + await expect(toast(page)).toHaveText('Thumbnail clear (gallery page) finished.'); + await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d$/); + await expect(progressSection(page).locator('progress')).toHaveAttribute('value', '100'); + + // The health check (a read job of its own kind): its status line, no tiles, no Cancel. + const health = await startJob(request, { kind: 'health', total: 0 }); + await openJob(page, health.id); + await expect(heading(page).locator('h1')).toHaveText('Health check'); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Checking the database and image links'); + await expect(progressSection(page).locator('progress')).not.toHaveAttribute('value'); + await expect(progressSection(page).locator('dl.stat-row')).toHaveCount(0); + await expect(resultsSection(page)).toHaveCount(0); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']); + await expect(pill(page)).toHaveText('Health check'); + await finish(request, health.id); + await expect(toast(page)).toHaveText('Health check finished.'); +}); diff --git a/tests/browser/workspace_activity_fixture.py b/tests/browser/workspace_activity_fixture.py index 41dba88d..b7497ab6 100644 --- a/tests/browser/workspace_activity_fixture.py +++ b/tests/browser/workspace_activity_fixture.py @@ -5,8 +5,11 @@ reading files, so a test sets its progress and counters exactly: - ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``, ``output_folders``; for ``kind: "image-tags"`` the image tagging job's - groups and its ``{mode, images}`` scope instead) and returns its summary, - or 409 while one runs; + groups and its ``{mode, images}`` scope instead; for ``kind: + "thumbnails"`` the thumbnail job's groups, ``guarded`` admission and its + ``{mode, size, quality, output_folders}`` scope; for ``kind: "health"`` the + health check's: no groups, ``read``, not cancellable, with its status + line) and returns its summary, or 409 while one runs; - ``POST /__test__/jobs/{id}/step`` records ``records`` (``{group, path, root, root_index, detail}`` file outcomes, counted, listed and logged as the scan records them) and ``bulk`` (``{group, count, root}``: ``count`` numbered @@ -14,9 +17,10 @@ reading files, so a test sets its progress and counters exactly: the progress to every subscriber; - ``POST /__test__/jobs/{id}/finish`` ends it as ``finished``, ``cancelled`` or ``failed``; a cancel through the real route ends it ``cancelled``; -- ``POST /__test__/jobs/hold`` opens a legacy-page hold (``kind``, ``title``): - a running, non-cancellable ``records`` job, as a legacy restore or - maintenance request takes, until ``finish`` ends it; +- ``POST /__test__/jobs/hold`` opens a legacy-page hold (``kind``, ``title``, + ``admission``, default ``records``): a running, non-cancellable job with no + outcome groups, as a legacy restore, maintenance or thumbnail clear request + takes, until ``finish`` ends it; - ``POST /__test__/jobs/restart`` acts out a ComfyUI restart: the open job streams drop without a final event and the registry starts empty. """ @@ -35,6 +39,9 @@ PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"} PREFIXES["no_metadata"] = "-" # The image tagging job's groups (#249 PR D), logged with the runner's prefixes. PREFIXES.update({"tagged": "+", "unchanged": "=", "skipped": "-"}) +# The thumbnail job's (#249 System S5). +PREFIXES["generated"] = "+" +HEALTH_STATUS_LINE = "Checking the database and image links" ROOT_INDEXES = {"output": 0, "input": 1} @@ -80,9 +87,12 @@ def outcome_records(data): def register_activity_commands(app, routes, api): from py.api.jobs import ( + HEALTH_JOB_KIND, IMAGE_TAG_GROUP_NAMES, IMAGE_TAG_JOB_KIND, SCAN_GROUP_NAMES, + THUMBNAIL_GROUP_NAMES, + THUMBNAILS_JOB_KIND, JobBusy, JobRegistry, ) @@ -138,7 +148,21 @@ def register_activity_commands(app, routes, api): data = await body(request) ending = asyncio.get_running_loop().create_future() kind = str(data.get("kind", "scan")) - if kind == IMAGE_TAG_JOB_KIND: + admission, cancellable = "records", True + if kind == THUMBNAILS_JOB_KIND: + title = "Thumbnail generation" + scope = { + "mode": str(data.get("mode", "missing")), + "size": count(data.get("size"), 512), + "quality": str(data.get("quality", "medium")), + "output_folders": count(data.get("output_folders"), 2), + } + groups = THUMBNAIL_GROUP_NAMES + admission = "guarded" + elif kind == HEALTH_JOB_KIND: + title, scope, groups = "Health check", {}, () + admission, cancellable = "read", False + elif kind == IMAGE_TAG_JOB_KIND: title = "Add tags to images" scope = { "mode": str(data.get("mode", "add")), @@ -156,7 +180,8 @@ def register_activity_commands(app, routes, api): job = api._jobs.start( kind, str(data.get("title", title)), - record_changing=True, + admission=admission, + cancellable=cancellable, scope=scope, runner=fake_runner(ending), groups=groups, @@ -164,6 +189,8 @@ def register_activity_commands(app, routes, api): except JobBusy: raise web.HTTPConflict() job.update(total=count(data.get("total"))) + if kind == HEALTH_JOB_KIND: + job.update(status_line=HEALTH_STATUS_LINE) endings[job.id] = ending return web.json_response({"success": True, "job": job.summary()}) @@ -178,7 +205,7 @@ def register_activity_commands(app, routes, api): async with api._jobs.hold( str(data.get("kind", "restore")), str(data.get("title", "Database restore (admin page)")), - admission="records", + admission=str(data.get("admission", "records")), ) as job: entered.set_result(job) state = await ending diff --git a/tests/js/workspace-activity-kinds.test.js b/tests/js/workspace-activity-kinds.test.js new file mode 100644 index 00000000..5cd4c8ca --- /dev/null +++ b/tests/js/workspace-activity-kinds.test.js @@ -0,0 +1,148 @@ +// Per-kind Activity descriptors (#249 System S5 Task 13, D111): each job kind's pill verb, toasts, line, tiles, +// outcomes, openable rows and labels, from one table (web/js/workspace/activity-kinds.mjs). +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const source = import('../../web/js/workspace/activity-kinds.mjs'); +const store = import('../../web/js/workspace/activity-store.mjs'); + +const ID = 'a'.repeat(32); + +function job(overrides = {}) { + return { id: ID, kind: 'scan', title: 'Scan for metadata', state: 'running', started_at: 1, finished_at: null, + progress: 42, status_line: '', processed: 8240, total: 19622, + counters: { added: 1, known: 0, review: 0, no_metadata: 0, errors: 1 }, scope: {}, listed: {}, log_length: 0, + cancellable: true, ...overrides }; +} + +test('scan descriptors are unchanged', async () => { + const { kindOf } = await source; + const scan = kindOf('scan'); + assert.equal(scan.pillVerb, 'Scanning'); + assert.equal(scan.startToast(job()), 'Scan started.'); + assert.deepEqual(scan.tiles, [['added', 'Added to Library', 'added'], ['known', 'Already known', ''], + ['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]); + assert.deepEqual(scan.outcomes, [['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], + ['no_metadata', 'No usable metadata'], ['known', 'Already known']]); + assert.deepEqual([...scan.openable].sort(), ['added', 'known']); + assert.deepEqual([scan.log, scan.cancelLabel], ['Scan log', 'Cancel scan']); + assert.equal(scan.finishToast(job({ state: 'finished' })), 'Scan finished · 1 added · 1 error'); + assert.equal(scan.finishToast(job({ state: 'cancelled' })), 'Scan cancelled · 1 added'); + assert.equal(scan.finishToast(job({ state: 'failed' })), 'Scan failed · 1 added · 1 error'); + assert.equal(scan.line(job()), '1 added · 1 error'); + assert.equal(scan.kept(job()), '1 added'); + assert.equal(scan.scope(job({ scope: { output_folders: 2 } })), '2 output folders'); + // The store keeps answering with the same table. + const { jobView, finishToast, startToast, pillParts, outcomeLine } = await store; + assert.equal(jobView('scan'), scan); + assert.equal(finishToast(job({ state: 'finished' })), 'Scan finished · 1 added · 1 error'); + assert.equal(startToast(job()), 'Scan started.'); + assert.equal(pillParts(job()).text, 'Scanning · 42% · 8,240 / 19,622'); + assert.equal(outcomeLine(job()), '1 added · 1 error'); +}); + +test('thumbnail job shows its own tiles and toasts', async () => { + const { kindOf, viewOf } = await source; + const { finishToast, startToast, pillParts, outcomeLine } = await store; + const thumbs = job({ kind: 'thumbnails', title: 'Thumbnail generation', processed: 4, total: 10, progress: 40, + counters: { generated: 1420, skipped: 3, errors: 1 }, scope: { mode: 'missing', size: 512, quality: 'medium', + output_folders: 2 } }); + const view = kindOf('thumbnails'); + assert.equal(viewOf(thumbs), view); + assert.deepEqual(view.tiles, [['generated', 'Generated', 'added'], ['skipped', 'Skipped', ''], ['errors', 'Errors', 'bad']]); + assert.deepEqual(view.outcomes, [['generated', 'Generated'], ['errors', 'Errors'], ['skipped', 'Skipped']]); + assert.deepEqual([...view.openable].sort(), ['generated', 'skipped']); + assert.deepEqual([view.log, view.cancelLabel], ['Job log', 'Cancel job']); + assert.equal(pillParts(thumbs).text, 'Generating thumbnails · 40% · 4 / 10'); + assert.equal(startToast(thumbs), 'Thumbnail generation started.'); + assert.equal(finishToast({ ...thumbs, state: 'finished' }), 'Thumbnails finished · 1,420 generated · 1 error'); + assert.equal(finishToast({ ...thumbs, state: 'failed' }), 'Thumbnails failed · 1,420 generated · 1 error'); + assert.equal(finishToast({ ...thumbs, state: 'cancelled' }), 'Thumbnails cancelled · 1,420 generated'); + assert.equal(finishToast({ ...thumbs, counters: { generated: 0, skipped: 0, errors: 2 }, state: 'finished' }), + 'Thumbnails finished · 0 generated · 2 errors'); + assert.equal(outcomeLine(thumbs), '1,420 generated · 1 error'); + assert.equal(view.kept(thumbs), '1,420 generated'); + assert.equal(view.scope(thumbs), '2 output folders'); +}); + +test('non-cancellable jobs show no Cancel job', async () => { + const { canCancel } = await source; + assert.equal(canCancel(job()), true); + assert.equal(canCancel(job({ cancellable: false })), false); + for (const state of ['finished', 'cancelled', 'failed', 'lost']) assert.equal(canCancel(job({ state })), false); + // The health check is a read job the server cannot stop. + assert.equal(canCancel(job({ kind: 'health', title: 'Health check', cancellable: false, counters: {} })), false); +}); + +test('indeterminate progress has no value attribute', async () => { + const { indeterminate, showProgress } = await source; + const { pillParts } = await store; + function node() { + const attributes = new Map(); + return { attributes, textContent: '', set value(v) { attributes.set('value', String(v)); }, + get value() { return Number(attributes.get('value') ?? 0); }, + setAttribute(key, v) { attributes.set(key, String(v)); }, removeAttribute(key) { attributes.delete(key); } }; + } + // Running with no total yet: the server has not said how much there is, so there is no percentage. + const health = job({ kind: 'health', title: 'Health check', progress: 0, processed: 0, total: 0, counters: {}, + cancellable: false, status_line: 'Checking the database and image links' }); + assert.equal(indeterminate(health), true); + const bar = node(); + bar.value = 37; + showProgress(bar, health); + assert.equal(bar.attributes.has('value'), false); + assert.equal(bar.textContent, ''); + assert.equal(pillParts(health).text, 'Health check'); + assert.deepEqual(pillParts(health), { label: 'Health check', files: '', text: 'Health check' }); + // A known total is determinate, and so is any ended job. + const scan = job(); + assert.equal(indeterminate(scan), false); + showProgress(bar, scan); + assert.equal(bar.attributes.get('value'), '42'); + assert.equal(bar.textContent, '42%'); + assert.equal(indeterminate({ ...health, state: 'finished', progress: 100 }), false); + // A scan before its file count is known is indeterminate too, never "0%". + assert.equal(pillParts(job({ total: 0, processed: 0, progress: 0 })).text, 'Scanning'); +}); + +test('held legacy jobs read "(admin page)" with no outcomes', async () => { + const { viewOf } = await source; + const { jobDetail } = await import('../../web/js/workspace/activity.mjs'); + const { finishToast, outcomeLine } = await store; + // A hold has no outcome groups (counters {}), whatever its kind: thumbnails held by the gallery's clear included. + for (const [kind, title] of [['restore', 'Database restore (admin page)'], + ['thumbnails', 'Thumbnail clear (gallery page)'], ['maintenance', 'Database maintenance (admin page)']]) { + const held = job({ kind, title, counters: {}, cancellable: false, progress: 0, processed: 0, total: 0 }); + const view = viewOf(held); + assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []], kind); + assert.equal(outcomeLine(held), ''); + assert.match(jobDetail(held), /^Started \d\d:\d\d · closing this view does not cancel it$/); + const ended = { ...held, state: 'finished', finished_at: 2 }; + assert.match(jobDetail(ended), /^Finished \d\d:\d\d$/); + assert.equal(finishToast(ended), `${title} finished.`); + assert.ok(title.endsWith('page)')); + } +}); + +test('unknown and generic kinds show no tiles of another kind', async () => { + const { kindOf, viewOf } = await source; + for (const kind of ['maintenance', '', 'constructor', '__proto__', 'health']) { + const view = kindOf(kind); + assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []], kind); + assert.deepEqual([view.log, view.cancelLabel], ['Job log', 'Cancel job']); + assert.equal(view.pillVerb, null); + assert.equal(view.line(job({ kind })), ''); + assert.equal(view.kept(job({ kind })), ''); + } + const backup = job({ kind: 'backup', title: 'Backup' }); + assert.equal(kindOf('backup').startToast(backup), 'Backup started.'); + assert.equal(kindOf('backup').finishToast({ ...backup, state: 'finished' }), 'Backup finished.'); + assert.equal(kindOf('backup').finishToast({ ...backup, state: 'cancelled' }), 'Backup was cancelled.'); + assert.equal(kindOf('backup').finishToast({ ...backup, state: 'failed' }), 'Backup failed.'); + // A kind's tiles are only the groups its job counts: no zero tile for a group the server never reported. + const partial = viewOf(job({ counters: { added: 3, errors: 0 } })); + assert.deepEqual(partial.tiles, [['added', 'Added to Library', 'added'], ['errors', 'Errors', 'bad']]); + assert.deepEqual(partial.outcomes, [['added', 'Added to Library'], ['errors', 'Errors']]); + assert.deepEqual([...partial.openable], ['added']); + assert.equal(partial.log, 'Scan log'); +}); diff --git a/tests/js/workspace-activity-store.test.js b/tests/js/workspace-activity-store.test.js index 82b69dac..fc8bfb6e 100644 --- a/tests/js/workspace-activity-store.test.js +++ b/tests/js/workspace-activity-store.test.js @@ -289,8 +289,10 @@ test('summaries are validated and the copy follows the spec', async () => { const scan = parseJob(summary({ progress: 42, processed: 8240, total: 19622, counters: { added: 1, errors: 1 } })); assert.deepEqual(pillParts(scan), { label: 'Scanning · 42%', files: ' · 8,240 / 19,622', text: 'Scanning · 42% · 8,240 / 19,622' }); - assert.equal(pillParts({ ...scan, total: 0 }).text, 'Scanning · 42%'); - assert.equal(pillParts({ ...scan, kind: 'thumbnails', title: 'Thumbnails' }).text, 'Thumbnails · 42% · 8,240 / 19,622'); + // D111: without a total the progress is indeterminate, so the pill shows no percentage. + assert.equal(pillParts({ ...scan, total: 0 }).text, 'Scanning'); + // A kind without a pill verb shows its title (D111: thumbnails have their own, see workspace-activity-kinds.test.js). + assert.equal(pillParts({ ...scan, kind: 'backup', title: 'Backup' }).text, 'Backup · 42% · 8,240 / 19,622'); assert.equal(finishToast({ ...scan, state: 'finished' }), 'Scan finished · 1 added · 1 error'); assert.equal(finishToast({ ...scan, state: 'cancelled' }), 'Scan cancelled · 1 added'); assert.equal(finishToast({ ...scan, state: 'failed' }), 'Scan failed · 1 added · 1 error'); @@ -332,7 +334,8 @@ test('image-tags jobs get their own tiles outcomes and pill verb', async () => { 'Add tags to images finished · 0 tagged · 0 errors'); }); -test('unknown kinds keep the generic view', async () => { +// D111 (S5): an unknown kind no longer borrows the scan's tiles (S1 review M6, S2 review M3); it has none. +test('unknown kinds get the generic view without the scan tiles', async () => { const { jobView } = await source; const scan = jobView('scan'); assert.deepEqual(scan.tiles, [['added', 'Added to Library', 'added'], ['known', 'Already known', ''], @@ -343,7 +346,7 @@ test('unknown kinds keep the generic view', async () => { assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']); for (const kind of ['maintenance', '', 'constructor', '__proto__']) { const view = jobView(kind); - assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [scan.tiles, scan.outcomes, [...scan.openable]]); + assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []]); assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']); } }); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 75926eb5..3d1704e6 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -44,7 +44,7 @@ "gallery.js": { "lines": 41, "branches": 63, - "functions": 44, + "functions": 45, "statements": 41 }, "html-escape.js": { @@ -161,6 +161,12 @@ "functions": 98, "statements": 97 }, + "workspace/activity-kinds.mjs": { + "lines": 98, + "branches": 95, + "functions": 98, + "statements": 98 + }, "workspace/activity-scan.mjs": { "lines": 96, "branches": 78, @@ -175,7 +181,7 @@ }, "workspace/activity.mjs": { "lines": 96, - "branches": 87, + "branches": 90, "functions": 98, "statements": 96 }, diff --git a/web/js/workspace/activity-job-view.mjs b/web/js/workspace/activity-job-view.mjs index f853cb46..dc9a9f1d 100644 --- a/web/js/workspace/activity-job-view.mjs +++ b/web/js/workspace/activity-job-view.mjs @@ -13,7 +13,11 @@ * - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`. * - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`): * an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads - * "Tagging log" and "Cancel tagging"; another kind keeps the scan's tiles as "Job log" and "Cancel job". + * "Tagging log" and "Cancel tagging". + * - D111: the kind's descriptor (activity-kinds.mjs `viewOf`) decides them for every kind: a thumbnail job counts + * Generated, Skipped and Errors; a kind without groups (the health check, a held legacy-page operation) shows no + * tiles and no Results, never another kind's zeros. A job without a known total has an indeterminate `<progress>`; + * only a job the server can stop shows Cancel. * * The summary follows the Activity store (one SSE stream per running job); the listed rows and the log are read from * `GET /prompt_manager/jobs/<id>` at most once a second while the job runs, and once more when it ends. @@ -22,7 +26,8 @@ import { createButton } from '../shared/toolkit/core.js'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { noun } from './plural.mjs'; -import { parseJob, jobView, JOB_VIEWS, JOBS_URL, LOST_DETAIL } from './activity-store.mjs'; +import { parseJob, JOB_VIEWS, JOBS_URL, LOST_DETAIL } from './activity-store.mjs'; +import { viewOf, canCancel, showProgress } from './activity-kinds.mjs'; import { jobDetail, CANCEL_FAILED_TOAST } from './activity.mjs'; import { downloadJobReport } from './activity-scan.mjs'; import { createMediaViewer } from './media-viewer.mjs'; @@ -70,7 +75,10 @@ export function parseDetail(raw) { /** The progress line: the server's while running, then what the job got through. */ export function progressLine(job) { const files = `${number(job.processed)} of ${number(job.total)} ${noun(job.total, 'file')}`; - if (job.state === 'running') return job.status_line || `${job.progress}%`; + // D111: a job that never counted files (a held legacy job, the health check) says only what the server says. + const counted = job.total > 0; + if (job.state === 'running') return job.status_line || (counted ? `${job.progress}%` : ''); + if (!counted && (job.state === 'finished' || job.state === 'cancelled')) return job.status_line; if (job.state === 'finished') return `${files} processed`; if (job.state === 'cancelled') return `Cancelled after ${files}`; if (job.state === 'lost') return LOST_DETAIL; @@ -96,7 +104,7 @@ export function filterRows(rows, filter) { /** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */ export function defaultOutcome(counters, outcomes = JOB_VIEWS.scan.outcomes) { - return (outcomes.find(([group]) => (counters[group] ?? 0) > 0) ?? outcomes[0])[0]; + return (outcomes.find(([group]) => (counters[group] ?? 0) > 0) ?? outcomes[0])?.[0] ?? null; } function section(title, id) { @@ -177,9 +185,10 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection, sender: createPromptSender({ scope }), onSelect: image => inspection.select(image) }); - /** The tiles and the outcome list of the job's kind, built once. */ - function layout(kind) { - view = jobView(kind); // D89 + /** The tiles and the outcome list of the job's kind, built once (D89, D111: only the groups it counts). */ + function layout(job) { + view = viewOf(job); + if (!view.tiles.length) stats.remove(); for (const [group, label, tone] of view.tiles) { const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat'); const value = element('dd', '0'); @@ -201,10 +210,11 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { function showBody(job) { if (mounted) return; mounted = true; - if (!view) layout(job.kind); + if (!view) layout(job); missing.remove(); parts.actions.append(download, cancel); - main.append(progressPart.node, resultsPart.node, log); + // A kind without outcome groups has no Results to browse (D111). + main.append(...(view.outcomes.length ? [progressPart.node, resultsPart.node, log] : [progressPart.node, log])); selected ??= defaultOutcome(job.counters, view.outcomes); } @@ -240,6 +250,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { } function renderRows(job) { + if (!view.outcomes.length) return; const rows = detail?.outcomes[selected] ?? []; const openable = view.openable.has(selected); const wanted = filterRows(rows.map((row, index) => ({ ...row, index })), input.value); @@ -275,7 +286,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { if (lost && document.activeElement === download) parts.h1.focus(); download.disabled = lost; // Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel. - if (job.state === 'running' && job.cancellable) { + if (canCancel(job)) { if (!cancel.isConnected) parts.actions.append(cancel); } else if (cancel.isConnected) { // A focused Cancel that goes away hands its focus to the job's heading, never to the page body. @@ -283,8 +294,8 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { cancel.remove(); } status.textContent = progressLine(job); - bar.value = job.progress; - bar.textContent = `${job.progress}%`; + // D111: no value while the total is unknown; a job that ended with nothing counted shows how it ended. + showProgress(bar, job.total > 0 || job.state !== 'finished' ? job : { ...job, progress: 100 }); bar.setAttribute('aria-label', scan ? 'Scan progress' : `${job.title} progress`); for (const [group, value] of tileValues) value.textContent = number(job.counters[group] ?? 0); for (const [group, button] of buttons) { diff --git a/web/js/workspace/activity-kinds.mjs b/web/js/workspace/activity-kinds.mjs new file mode 100644 index 00000000..3fd7c75b --- /dev/null +++ b/web/js/workspace/activity-kinds.mjs @@ -0,0 +1,189 @@ +/** + * Per-kind Activity descriptors (#249 System S5, D111): one table that says, for each job kind, what Activity shows. + * + * `kindOf(kind)` → `{pillVerb, startToast(job), finishToast(job), line(job), kept(job), scope(job), tiles, outcomes, + * openable, log, cancelLabel}`: + * - `pillVerb`: the pill's verb ("Scanning"); null shows the job's title. + * - `startToast` / `finishToast`: what a toast says when this tab starts the job and when it ends. + * - `line`: the counters as one line ("N added · M errors"); `kept`: what a cancelled job kept ("N added"); `scope`: + * what the job covers ("2 output folders"). + * - `tiles` (`[group, label, tone]`), `outcomes` (`[group, label]`) and `openable` (rows that open the viewer, D57). + * - `log` and `cancelLabel` (`cancel` is the same label under the job view's older name). + * + * `scan` is exactly the D56 view and `image-tags` the D89 one. `thumbnails` counts Generated (green), Skipped and + * Errors (red). Any other kind (the health check, a held legacy-page operation, a kind this page does not know) gets + * the generic descriptor: title-based copy and no tiles or outcomes, so it never shows another kind's zeros (S1 review + * M6, S2 review M3). `viewOf(job)` keeps only the groups the job itself counts, so a hold (no groups) has none either. + * + * Progress is indeterminate while a job runs without a known total (`indeterminate`): the `<progress>` has no value + * and the pill shows no percentage, never a made-up 0%. A job shows Cancel only while it runs and the server can stop + * it (`canCancel`). + */ +import { noun, countOf } from './plural.mjs'; + +// Grouped as the server writes its status lines ("8,240 of 19,622"), whatever the browser's locale. +const number = value => value.toLocaleString('en-US'); +const counted = (job, group, word) => `${number(job.counters?.[group] ?? 0)} ${word}`; +const errorsOf = job => `${number(job.counters?.errors ?? 0)} ${noun(job.counters?.errors ?? 0, 'error')}`; +const whole = value => Number.isSafeInteger(value) && value >= 0; +const tiles = rows => Object.freeze(rows.map(Object.freeze)); +const groups = (...names) => Object.freeze(new Set(names)); + +/** "1,420 added". */ +export const addedOf = job => counted(job, 'added', 'added'); +/** "12 tagged" (D89). */ +export const taggedOf = job => counted(job, 'tagged', 'tagged'); +/** "3 generated" (D111). */ +export const generatedOf = job => counted(job, 'generated', 'generated'); + +/** "2 output folders": what a scan or a thumbnail job walks. */ +function foldersOf(job) { + const folders = job.scope?.output_folders; + return whole(folders) ? countOf(folders, 'output folder') : ''; +} + +/** "4 images" (D89), grouped as the server writes its lines. */ +function imagesOf(job) { + const images = job.scope?.images; + return whole(images) ? `${number(images)} ${noun(images, 'image')}` : ''; +} + +const titleStarted = job => `${job.title} started.`; + +function titleFinished(job) { + if (job.state === 'cancelled') return `${job.title} was cancelled.`; + if (job.state === 'failed') return `${job.title} failed.`; + return `${job.title} finished.`; +} + +/** "<Noun> finished · <line>", "<Noun> cancelled · <kept>", "<Noun> failed · <line>". */ +const namedFinish = (name, kept, line) => job => { + if (job.state === 'cancelled') return `${name(job)} cancelled · ${kept(job)}`; + return `${name(job)} ${job.state === 'failed' ? 'failed' : 'finished'} · ${line(job)}`; +}; + +const nothing = () => ''; +const GENERIC = Object.freeze({ + pillVerb: null, startToast: titleStarted, finishToast: titleFinished, line: nothing, kept: nothing, + scope: foldersOf, tiles: tiles([]), outcomes: tiles([]), openable: groups(), log: 'Job log', cancelLabel: 'Cancel job', + cancel: 'Cancel job', +}); + +const scanLine = job => `${addedOf(job)} · ${errorsOf(job)}`; +const tagLine = job => `${taggedOf(job)} · ${errorsOf(job)}`; +const thumbnailLine = job => `${generatedOf(job)} · ${errorsOf(job)}`; +// D85: an image tagging job says which of its two modes started. +const IMAGE_TAG_START_TOASTS = Object.freeze({ add: 'Tagging started.', auto: 'Auto-tagging started.' }); + +/** Every kind with a descriptor of its own. */ +export const KINDS = Object.freeze({ + __proto__: null, + // D56: the scan's tiles and outcome list in the mockup's orders; what got added and what went wrong first. + scan: Object.freeze({ + ...GENERIC, + pillVerb: 'Scanning', + startToast: () => 'Scan started.', + finishToast: namedFinish(() => 'Scan', addedOf, scanLine), + line: scanLine, + kept: addedOf, + tiles: tiles([['added', 'Added to Library', 'added'], ['known', 'Already known', ''], + ['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]), + outcomes: tiles([['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], + ['no_metadata', 'No usable metadata'], ['known', 'Already known']]), + openable: groups('added', 'known'), + log: 'Scan log', + cancelLabel: 'Cancel scan', + cancel: 'Cancel scan', + }), + // D89: Tagged (green), Unchanged, Skipped (warning) and Errors (red); Tagged and Unchanged rows open the viewer. + 'image-tags': Object.freeze({ + ...GENERIC, + pillVerb: 'Tagging', + startToast: job => IMAGE_TAG_START_TOASTS[job.scope?.mode] ?? titleStarted(job), + finishToast: namedFinish(job => job.title, taggedOf, tagLine), + line: tagLine, + kept: taggedOf, + scope: imagesOf, + tiles: tiles([['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], + ['errors', 'Errors', 'bad']]), + outcomes: tiles([['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['unchanged', 'Unchanged']]), + openable: groups('tagged', 'unchanged'), + log: 'Tagging log', + cancelLabel: 'Cancel tagging', + cancel: 'Cancel tagging', + }), + // D111: Generated (green), Skipped (already fresh) and Errors (red); a row with a preview opens the viewer. + thumbnails: Object.freeze({ + ...GENERIC, + pillVerb: 'Generating thumbnails', + finishToast: namedFinish(() => 'Thumbnails', generatedOf, thumbnailLine), + line: thumbnailLine, + kept: generatedOf, + tiles: tiles([['generated', 'Generated', 'added'], ['skipped', 'Skipped', ''], ['errors', 'Errors', 'bad']]), + outcomes: tiles([['generated', 'Generated'], ['errors', 'Errors'], ['skipped', 'Skipped']]), + openable: groups('generated', 'skipped'), + }), +}); + +/** The descriptor of a job `kind`; any kind without one gets the generic descriptor. */ +export function kindOf(kind) { + return (typeof kind === 'string' && KINDS[kind]) || GENERIC; +} + +const countsOf = job => job.counters && typeof job.counters === 'object' ? job.counters : {}; + +/** + * A legacy-page operation held on the registry (D112): not cancellable and counting no outcome groups. Whatever its + * kind (the gallery's thumbnail clear is a `thumbnails` hold), it reads as the generic job under its own title. + */ +export const held = job => job.cancellable === false && Object.keys(countsOf(job)).length === 0; + +/** + * The descriptor for one job: its kind's, keeping only the outcome groups the job counts; a held job's is the generic + * one. + */ +export function viewOf(job) { + if (held(job)) return GENERIC; + const view = kindOf(job.kind); + const counts = countsOf(job); + const has = ([group]) => Object.hasOwn(counts, group); + if (view.tiles.every(has) && view.outcomes.every(has)) return view; + return Object.freeze({ ...view, tiles: tiles(view.tiles.filter(has)), outcomes: tiles(view.outcomes.filter(has)), + openable: groups(...[...view.openable].filter(group => Object.hasOwn(counts, group))) }); +} + +/** A running job whose total the server has not reported (yet) has no percentage. */ +export function indeterminate(job) { + return job.state === 'running' && !(job.total > 0); +} + +/** Set a `<progress>` to the job's: its value and "N%", or no value at all while indeterminate. */ +export function showProgress(node, job) { + if (indeterminate(job)) { + node.removeAttribute('value'); + node.textContent = ''; + return; + } + node.value = job.progress; + node.textContent = `${job.progress}%`; +} + +/** Truth rule: Cancel only while the job runs and the server can stop it (never on a held legacy job). */ +export function canCancel(job) { + return job.state === 'running' && job.cancellable !== false; +} + +/** The pill's text: "Scanning · 42% · 8,240 / 19,622"; the file counts once the total is known, no "%" before. */ +export function pillParts(job) { + const verb = viewOf(job).pillVerb ?? job.title; + const label = indeterminate(job) ? verb : `${verb} · ${job.progress}%`; + const files = job.total > 0 ? ` · ${number(job.processed)} / ${number(job.total)}` : ''; + return { label, files, text: label + files }; +} + +/** The toast after this tab starts a job. */ +export const startToast = job => viewOf(job).startToast(job); +/** The toast when a job ends. */ +export const finishToast = job => viewOf(job).finishToast(job); +/** The counters as one line; '' for a kind without one. */ +export const outcomeLine = job => viewOf(job).line(job); diff --git a/web/js/workspace/activity-store.mjs b/web/js/workspace/activity-store.mjs index 0729d449..ca535c43 100644 --- a/web/js/workspace/activity-store.mjs +++ b/web/js/workspace/activity-store.mjs @@ -12,7 +12,9 @@ * the finish toast follows any job this store saw running when it ends. */ import { showWorkspaceToast } from './toast.mjs'; -import { noun } from './plural.mjs'; +import { KINDS, kindOf, finishToast, startToast } from './activity-kinds.mjs'; + +export { addedOf, taggedOf, finishToast, startToast, pillParts, outcomeLine } from './activity-kinds.mjs'; export const JOBS_URL = '/prompt_manager/jobs'; export const BACKOFF_FIRST_MS = 1000; @@ -23,51 +25,13 @@ export const LOST_DETAIL = 'This job ended when ComfyUI restarted.'; const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']); const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']); const JOB_ID = /^[0-9a-f]{32}$/; -// The pill's verb for each job kind; a kind without one shows its title (D89: image tagging jobs read "Tagging"). -const PILL_VERBS = Object.freeze({ scan: 'Scanning', 'image-tags': 'Tagging' }); -const START_TOASTS = Object.freeze({ scan: 'Scan started.' }); - -/** The scan's job view (D56): its counter tiles `[group, label, tone]` and outcome list in the mockup's orders. */ -const SCAN_VIEW = Object.freeze({ - tiles: Object.freeze([ - ['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'], - ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'], - ].map(Object.freeze)), - // What got added and what went wrong first. - outcomes: Object.freeze([ - ['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], - ['no_metadata', 'No usable metadata'], ['known', 'Already known'], - ].map(Object.freeze)), - // Rows that open the file in the viewer (D57); the others are plain text. - openable: Object.freeze(new Set(['added', 'known'])), - log: 'Scan log', - cancel: 'Cancel scan', -}); - /** - * Each job kind's view: its tiles, outcome list, openable rows, log label and cancel label. D89: an image tagging job - * (`image-tags`) counts Tagged (green), Unchanged, Skipped (warning) and Errors (red), lists what got tagged and what - * went wrong first, and opens Tagged and Unchanged rows in the viewer like the scan's (D57). + * Each job kind's copy and view live in one table (activity-kinds.mjs, D111): the scan's (D56), image tagging's (D89), + * thumbnails' and the generic one. The names below are that table's, kept for this store's importers. */ -export const JOB_VIEWS = Object.freeze({ - scan: SCAN_VIEW, - 'image-tags': Object.freeze({ - tiles: Object.freeze([['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], - ['errors', 'Errors', 'bad']].map(Object.freeze)), - outcomes: Object.freeze([['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], - ['unchanged', 'Unchanged']].map(Object.freeze)), - openable: Object.freeze(new Set(['tagged', 'unchanged'])), - log: 'Tagging log', - cancel: 'Cancel tagging', - }), -}); -// Another kind keeps the scan's tiles and outcomes under the generic labels. -const GENERIC_VIEW = Object.freeze({ ...SCAN_VIEW, log: 'Job log', cancel: 'Cancel job' }); - +export const JOB_VIEWS = KINDS; /** The view of a job `kind`; an unknown kind gets the generic one. */ -export function jobView(kind) { - return Object.hasOwn(JOB_VIEWS, kind) ? JOB_VIEWS[kind] : GENERIC_VIEW; -} +export const jobView = kindOf; /** The wait before the `attempt`-th re-read (0-based): 1 s, 2 s, 4 s … never more than 30 s. */ export function backoffDelay(attempt) { @@ -108,57 +72,6 @@ export function parseJob(raw) { }; } -// Grouped as the server writes its status lines ("8,240 of 19,622"), whatever the browser's locale. -const number = value => value.toLocaleString('en-US'); -const errorsOf = job => `${number(job.counters.errors ?? 0)} ${noun(job.counters.errors ?? 0, 'error')}`; -/** "1,420 added". */ -export const addedOf = job => `${number(job.counters.added ?? 0)} added`; - -/** - * The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added"; an image tagging job - * the same way with its own title and count (D89): "Auto-tag images finished · N tagged · M errors". - */ -export function finishToast(job) { - if (job.kind === 'scan') { - if (job.state === 'cancelled') return `Scan cancelled · ${addedOf(job)}`; - if (job.state === 'failed') return `Scan failed · ${addedOf(job)} · ${errorsOf(job)}`; - return `Scan finished · ${addedOf(job)} · ${errorsOf(job)}`; - } - if (job.kind === 'image-tags') { - if (job.state === 'cancelled') return `${job.title} cancelled · ${taggedOf(job)}`; - return `${job.title} ${job.state === 'failed' ? 'failed' : 'finished'} · ${outcomeLine(job)}`; - } - if (job.state === 'cancelled') return `${job.title} was cancelled.`; - if (job.state === 'failed') return `${job.title} failed.`; - return `${job.title} finished.`; -} - -// D85: an image tagging job says which of its two modes started. -const IMAGE_TAG_START_TOASTS = Object.freeze({ add: 'Tagging started.', auto: 'Auto-tagging started.' }); - -/** The toast after this tab starts a job. */ -export function startToast(job) { - if (job.kind === 'image-tags' && IMAGE_TAG_START_TOASTS[job.scope.mode]) return IMAGE_TAG_START_TOASTS[job.scope.mode]; - return START_TOASTS[job.kind] ?? `${job.title} started.`; -} - -/** The pill's text: "Scanning · 42% · 8,240 / 19,622"; the file counts only once the total is known. */ -export function pillParts(job) { - const label = `${PILL_VERBS[job.kind] ?? job.title} · ${job.progress}%`; - const files = job.total > 0 ? ` · ${number(job.processed)} / ${number(job.total)}` : ''; - return { label, files, text: label + files }; -} - -/** "12 tagged" (D89). */ -export const taggedOf = job => `${number(job.counters.tagged ?? 0)} tagged`; - -/** The counters as one line: "N added · M errors" for a scan, "N tagged · M errors" for image tagging (D89). */ -export function outcomeLine(job) { - if (job.kind === 'scan') return `${addedOf(job)} · ${errorsOf(job)}`; - if (job.kind === 'image-tags') return `${taggedOf(job)} · ${errorsOf(job)}`; - return ''; -} - function defaultConnect(url) { const resolve = globalThis.ApiClient?.resolveUrl ?? (value => value); return new EventSource(resolve(url)); diff --git a/web/js/workspace/activity.mjs b/web/js/workspace/activity.mjs index f4deedee..d2ad2b53 100644 --- a/web/js/workspace/activity.mjs +++ b/web/js/workspace/activity.mjs @@ -12,13 +12,15 @@ * "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and * "Cancel job" (no "Simulate completion"). * - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors". + * - D111: what a line says (scope, counters, what a cancel kept) is the job kind's descriptor (activity-kinds.mjs); a + * job without a known total shows an indeterminate `<progress>`, and only a job the server can stop shows Cancel. */ import { createButton } from '../shared/toolkit/core.js'; import { navigateTo } from './state.mjs'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; -import { countOf, noun } from './plural.mjs'; -import { pillParts, outcomeLine, addedOf, taggedOf, LOST_DETAIL } from './activity-store.mjs'; +import { pillParts, outcomeLine, LOST_DETAIL } from './activity-store.mjs'; +import { viewOf, canCancel, showProgress } from './activity-kinds.mjs'; export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.'; export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.'; @@ -45,20 +47,9 @@ export function clockTime(seconds) { const join = parts => parts.filter(Boolean).join(' · '); -const whole = value => Number.isSafeInteger(value) && value >= 0; - -/** What a job covers: "2 output folders" for a scan, "4 images" for image tagging (D89). */ -function scopeLine(job) { - // D89: grouped as the server writes its lines ("19,622 images"). - const images = job.scope?.images; - if (job.kind === 'image-tags') return whole(images) ? `${images.toLocaleString('en-US')} ${noun(images, 'image')}` : ''; - const folders = job.scope?.output_folders; - return whole(folders) ? countOf(folders, 'output folder') : ''; -} - -/** What a cancelled job kept: "N added" for a scan, "N tagged" for image tagging (D89). */ -const KEPT = Object.freeze({ __proto__: null, scan: addedOf, 'image-tags': taggedOf }); // D89 -const keptOf = job => KEPT[job.kind]?.(job) ?? ''; +/** What a job covers ("2 output folders", "4 images") and what a cancelled one kept ("N added"), by kind (D111). */ +const scopeLine = job => viewOf(job).scope(job); +const keptOf = job => viewOf(job).kept(job); /** The item's line under its title, by state. */ export function jobDetail(job) { @@ -151,15 +142,14 @@ function itemFor(job, scope) { // parentNode, not isConnected: on the first render the item is not in the page yet, so a job that had already // ended when Activity opened kept its Cancel job (and a running one's progress moved after its actions). if (running) { - progress.value = next.progress; - progress.textContent = `${next.progress}%`; + showProgress(progress, next); // D111: no value while the total is unknown progress.setAttribute('aria-label', `${next.title} progress`); if (!progress.parentNode) item.insertBefore(progress, actions.parentNode ? actions : null); } else { progress.remove(); } // Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel job. - if (running && next.cancellable) { + if (canCancel(next)) { if (!actions.parentNode) item.append(actions); } else if (actions.parentNode) { // A focused Cancel job that goes away hands its focus to the item's title link, never to the page body.