fix(activity): final review round for the job store, job view and scan rows
- Store: one failed list read no longer marks running jobs as ended; two in a row, or a good list without them, do (M1). A good read resets the backoff, except for a stream that keeps dropping while the list reads fine (M2). Finished jobs the server pruned are dropped (M3). Numbers group as en-US, like the server's lines (M13). - Job view: a job the store's list no longer has shows the not-found state (M3). Focus moves to the job heading when Cancel scan goes away, and to the item's title link in the list (M4). The detail is read only when its listed rows change or the open log grows, using the summary's new listed and log_length (M12). - Viewer rows open by exact identity: each scan row records its root's listing index, the source must still carry the row's label, and the workspace listing gains an exact `path` filter (M8, M9). - Scan rows name the prompt by its Library title, stored title first (M10). - A toast with a link stays 20 s, and any toast stays while hovered or focused (M14). - A real two-batch scan cancelled after batch 1 keeps partial counts (M7). - TESTING.md: D54, D56 and D57 updated (M5, M6).
This commit is contained in:
+37
-17
@@ -516,14 +516,17 @@ Recorded shell deviations:
|
||||
ComfyUI restarted." The running actions are "Cancel job" only (no "Simulate
|
||||
completion"); a failed cancel toasts "The job could not be cancelled. Try again.". With
|
||||
no job the section says "No jobs have run since ComfyUI started. Scans and other long
|
||||
operations appear here." `#/activity/<id>` shows the list with that job's item marked
|
||||
`aria-current="true"` until the job view (Activity-1 Task 3); that view is D56.
|
||||
operations appear here." `#/activity/<id>` is that job's own view (D56). When a job
|
||||
ends while its Cancel job has focus, focus moves to the item's title link.
|
||||
|
||||
The Activity store reads the list once, then follows each running job over
|
||||
`/prompt_manager/jobs/<id>/events`. A dropped stream re-reads the list after 1 s,
|
||||
doubling to at most 30 s; a received event resets the wait. A running job the server
|
||||
no longer lists, or one whose server cannot be read, is shown as ended, so no pill
|
||||
stays. While the tab is visible the list is read again every 15 s (and whenever
|
||||
doubling to at most 30 s; a received event resets the wait, and so does a good list
|
||||
read after failed ones or after any read that was not for a dropped stream. A running
|
||||
job the server no longer lists, or one whose server could not be read twice in a row,
|
||||
is shown as ended, so no pill stays; one failed read (a proxy's 502) changes nothing.
|
||||
A finished job the server no longer lists (it keeps 20) is dropped. Numbers are
|
||||
grouped "en-US", as the server's lines are. While the tab is visible the list is read again every 15 s (and whenever
|
||||
Activity opens), so a job started in another tab or on the legacy admin page appears
|
||||
without a reload. Toasts: "Scan started." after this tab's own start; "Scan finished
|
||||
· N added · M errors", "Scan cancelled · N added" or "Scan failed · N added · M
|
||||
@@ -573,14 +576,26 @@ Recorded shell deviations:
|
||||
`.outcomes` with `.outcome-list` (`button[aria-pressed]` label + count, in the
|
||||
mockup's order Added, Needs review, Errors, No usable metadata, Already known; the
|
||||
view opens on the first with files) beside `.outcome-files`: one `.file-row` (`.path`
|
||||
root-relative, `.why` the server's detail) per listed file, "No files in this group."
|
||||
root-relative, `.why` the server's detail) per listed file. The detail wording is the
|
||||
builder's (approved with Task 1): "Added to the Library as “title”", "Linked to
|
||||
“title”", "Already linked to “title”", "Needs review: …", "Error: …", where the title
|
||||
is the prompt's Library title (its stored title, else its text's first line), not the
|
||||
mockup's sample "→ “title” · new positive + negative". Each log line is "HH:MM:SS
|
||||
<prefix> <root label>/<path> · <detail>" rather than the mockup's "[HH:MM:SS] + path →
|
||||
prompt #4812 (new)". The header's actions sit in the page heading's
|
||||
`.heading-actions`, where the mockup has a `.panel-heading > .row-actions`. An
|
||||
empty outcome shows "No files in this group."
|
||||
or "No listed file matches this filter.", and the note "Showing N of M" when fewer
|
||||
than the counter are shown (the server lists 200 per outcome), with "each row opens
|
||||
the image in the viewer" for Added and Already known. Then `details.term`, collapsed:
|
||||
"● Scan log — raw detail, click to show" and `pre[tabindex=0]` with the raw lines.
|
||||
The summary follows the store; the rows and log are read from `GET
|
||||
/prompt_manager/jobs/<id>` at most once a second while it runs and once at its end.
|
||||
An unknown id shows "This job is not in this session’s Activity. Jobs are kept until
|
||||
The summary follows the store and carries `listed` (rows per outcome) and
|
||||
`log_length`; the rows and log are read from `GET /prompt_manager/jobs/<id>` once on
|
||||
opening, then at most once a second only while what it lists changes (or, with the
|
||||
log open, its length), so a job whose lists are capped and whose log is closed is not
|
||||
re-read; opening the log reads its new lines. When the job ends while Cancel scan has
|
||||
focus, focus moves to the job's heading. An unknown id, or a job the store's list no
|
||||
longer has, shows "This job is not in this session’s Activity. Jobs are kept until
|
||||
ComfyUI restarts." and "All activity"; a job lost with a restart keeps what was read.
|
||||
At `max-width: 700px` the tiles take two columns and the outcome list stacks above
|
||||
the files. Each Activity item's title is now a link to its view, in the title's
|
||||
@@ -589,14 +604,19 @@ Recorded shell deviations:
|
||||
`workspace-activity.spec.mjs` covers the counters, live updates, viewer rows, the
|
||||
filter, the 200-row cap, Cancel scan, the busy start and an unknown or lost job.
|
||||
- **D57**: rows under Added and Already known are `button.file-row`s that open the
|
||||
existing media viewer on that one file. The row's root label finds its source in
|
||||
`/prompt_manager/gallery/subfolders?mode=workspace`, and the workspace listing
|
||||
(`root`, `q` = the path) returns the listing item with that exact path: the same
|
||||
identity (`root_index:path`) Images opens. A file the listing no longer has toasts
|
||||
"This file is no longer in Images. Rescan from Import and try again."; other rows are
|
||||
plain text, with no new file route. A start refused because another record-changing
|
||||
job runs toasts "Another job is running: <title>." with the title linking that job's
|
||||
view (the shared toast now accepts link parts).
|
||||
existing media viewer on that one file, by the identity Images opens it by
|
||||
(`root_index:path`). Each scan row records its root's listing index (`root_index`,
|
||||
the place in `_get_all_output_dirs`) beside its label. The row's source is that index
|
||||
in `/prompt_manager/gallery/subfolders?mode=workspace` and must still carry the row's
|
||||
label, so a reordered or replaced source never opens another root's file; the
|
||||
workspace listing's new `path` filter (one exact relative path, case-folded, at most
|
||||
1,024 characters, no traversal) returns that file. A row without an index, a changed
|
||||
source or a file the listing no longer has toasts "This file is no longer in Images.
|
||||
Rescan from Import and try again."; other rows are plain text, with no new file
|
||||
route. A start refused because another record-changing job runs toasts "Another job
|
||||
is running: <title>." with the title linking that job's view (the shared toast now
|
||||
accepts link parts). A toast with a link stays 20 s instead of 6.5 s, and any toast
|
||||
stays while the pointer or focus is on it.
|
||||
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
|
||||
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
|
||||
library.", and the title has no " concept" suffix. Save view's dialog action reads
|
||||
|
||||
+29
-10
@@ -198,21 +198,28 @@ def _scan_status_line(done, total, batches, elapsed):
|
||||
|
||||
|
||||
def _scan_roots(output_dirs):
|
||||
"""(absolute root, public label) pairs, the most specific root first."""
|
||||
"""(absolute root, public label, listing index), the most specific first.
|
||||
|
||||
The index is the root's place in ``output_dirs`` (``_get_all_output_dirs``),
|
||||
the ``root_index`` the workspace listing names it by.
|
||||
"""
|
||||
public_path, _ = _public_helpers()
|
||||
roots = [(os.path.abspath(str(d)), public_path(d)) for d in output_dirs]
|
||||
roots = [
|
||||
(os.path.abspath(str(d)), public_path(d), index)
|
||||
for index, d in enumerate(output_dirs)
|
||||
]
|
||||
return sorted(roots, key=lambda root: len(root[0]), reverse=True)
|
||||
|
||||
|
||||
def _scan_location(media_file, roots):
|
||||
"""``media_file`` relative to its output root, and that root's label."""
|
||||
"""``media_file`` relative to its output root, that root's label and index."""
|
||||
full = os.path.abspath(str(media_file))
|
||||
folded = os.path.normcase(full)
|
||||
for root, label in roots:
|
||||
for root, label, index in roots:
|
||||
base = os.path.normcase(root).rstrip("\\/") + os.sep
|
||||
if folded.startswith(base):
|
||||
return Path(os.path.relpath(full, root)).as_posix(), label
|
||||
return Path(full).name, ""
|
||||
return Path(os.path.relpath(full, root)).as_posix(), label, index
|
||||
return Path(full).name, "", None
|
||||
|
||||
|
||||
def _scan_outcome(media_file, roots, status, title=None):
|
||||
@@ -223,8 +230,8 @@ def _scan_outcome(media_file, roots, status, title=None):
|
||||
group = "errors" if status == "error" else "no_metadata"
|
||||
template = SCAN_TITLED_DETAILS.get(status) if title else None
|
||||
detail = template.format(title=title) if template else SCAN_DETAILS[status]
|
||||
path, label = _scan_location(media_file, roots)
|
||||
row = {"path": path, "root": label, "detail": detail}
|
||||
path, label, index = _scan_location(media_file, roots)
|
||||
row = {"path": path, "root": label, "root_index": index, "detail": detail}
|
||||
where = f"{label}/{path}" if label else path
|
||||
return group, row, f"{SCAN_LOG_PREFIXES[group]} {where} · {detail}"
|
||||
|
||||
@@ -1715,7 +1722,7 @@ class AdminRoutesMixin:
|
||||
return result["status"]
|
||||
return {
|
||||
**result,
|
||||
"title": display_title((), resolution["positive"]["text"]),
|
||||
"title": self._scan_title(media_file, resolution["positive"]["text"]),
|
||||
}
|
||||
if canonical.get("prompt") and resolution["state"] in (
|
||||
"ambiguous",
|
||||
@@ -1747,7 +1754,19 @@ class AdminRoutesMixin:
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {"status": status, "title": display_title((), prompt_text)}
|
||||
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
|
||||
|
||||
def _scan_title(self, media_file, text):
|
||||
"""The linked prompt's Library title (its stored title, else ``text``'s
|
||||
first line), as the Library and the inspector name it."""
|
||||
try:
|
||||
path = str(media_file)
|
||||
summary = self.db.get_image_link_summaries([path], False).get(path)
|
||||
if summary and summary.get("title"):
|
||||
return summary["title"]
|
||||
except Exception:
|
||||
self.logger.warning("Scan row title could not be read; using its text")
|
||||
return display_title((), text)
|
||||
|
||||
def _store_scanned_prompt(self, media_file, canonical, prompt_text):
|
||||
"""Link ``media_file`` to ``prompt_text``'s prompt, saving it when new."""
|
||||
|
||||
@@ -705,6 +705,8 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
media = query.get("media", "all")
|
||||
order = query.get("sort", "newest")
|
||||
folder = query.get("subfolder", "").strip().replace("\\", "/")
|
||||
# ``path``: one exact relative path (Activity opens a scan row by it, D57).
|
||||
exact = query.get("path", "").strip().replace("\\", "/")
|
||||
category = query.get("category", "").strip() or None
|
||||
tags = sorted(
|
||||
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
|
||||
@@ -723,12 +725,15 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
raise ValueError("Invalid gallery filters")
|
||||
if folder and (_has_traversal(folder) or len(folder) > 1024):
|
||||
raise ValueError("Invalid gallery folder")
|
||||
if exact and (_has_traversal(exact) or len(exact) > 1024):
|
||||
raise ValueError("Invalid gallery path")
|
||||
return {
|
||||
"text": text,
|
||||
"query": folded,
|
||||
"media": media,
|
||||
"order": order,
|
||||
"folder": folder,
|
||||
"path": exact.casefold(),
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"favorites": favorites == "1",
|
||||
@@ -762,7 +767,10 @@ def _filtered_page(
|
||||
native_folder = folder.replace("/", os.sep)
|
||||
nested = native_folder + os.sep
|
||||
total, page, positives = 0, [], set()
|
||||
exact = filters.get("path", "")
|
||||
for position, entry in enumerate(entries):
|
||||
if exact and entry.folded != exact:
|
||||
continue
|
||||
if folder and not (
|
||||
entry.folder == native_folder or entry.folder.startswith(nested)
|
||||
):
|
||||
@@ -1155,6 +1163,7 @@ class ImageRoutesMixin:
|
||||
filters["media"],
|
||||
filters["order"],
|
||||
filters["folder"],
|
||||
filters["path"],
|
||||
root_index,
|
||||
[str(root) for root in output_dirs],
|
||||
filters["category"],
|
||||
|
||||
+11
-3
@@ -143,9 +143,10 @@ class Job:
|
||||
def record(self, group, row, log_line):
|
||||
"""Count one file outcome in ``group``, list its row and log it.
|
||||
|
||||
``row`` is ``{path, root, detail}``: the path relative to its output
|
||||
root and the root's public label. An absolute path is cut to its file
|
||||
name, so a row never reveals the server's layout.
|
||||
``row`` is ``{path, root, root_index, detail}``: the path relative to
|
||||
its output root, the root's public label and its listing index (the
|
||||
identity Images opens it by; None when unknown). An absolute path is
|
||||
cut to its file name, so a row never reveals the server's layout.
|
||||
"""
|
||||
if group not in self.counters:
|
||||
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
|
||||
@@ -155,11 +156,15 @@ class Job:
|
||||
if raw and raw != path:
|
||||
log_line = log_line.replace(raw, path)
|
||||
rows = self.outcomes[group]
|
||||
index = row.get("root_index")
|
||||
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
|
||||
index = None
|
||||
if len(rows) < MAX_OUTCOME_ROWS:
|
||||
rows.append(
|
||||
{
|
||||
"path": path,
|
||||
"root": str(row.get("root") or ""),
|
||||
"root_index": index,
|
||||
"detail": str(row.get("detail") or ""),
|
||||
}
|
||||
)
|
||||
@@ -219,6 +224,9 @@ class Job:
|
||||
"total": self.total,
|
||||
"counters": dict(self.counters),
|
||||
"scope": dict(self.scope),
|
||||
# What detail() lists, so a view re-reads it only when it changed.
|
||||
"listed": {group: len(rows) for group, rows in self.outcomes.items()},
|
||||
"log_length": len(self.log),
|
||||
}
|
||||
|
||||
def detail(self):
|
||||
|
||||
@@ -267,8 +267,11 @@ test('reload during a scan reattaches to the running job', async ({ page, reques
|
||||
|
||||
// Cancel job asks the server to stop at a file boundary; the job ends cancelled with its partial counts.
|
||||
await step(request, job.id, { counters: { added: 7 } });
|
||||
await items(page).first().getByRole('button', { name: 'Cancel job', exact: true }).click();
|
||||
await items(page).first().getByRole('button', { name: 'Cancel job', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
|
||||
// The removed Cancel job hands its focus to the item's title link (review M4).
|
||||
await expect(items(page).first().locator('h3 > a')).toBeFocused();
|
||||
await expect(items(page).first().locator('.chip.blue')).toHaveText('Cancelled');
|
||||
await expect(items(page).first().locator('p')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
|
||||
await expect(pill(page)).toBeHidden();
|
||||
@@ -384,6 +387,8 @@ test('start scan creates a job, toasts, and opens its view', async ({ page, requ
|
||||
const values = await tileValues(page);
|
||||
expect(values.map(([, label]) => label)).toEqual(TILE_LABELS);
|
||||
expect(values.reduce((sum, [, , value]) => sum + Number(value.replaceAll(',', '')), 0)).toBe(job.processed);
|
||||
// Opening the log reads its last lines (review M12).
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect(scanLog(page).locator('pre')).toContainText('Scan finished');
|
||||
});
|
||||
|
||||
@@ -498,6 +503,26 @@ test('added rows open the viewer, error rows are plain', async ({ page, request
|
||||
await fileRows(page).nth(1).click();
|
||||
await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
|
||||
// A row opens only its own root's file: a root whose label no longer matches (a replaced source) never opens the
|
||||
// other root's same path, and a row without a root index opens nothing (review M8, M9).
|
||||
const lookups = [];
|
||||
page.on('request', item => { const url = new URL(item.url()); if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('path')) lookups.push(url.search); });
|
||||
await step(request, job.id, { records: [
|
||||
{ group: 'added', path: 'shared/same.png', root: 'output', root_index: 1, detail: 'Added' },
|
||||
{ group: 'added', path: 'shared/same.png', root: 'output', root_index: null, detail: 'Added' },
|
||||
] });
|
||||
await expect(fileRows(page)).toHaveCount(4);
|
||||
for (const index of [2, 3]) {
|
||||
await fileRows(page).nth(index).click();
|
||||
await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
|
||||
}
|
||||
expect(lookups).toEqual([]);
|
||||
// The lookup is the exact identity, not a name search.
|
||||
await fileRows(page).first().click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open]')).toBeVisible();
|
||||
expect(lookups).toEqual(['?mode=workspace&root=0&path=shared%2Fsame.png&limit=5']);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('filter narrows the selected outcome', async ({ page, request }) => {
|
||||
@@ -545,6 +570,18 @@ test('job view renders 200-row cap note', async ({ page, request }) => {
|
||||
await filter(page).fill('file_00199');
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
await expect(resultsNote(page)).toHaveText(`Showing 1 of 250 · ${VIEWER_HINT}`);
|
||||
|
||||
// Review M12: with the list capped and the log closed, more files only update the counters; the detail is read
|
||||
// again only when the log opens with new lines.
|
||||
const reads = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); });
|
||||
await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } });
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('290');
|
||||
await page.waitForTimeout(1500);
|
||||
expect(reads).toEqual([]);
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect.poll(() => reads.length).toBe(1);
|
||||
await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png');
|
||||
});
|
||||
|
||||
test('cancel scan ends cancelled with partial counts', async ({ page, request }) => {
|
||||
@@ -552,8 +589,11 @@ test('cancel scan ends cancelled with partial counts', async ({ page, request })
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 9, counters: { added: 7, errors: 2 } });
|
||||
await openJob(page, job.id);
|
||||
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).click();
|
||||
// From the keyboard: when the job ends and Cancel scan goes away, focus moves to the job's heading (review M4).
|
||||
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
|
||||
await expect(heading(page).locator('h1')).toBeFocused();
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
|
||||
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0);
|
||||
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Cancelled after 9 of 19,622 files');
|
||||
@@ -577,6 +617,9 @@ test('busy start names the running job', async ({ page, request }) => {
|
||||
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
expect((await listed(request)).map(item => item.id)).toEqual([running.id]);
|
||||
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable.
|
||||
await page.waitForTimeout(7000);
|
||||
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible();
|
||||
await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click();
|
||||
await expect(heading(page).locator('h1')).toHaveText('Database maintenance');
|
||||
// A job of another kind cancels as "Cancel job".
|
||||
|
||||
@@ -6,7 +6,7 @@ reading files, so a test sets its progress and counters exactly:
|
||||
- ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``,
|
||||
``output_folders``) and returns its summary, or 409 while one runs;
|
||||
- ``POST /__test__/jobs/{id}/step`` records ``records`` (``{group, path,
|
||||
root, detail}`` file outcomes, counted, listed and logged as the scan
|
||||
root, root_index, detail}`` file outcomes, counted, listed and logged as the scan
|
||||
records them) and ``bulk`` (``{group, count, root}``: ``count`` numbered
|
||||
rows), sets ``processed`` (and ``total``) and ``counters``, then publishes
|
||||
the progress to every subscriber;
|
||||
@@ -28,6 +28,7 @@ POLL_SECONDS = 0.02
|
||||
|
||||
PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"}
|
||||
PREFIXES["no_metadata"] = "-"
|
||||
ROOT_INDEXES = {"output": 0, "input": 1}
|
||||
|
||||
|
||||
def outcome_records(data):
|
||||
@@ -40,11 +41,14 @@ def outcome_records(data):
|
||||
for record in records:
|
||||
if not isinstance(record, dict) or record.get("group") not in PREFIXES:
|
||||
raise web.HTTPBadRequest()
|
||||
root = str(record.get("root", "output"))
|
||||
rows.append(
|
||||
{
|
||||
"group": record["group"],
|
||||
"path": str(record.get("path", "")),
|
||||
"root": str(record.get("root", "output")),
|
||||
"root": root,
|
||||
# The fixture's listing order: output is root 0, input root 1.
|
||||
"root_index": record.get("root_index", ROOT_INDEXES.get(root)),
|
||||
"detail": str(record.get("detail", "")),
|
||||
}
|
||||
)
|
||||
@@ -59,6 +63,7 @@ def outcome_records(data):
|
||||
"group": bulk["group"],
|
||||
"path": f"bulk/file_{index:05d}.png",
|
||||
"root": str(bulk.get("root", "output")),
|
||||
"root_index": 0,
|
||||
"detail": f"Bulk row {index}",
|
||||
}
|
||||
for index in range(total)
|
||||
|
||||
@@ -111,12 +111,13 @@ test('store merges list and SSE updates and caps backoff', async () => {
|
||||
assert.deepEqual(store.running(), []);
|
||||
assert.equal(typeof LOST_DETAIL, 'string');
|
||||
|
||||
// The server is back without the job (a restart): it stays ended, earlier finished jobs stay listed.
|
||||
// The server is back without the job (a restart): it stays ended; finished jobs the server no longer lists are
|
||||
// dropped, so the list never outgrows the server's (review M3).
|
||||
world.listError = null;
|
||||
world.list = [];
|
||||
await world.fire();
|
||||
assert.equal(store.status, 'ready');
|
||||
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'lost'], [ID_A, 'finished'], [ID_B, 'finished']]);
|
||||
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'lost']]);
|
||||
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 0, 'no retry after a good read');
|
||||
assert.equal(world.toasts.length, 1, 'an ended job has no finish toast');
|
||||
world.dispose();
|
||||
@@ -260,7 +261,7 @@ test('summaries are validated and the copy follows the spec', async () => {
|
||||
const loose = parseJob({ id: ID_A, state: 'running', progress: 140.4, processed: -1, total: 1.5, counters: { added: 'x', errors: 2 },
|
||||
title: ' ', started_at: 'x', scope: [] });
|
||||
assert.deepEqual(loose, { id: ID_A, kind: '', title: 'Background job', state: 'running', started_at: null, finished_at: null,
|
||||
progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {} });
|
||||
progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {}, listed: {}, log_length: 0 });
|
||||
assert.equal(parseJob(summary({ progress: -3 })).progress, 0);
|
||||
|
||||
const scan = parseJob(summary({ progress: 42, processed: 8240, total: 19622, counters: { added: 1, errors: 1 } }));
|
||||
@@ -324,3 +325,47 @@ test('pagehide stops polling, closes streams and aborts the in-flight read; page
|
||||
assert.equal(signals.at(-1).aborted, true, 'disposing aborts the store reads');
|
||||
assert.equal(world.open().length, 0);
|
||||
});
|
||||
|
||||
test('one failed read is not a restart; two in a row are, and a good read resets the backoff', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, ...deps } = harness();
|
||||
world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900 })];
|
||||
const store = createActivityStore(deps);
|
||||
await store.load();
|
||||
// A single failed read (a proxy's 502) keeps the job running and its stream open (review M1).
|
||||
world.listError = apiError(502, 'Bad gateway', null);
|
||||
await store.load();
|
||||
assert.equal(store.status, 'error');
|
||||
assert.equal(store.find(ID_A).state, 'running');
|
||||
assert.equal(world.open().length, 1);
|
||||
assert.equal(await world.fire(), 1000);
|
||||
// The second failure in a row: the server is gone, so the running job ended with it.
|
||||
assert.equal(store.find(ID_A).state, 'lost');
|
||||
assert.equal(world.open().length, 0);
|
||||
for (let index = 0; index < 3; index += 1) await world.fire();
|
||||
// The server is back with a new running job: the backoff starts again at 1 s (review M2).
|
||||
world.listError = null;
|
||||
world.list = [summary({ id: ID_C })];
|
||||
await world.fire();
|
||||
assert.equal(store.status, 'ready');
|
||||
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'running'], [ID_A, 'lost']]);
|
||||
world.open()[0].onerror();
|
||||
assert.equal(await world.fire(), 1000);
|
||||
// A summary carries what its detail lists, for the job view's reads (review M12).
|
||||
send(world.open()[0], summary({ id: ID_C, listed: { added: 3, errors: -1 }, log_length: 7 }));
|
||||
assert.deepEqual([store.find(ID_C).listed, store.find(ID_C).log_length], [{ added: 3, errors: 0 }, 7]);
|
||||
world.dispose();
|
||||
});
|
||||
|
||||
test('numbers group the way the server writes them, whatever the browser locale', async () => {
|
||||
const { pillParts, finishToast, parseJob } = await source;
|
||||
const original = Number.prototype.toLocaleString;
|
||||
Number.prototype.toLocaleString = function (locale, ...rest) { return original.call(this, locale ?? 'de-DE', ...rest); };
|
||||
try {
|
||||
const job = parseJob(summary({ progress: 41, processed: 8240, total: 19622, counters: { added: 1420, errors: 18 } }));
|
||||
assert.equal(pillParts(job).text, 'Scanning · 41% · 8,240 / 19,622');
|
||||
assert.equal(finishToast({ ...job, state: 'finished' }), 'Scan finished · 1,420 added · 18 errors');
|
||||
} finally {
|
||||
Number.prototype.toLocaleString = original;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2094,6 +2094,127 @@ class TestActivityJobs(AdminAPITestCase):
|
||||
(legacy["processed"], legacy["found"], legacy["failed"]), (6, 4, 1)
|
||||
)
|
||||
|
||||
async def test_cancel_after_first_batch_keeps_partial_counts(self):
|
||||
"""Review M7: the real scan stops at the batch boundary, keeping batch 1."""
|
||||
import threading
|
||||
from unittest import mock
|
||||
|
||||
from py.api import admin
|
||||
|
||||
_write_png(self.output_dir / "a.png", prompt_text="a red square")
|
||||
_write_png(self.output_dir / "b.png", prompt_text="a blue circle")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
first_done, release = threading.Event(), threading.Event()
|
||||
original = self.api._ingest_scan_batch_sync
|
||||
|
||||
def gated(batch_results, roots=(), outcomes=None):
|
||||
counts = original(batch_results, roots=roots, outcomes=outcomes)
|
||||
first_done.set()
|
||||
release.wait(5)
|
||||
return counts
|
||||
|
||||
self.api._ingest_scan_batch_sync = gated
|
||||
with mock.patch.object(admin, "SCAN_BATCH_SIZE", 1):
|
||||
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
self.assertEqual(status, 202)
|
||||
job_id = started["job"]["id"]
|
||||
await asyncio.get_running_loop().run_in_executor(None, first_done.wait, 5)
|
||||
status, cancelled = await self._json(
|
||||
"POST", f"/prompt_manager/jobs/{job_id}/cancel"
|
||||
)
|
||||
self.assertEqual((status, cancelled["cancellation_requested"]), (200, True))
|
||||
release.set()
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
|
||||
status, body = await self._json("GET", f"/prompt_manager/jobs/{job_id}")
|
||||
job = body["job"]
|
||||
self.assertEqual(job["state"], "cancelled")
|
||||
self.assertEqual((job["processed"], job["total"]), (1, 2))
|
||||
self.assertEqual(sum(job["counters"].values()), job["processed"])
|
||||
self.assertEqual(job["counters"]["added"], 1)
|
||||
listed = [row["path"] for rows in job["outcomes"].values() for row in rows]
|
||||
self.assertEqual(len(listed), 1)
|
||||
self.assertIn(listed[0], {"a.png", "b.png"})
|
||||
self.assertIn("Scan cancelled · 1 file processed", job["log"][-1])
|
||||
# A cancel after the end is a no-op.
|
||||
status, again = await self._json(
|
||||
"POST", f"/prompt_manager/jobs/{job_id}/cancel"
|
||||
)
|
||||
self.assertEqual((status, again["cancellation_requested"]), (200, False))
|
||||
|
||||
async def test_scan_rows_carry_their_root_index(self):
|
||||
"""Review M9: two roots labelled "output" stay apart by listing index."""
|
||||
second = self.outside_dir / "output"
|
||||
second.mkdir(parents=True)
|
||||
os.environ[EXTRA_ROOTS_ENV] = str(second)
|
||||
_write_png(self.output_dir / "same.png", prompt_text="a red square")
|
||||
_write_png(second / "same.png", prompt_text="a blue circle")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [
|
||||
self._root(self.output_dir),
|
||||
self._root(second),
|
||||
]
|
||||
output_dirs = [Path(d) for d in self.api._get_all_output_dirs()]
|
||||
self.assertEqual(len(output_dirs), 2)
|
||||
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
status, body = await self._json(
|
||||
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
|
||||
)
|
||||
rows = body["job"]["outcomes"]["added"]
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual({row["root"] for row in rows}, {"output"})
|
||||
by_index = {row["root_index"]: row for row in rows}
|
||||
self.assertEqual(set(by_index), {0, 1})
|
||||
for index, row in by_index.items():
|
||||
self.assertEqual(row["path"], "same.png")
|
||||
self.assertTrue((output_dirs[index] / row["path"]).is_file())
|
||||
texts = {
|
||||
index: (
|
||||
"red"
|
||||
if output_dirs[index] == Path(self._root(self.output_dir))
|
||||
else "blue"
|
||||
)
|
||||
for index in by_index
|
||||
}
|
||||
for index, row in by_index.items():
|
||||
self.assertIn(texts[index], row["detail"])
|
||||
|
||||
async def test_scan_row_names_the_prompt_by_its_stored_title(self):
|
||||
"""Review M10: a row names the prompt as the Library does."""
|
||||
_write_png(self.output_dir / "gen.png", prompt_text="a red square\nmore")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
summaries = self.api.db.get_image_link_summaries
|
||||
|
||||
def titled(paths, allow_filename_fallback=True):
|
||||
found = summaries(paths, allow_filename_fallback)
|
||||
return {
|
||||
path: {**summary, "title": "Stored square title"}
|
||||
for path, summary in found.items()
|
||||
}
|
||||
|
||||
self.api.db.get_image_link_summaries = titled
|
||||
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
status, body = await self._json(
|
||||
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
|
||||
)
|
||||
(row,) = body["job"]["outcomes"]["added"]
|
||||
self.assertIn("“Stored square title”", row["detail"])
|
||||
|
||||
# Without a stored title (or an unreadable one): the text's first line.
|
||||
def broken(paths, allow_filename_fallback=True):
|
||||
raise RuntimeError("unreadable")
|
||||
|
||||
self.api.db.get_image_link_summaries = broken
|
||||
_write_png(self.output_dir / "next.png", prompt_text="a green cone\nmore")
|
||||
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
status, body = await self._json(
|
||||
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
|
||||
)
|
||||
details = {r["path"]: r["detail"] for r in body["job"]["outcomes"]["added"]}
|
||||
self.assertIn("“a green cone”", details["next.png"])
|
||||
|
||||
async def test_jobs_routes_404_and_409(self):
|
||||
_write_png(self.output_dir / "gen.png", prompt_text="a red square")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
|
||||
@@ -190,6 +190,26 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
self.assertEqual(data["images"], [])
|
||||
self.assertIsNone(data["next_cursor"])
|
||||
|
||||
async def test_path_filter_lists_only_that_exact_file(self):
|
||||
# Activity (D57): a scan row opens its file by root and exact relative path,
|
||||
# never by a substring that also matches the same name in other folders.
|
||||
self.media("ComfyUI_00001_.png")
|
||||
for day in range(3):
|
||||
self.media(f"2026-10-0{day + 1}/ComfyUI_00001_.png")
|
||||
self.media("2026-10-01/ComfyUI_00001_.png.bak.png")
|
||||
_, data = await self.get(path="ComfyUI_00001_.png")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["ComfyUI_00001_.png"])
|
||||
_, data = await self.get(path="2026-10-02/ComfyUI_00001_.png")
|
||||
self.assertEqual(
|
||||
[i["path"] for i in data["images"]], ["2026-10-02/ComfyUI_00001_.png"]
|
||||
)
|
||||
_, data = await self.get(path="missing.png")
|
||||
self.assertEqual(data["images"], [])
|
||||
for bad in ("../outside.png", "a" * 1025):
|
||||
with self.subTest(bad=bad[:12]):
|
||||
response, _ = await self.get(path=bad)
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_cursor_cannot_be_reused_with_changed_filters_or_tampered(self):
|
||||
self.media("a.png")
|
||||
self.media("b.png")
|
||||
|
||||
@@ -177,9 +177,13 @@ class TestJobRegistry(unittest.IsolatedAsyncioTestCase):
|
||||
"total",
|
||||
"counters",
|
||||
"scope",
|
||||
"listed",
|
||||
"log_length",
|
||||
},
|
||||
)
|
||||
self.assertEqual(summary["scope"], {"mode": "all"})
|
||||
# What the detail would list, so a view reads it only when that changed.
|
||||
self.assertEqual((summary["listed"], summary["log_length"]), ({}, 0))
|
||||
self.assertIsNone(summary["finished_at"])
|
||||
|
||||
gate.set()
|
||||
@@ -404,3 +408,19 @@ class TestJobOutcomesAndLog(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestListedSummary(unittest.TestCase):
|
||||
def test_summary_counts_listed_rows_and_log_lines(self):
|
||||
job = Job("scan", "Scan", groups=("added", "errors"))
|
||||
job.record(
|
||||
"added", {"path": "a.png", "root": "output", "root_index": 0}, "+ a.png"
|
||||
)
|
||||
job.record("errors", {"path": "b.png", "root_index": "x"}, "! b.png")
|
||||
summary = job.summary()
|
||||
self.assertEqual(summary["listed"], {"added": 1, "errors": 1})
|
||||
self.assertEqual(summary["log_length"], 2)
|
||||
rows = job.detail()["outcomes"]
|
||||
self.assertEqual(rows["added"][0]["root_index"], 0)
|
||||
# Only a whole non-negative number is a root index.
|
||||
self.assertIsNone(rows["errors"][0]["root_index"])
|
||||
|
||||
@@ -33,8 +33,7 @@ export const NO_MATCH = 'No listed file matches this filter.';
|
||||
export const VIEWER_HINT = 'each row opens the image in the viewer';
|
||||
export const FILE_GONE = 'This file is no longer in Images. Rescan from Import and try again.';
|
||||
export const OPEN_FAILED = 'The file could not be opened. Try again.';
|
||||
const LISTING_LIMIT = 100;
|
||||
const MAX_QUERY = 500;
|
||||
const LISTING_LIMIT = 5;
|
||||
|
||||
/** The counter tiles, in the spec's order and the mockup's tones. */
|
||||
export const TILES = Object.freeze([
|
||||
@@ -55,7 +54,8 @@ function element(tag, text, className) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const number = value => value.toLocaleString();
|
||||
// Grouped as the server writes its lines, whatever the browser's locale (as images.mjs does).
|
||||
const number = value => value.toLocaleString('en-US');
|
||||
const isText = value => typeof value === 'string';
|
||||
|
||||
/** A job's detail read: the summary plus its listed rows per outcome and its log; null when it is not one. */
|
||||
@@ -67,7 +67,9 @@ export function parseDetail(raw) {
|
||||
for (const [group, rows] of Object.entries(groups)) {
|
||||
if (!Array.isArray(rows)) continue;
|
||||
outcomes[group] = rows.filter(row => row && isText(row.path) && row.path)
|
||||
.map(row => ({ path: row.path, root: isText(row.root) ? row.root : '', detail: isText(row.detail) ? row.detail : '' }));
|
||||
.map(row => ({ path: row.path, root: isText(row.root) ? row.root : '',
|
||||
root_index: Number.isSafeInteger(row.root_index) && row.root_index >= 0 ? row.root_index : null,
|
||||
detail: isText(row.detail) ? row.detail : '' }));
|
||||
}
|
||||
const log = Array.isArray(raw.log) ? raw.log.filter(isText) : [];
|
||||
return { job, outcomes, log };
|
||||
@@ -187,7 +189,11 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
let readTimer = null;
|
||||
let roots = null;
|
||||
let lastState = null;
|
||||
const current = () => store.find(jobId) ?? detail?.job ?? null;
|
||||
// Set once this page's own list read answered, or the detail read said the job is unknown.
|
||||
let listedOnce = false;
|
||||
let notFound = false;
|
||||
// The store is the truth once it has read the list: a job it no longer has was pruned by the server.
|
||||
const current = () => store.find(jobId) ?? (listedOnce ? null : detail?.job ?? null);
|
||||
|
||||
const inspection = createInspectionController({ api });
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
@@ -203,6 +209,24 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
selected ??= defaultOutcome(job.counters);
|
||||
}
|
||||
|
||||
/** The job is not (or no longer) in this session: say so instead of an empty view. */
|
||||
function showMissing() {
|
||||
if (mounted) {
|
||||
mounted = false;
|
||||
const focused = main.contains(document.activeElement) && document.activeElement !== parts.h1;
|
||||
download.remove();
|
||||
cancel.remove();
|
||||
progressPart.node.remove();
|
||||
resultsPart.node.remove();
|
||||
log.remove();
|
||||
parts.h1.textContent = 'Activity';
|
||||
parts.description.textContent = '';
|
||||
document.title = 'Activity · Prompt Manager';
|
||||
if (focused) parts.h1.focus();
|
||||
}
|
||||
if (!missing.isConnected) main.append(missing);
|
||||
}
|
||||
|
||||
// Listed rows only ever grow, so a row keeps its node across reads: a focused row (or the viewer's trigger) stays.
|
||||
let rowNodes = new Map();
|
||||
function rowNode(row, index, openable) {
|
||||
@@ -237,7 +261,10 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
|
||||
function render() {
|
||||
const job = current();
|
||||
if (!job) return;
|
||||
if (!job) {
|
||||
if (listedOnce || notFound) showMissing();
|
||||
return;
|
||||
}
|
||||
showBody(job);
|
||||
const scan = job.kind === 'scan';
|
||||
parts.h1.textContent = job.title;
|
||||
@@ -246,7 +273,9 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
cancel.textContent = scan ? 'Cancel scan' : 'Cancel job';
|
||||
if (job.state === 'running') {
|
||||
if (!cancel.isConnected) parts.actions.append(cancel);
|
||||
} else {
|
||||
} else if (cancel.isConnected) {
|
||||
// A focused Cancel that goes away hands its focus to the job's heading, never to the page body.
|
||||
if (document.activeElement === cancel) parts.h1.focus();
|
||||
cancel.remove();
|
||||
}
|
||||
status.textContent = progressLine(job);
|
||||
@@ -289,7 +318,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
// An unknown job: the page says so unless the store still knows it (then the rows wait for a later read).
|
||||
if (error?.status === 404 && !current()) main.append(missing);
|
||||
if (error?.status === 404) notFound = true;
|
||||
} finally {
|
||||
reading = false;
|
||||
}
|
||||
@@ -297,15 +326,31 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
if (readAgain) { readAgain = false; scheduleRead(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the detail read would change the page: more listed rows in any outcome, or (with the log open) more log
|
||||
* lines. Counters come with the summary, so a job whose lists are capped and whose log is closed is not re-read.
|
||||
*/
|
||||
function detailChanged(job) {
|
||||
if (!detail) return true;
|
||||
const groups = new Set([...Object.keys(job.listed), ...Object.keys(detail.outcomes)]);
|
||||
for (const group of groups) if ((job.listed[group] ?? 0) !== (detail.outcomes[group]?.length ?? 0)) return true;
|
||||
return log.open && job.log_length !== detail.log.length;
|
||||
}
|
||||
|
||||
function follow() {
|
||||
const job = store.find(jobId);
|
||||
render();
|
||||
if (!job) return;
|
||||
// The listed rows change while it runs, and once more as it ends; a job lost with the server cannot be read.
|
||||
if (job.state === 'running' || (lastState === 'running' && job.state !== 'lost')) scheduleRead();
|
||||
// A job lost with the server cannot be read; any other is read while what it lists changes.
|
||||
if (job.state !== 'lost' && (job.state === 'running' || lastState === 'running') && detailChanged(job)) scheduleRead();
|
||||
lastState = job.state;
|
||||
}
|
||||
|
||||
scope.listen(log, 'toggle', () => {
|
||||
const job = store.find(jobId);
|
||||
if (log.open && job && job.state !== 'lost' && detailChanged(job)) scheduleRead();
|
||||
});
|
||||
|
||||
scope.listen(cancel, 'click', async () => {
|
||||
if (cancel.getAttribute('aria-disabled') === 'true') return;
|
||||
cancel.setAttribute('aria-disabled', 'true');
|
||||
@@ -329,17 +374,20 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
if (button?.row) openRow(button.row, button);
|
||||
});
|
||||
|
||||
/** The listing's own item for a row (its source by label, then its path), so the viewer reads it as Images does. */
|
||||
/**
|
||||
* The listing's own item for a row, by its exact identity (D57): the root index the scan recorded, still carrying
|
||||
* the row's label (so a reordered or replaced source never opens another root's file), and its exact relative path.
|
||||
*/
|
||||
async function listed(row) {
|
||||
if (row.root_index === null) return null;
|
||||
if (!roots) {
|
||||
const data = await api.get('/prompt_manager/gallery/subfolders?mode=workspace', { signal: scope.signal });
|
||||
roots = Array.isArray(data?.roots) ? data.roots : [];
|
||||
}
|
||||
const root = roots.find(item => item?.label === row.root && Number.isSafeInteger(item.index));
|
||||
if (!root) return null;
|
||||
const query = row.path.length > MAX_QUERY ? row.path.split('/').at(-1).slice(0, MAX_QUERY) : row.path;
|
||||
const params = new URLSearchParams({ mode: 'workspace', root: String(root.index), q: query,
|
||||
limit: String(LISTING_LIMIT), sort: 'newest' });
|
||||
const root = roots.find(item => item?.index === row.root_index);
|
||||
if (!root || root.label !== row.root) return null;
|
||||
const params = new URLSearchParams({ mode: 'workspace', root: String(root.index), path: row.path,
|
||||
limit: String(LISTING_LIMIT) });
|
||||
const page = await api.get(`/prompt_manager/images/output?${params}`, { signal: scope.signal });
|
||||
return (Array.isArray(page?.images) ? page.images : []).find(item => item?.path === row.path
|
||||
&& item.root_index === root.index) ?? null;
|
||||
@@ -364,6 +412,10 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
scope.cleanup(store.subscribe(follow));
|
||||
render();
|
||||
read();
|
||||
// A job that finished before this page loaded is only in the list read.
|
||||
store.load();
|
||||
// A job that finished before this page loaded is only in the list read; once it answered, the store decides.
|
||||
store.load().then(() => {
|
||||
if (scope.signal.aborted || store.status !== 'ready') return;
|
||||
listedOnce = true;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const JOBS_URL = '/prompt_manager/jobs';
|
||||
export const BACKOFF_FIRST_MS = 1000;
|
||||
export const BACKOFF_MAX_MS = 30000;
|
||||
export const POLL_MS = 15000;
|
||||
export const LOST_AFTER_FAILURES = 2;
|
||||
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']);
|
||||
@@ -41,6 +42,9 @@ export function parseJob(raw) {
|
||||
if (!plainObject(raw) || typeof raw.id !== 'string' || !JOB_ID.test(raw.id) || !SERVER_STATES.has(raw.state)) return null;
|
||||
const counters = {};
|
||||
if (plainObject(raw.counters)) for (const [key, value] of Object.entries(raw.counters)) counters[key] = count(value);
|
||||
// What the job's detail lists per outcome, and its log length: a view re-reads the detail only when they change.
|
||||
const listed = {};
|
||||
if (plainObject(raw.listed)) for (const [key, value] of Object.entries(raw.listed)) listed[key] = count(value);
|
||||
const progress = typeof raw.progress === 'number' && Number.isFinite(raw.progress) ? Math.round(raw.progress) : 0;
|
||||
return {
|
||||
id: raw.id,
|
||||
@@ -55,10 +59,13 @@ export function parseJob(raw) {
|
||||
total: count(raw.total),
|
||||
counters,
|
||||
scope: plainObject(raw.scope) ? { ...raw.scope } : {},
|
||||
listed,
|
||||
log_length: count(raw.log_length),
|
||||
};
|
||||
}
|
||||
|
||||
const number = value => value.toLocaleString();
|
||||
// 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`;
|
||||
@@ -113,6 +120,8 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
|
||||
let jobs = [];
|
||||
let status = 'idle';
|
||||
let attempt = 0;
|
||||
// Failed list reads in a row: one (a proxy's 502, a dropped poll) is not yet a restart.
|
||||
let failures = 0;
|
||||
let retryTimer = null;
|
||||
let pollTimer = null;
|
||||
let reads = 0;
|
||||
@@ -155,7 +164,7 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
|
||||
};
|
||||
source.onerror = () => {
|
||||
closeStream(id);
|
||||
if (find(id)?.state === 'running') scheduleResync();
|
||||
if (find(id)?.state === 'running') scheduleResync({ drop: true });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,12 +180,15 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Replace the jobs with the server's list; a running job it no longer has has ended with the server. */
|
||||
/**
|
||||
* Replace the jobs with the server's list. A running job it no longer has ended with the server and stays as
|
||||
* ended; a finished job it no longer lists was pruned there and is dropped here too.
|
||||
*/
|
||||
function merge(list) {
|
||||
const next = list.map(parseJob).filter(Boolean).map(job => settled(find(job.id), job));
|
||||
const ids = new Set(next.map(job => job.id));
|
||||
const carried = jobs.filter(job => !ids.has(job.id))
|
||||
.map(job => job.state === 'running' ? { ...job, state: 'lost' } : job);
|
||||
const carried = jobs.filter(job => !ids.has(job.id) && (job.state === 'running' || job.state === 'lost'))
|
||||
.map(job => ({ ...job, state: 'lost' }));
|
||||
for (const job of next) announce(find(job.id), job);
|
||||
jobs = [...next, ...carried];
|
||||
follow();
|
||||
@@ -187,13 +199,14 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
|
||||
follow();
|
||||
}
|
||||
|
||||
function scheduleResync() {
|
||||
/** Re-read after the backoff; `drop` marks a re-read for a dropped stream, which keeps the backoff growing. */
|
||||
function scheduleResync({ drop = false } = {}) {
|
||||
if (disposed || suspended || retryTimer !== null) return;
|
||||
const delay = backoffDelay(attempt);
|
||||
attempt += 1;
|
||||
retryTimer = timers.setTimeout(() => {
|
||||
retryTimer = null;
|
||||
load();
|
||||
load({ drop });
|
||||
}, delay);
|
||||
}
|
||||
|
||||
@@ -209,7 +222,7 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
|
||||
}
|
||||
|
||||
/** Read this session's jobs. A failed read marks running jobs ended and tries again after the backoff. */
|
||||
async function load() {
|
||||
async function load({ drop = false } = {}) {
|
||||
if (disposed || suspended) return;
|
||||
const read = ++reads;
|
||||
// Only the newest read applies, so an older one still in flight is aborted.
|
||||
@@ -223,10 +236,15 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
|
||||
if (!Array.isArray(result?.jobs)) throw new TypeError('Invalid job list');
|
||||
merge(result.jobs);
|
||||
status = 'ready';
|
||||
// A good read ends the backoff (the server is back, or nothing dropped), except for a stream that keeps
|
||||
// dropping while the list reads fine: that waits longer each time, and a received message resets it.
|
||||
if (failures > 0 || !drop) attempt = 0;
|
||||
failures = 0;
|
||||
schedulePoll();
|
||||
} catch (error) {
|
||||
if (disposed || suspended || scope.signal?.aborted || controller.signal.aborted || read !== reads) return;
|
||||
markLost();
|
||||
failures += 1;
|
||||
if (failures >= LOST_AFTER_FAILURES) markLost();
|
||||
status = 'error';
|
||||
scheduleResync();
|
||||
}
|
||||
|
||||
@@ -146,6 +146,8 @@ function itemFor(job, scope) {
|
||||
if (!progress.isConnected) item.insertBefore(progress, actions.isConnected ? actions : null);
|
||||
if (!actions.isConnected) item.append(actions);
|
||||
} else {
|
||||
// A focused Cancel job that goes away hands its focus to the item's title link, never to the page body.
|
||||
if (actions.contains(document.activeElement)) link.focus();
|
||||
progress.remove();
|
||||
actions.remove();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
/** The shell's shared toast (`#workspace-toast`, role="status"), used by every workspace page. */
|
||||
const TOAST_MS = 6500;
|
||||
export const TOAST_MS = 6500;
|
||||
/** A toast with a link stays longer, so a keyboard user can reach it (D57, WCAG 2.2.1). */
|
||||
export const LINK_TOAST_MS = 20000;
|
||||
let toastTimer;
|
||||
// Stops waiting for the pointer or focus to leave a toast that a newer one replaced.
|
||||
let releaseWait = null;
|
||||
|
||||
/** Whether the pointer or keyboard focus rests on the toast. */
|
||||
const held = toast => typeof toast.matches === 'function' && toast.matches(':hover, :focus-within');
|
||||
|
||||
/** Clear the toast when its time is up, unless the pointer or focus is on it: then wait until they leave. */
|
||||
function scheduleClear(toast, delay) {
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
if (held(toast)) {
|
||||
const release = () => {
|
||||
toast.removeEventListener('pointerleave', leave);
|
||||
toast.removeEventListener('focusout', later);
|
||||
releaseWait = null;
|
||||
};
|
||||
const leave = () => {
|
||||
if (held(toast)) return;
|
||||
release();
|
||||
scheduleClear(toast, TOAST_MS);
|
||||
};
|
||||
// focusout fires before the next element takes focus, so it checks once focus has moved.
|
||||
const later = () => setTimeout(leave);
|
||||
toast.addEventListener('pointerleave', leave);
|
||||
toast.addEventListener('focusout', later);
|
||||
releaseWait = release;
|
||||
return;
|
||||
}
|
||||
toast.textContent = '';
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one outcome in the shared workspace toast; it clears itself like the concept's. A message given as parts
|
||||
* (`['Another job is running: ', {text, href}, '.']`) renders each `{text, href}` as a link (D57).
|
||||
* (`['Another job is running: ', {text, href}, '.']`) renders each `{text, href}` as a link (D57); such a toast stays
|
||||
* for LINK_TOAST_MS and while the pointer or focus is on it.
|
||||
*/
|
||||
export function showWorkspaceToast(message) {
|
||||
const toast = document.getElementById('workspace-toast');
|
||||
if (!toast) return;
|
||||
releaseWait?.();
|
||||
if (Array.isArray(message)) {
|
||||
toast.replaceChildren(...message.map(part => {
|
||||
if (typeof part === 'string') return document.createTextNode(part);
|
||||
@@ -20,6 +55,6 @@ export function showWorkspaceToast(message) {
|
||||
} else {
|
||||
toast.textContent = message;
|
||||
}
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {toast.textContent = '';}, TOAST_MS);
|
||||
const linked = Array.isArray(message) && message.some(part => typeof part !== 'string');
|
||||
scheduleClear(toast, linked ? LINK_TOAST_MS : TOAST_MS);
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ export function openScanReviewDialog({ scope, sources, onBackup, onStart, trigge
|
||||
requireCallbacks({ onBackup, onStart });
|
||||
if (!Number.isSafeInteger(sources) || sources < 0) throw new TypeError('The scan review needs its source count');
|
||||
const grid = el('dl', undefined, 'meta-grid');
|
||||
for (const [term, value] of [['Sources', `${sources.toLocaleString()} output ${sources === 1 ? 'folder' : 'folders'}`],
|
||||
for (const [term, value] of [['Sources', `${sources.toLocaleString('en-US')} output ${sources === 1 ? 'folder' : 'folders'}`],
|
||||
['Scope', 'All files']]) {
|
||||
const entry = el('div');
|
||||
entry.append(el('dt', term), el('dd', value));
|
||||
|
||||
Reference in New Issue
Block a user