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:
+24
-4
@@ -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).
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user