feat(activity): per-kind job descriptors

One descriptor table (activity-kinds.mjs) decides each job kind's pill
verb, toasts, line, tiles, outcomes, openable rows and labels; the
store re-exports it. The scan and image tagging views are unchanged.
A thumbnail job counts Generated, Skipped and Errors, reads
"Generating thumbnails" in the pill and "Thumbnails finished · N
generated · M errors" when it ends.

Any other kind (the health check, a held legacy-page operation) gets
the generic descriptor with no tiles and no Results, instead of the
scan's zero tiles. A running job without a known total shows an
indeterminate progress bar and no percentage, and only a job the
server can cancel shows Cancel.
This commit is contained in:
Vito Sansevero
2026-10-23 13:53:14 -07:00
parent d5bcae301b
commit cb1de9de88
10 changed files with 524 additions and 143 deletions
+24 -4
View File
@@ -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", "<title> 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).
+74
View File
@@ -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.');
});
+35 -8
View File
@@ -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
+148
View File
@@ -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');
});
+7 -4
View File
@@ -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']);
}
});
+8 -2
View File
@@ -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
},
+23 -12
View File
@@ -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) {
+189
View File
@@ -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);
+7 -94
View File
@@ -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));
+9 -19
View File
@@ -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.